diff --git a/Sources/AppDelegate+FocusedWorkspaceCloseShortcut.swift b/Sources/AppDelegate+FocusedWorkspaceCloseShortcut.swift new file mode 100644 index 000000000000..17d7b12fb2aa --- /dev/null +++ b/Sources/AppDelegate+FocusedWorkspaceCloseShortcut.swift @@ -0,0 +1,45 @@ +import AppKit + +extension AppDelegate { + func mainWindowContextForFocusedWorkspaceCloseShortcut(event: NSEvent) -> MainWindowContext? { + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { + return context + } + + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + return context + } + + if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.closeWorkspace") { + return context + } + + if shortcutEventHasAddressableWindow(event) { + if let eventWindow = resolvedShortcutEventWindow(event), + cmuxWindowShouldOwnCloseShortcut(eventWindow) { + return preferredMainWindowContextForShortcutRouting(event: event) + } + return nil + } + + return nil + } + + @discardableResult + func closeWorkspaceFromFocusedShortcut(event: NSEvent) -> Bool { + guard let context = mainWindowContextForFocusedWorkspaceCloseShortcut(event: event) else { + return false + } + + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + activateMainWindowContext(context) + } + + context.tabManager.closeCurrentWorkspaceWithConfirmation() + return true + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 508293311dd2..6abba7da1260 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6294,7 +6294,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - private func resolvedShortcutEventWindow(_ event: NSEvent) -> NSWindow? { + func resolvedShortcutEventWindow(_ event: NSEvent) -> NSWindow? { if let window = event.window { return window } @@ -8079,7 +8079,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return fallback } - private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { + func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { guard let event else { return false } // NSEvent.windowNumber can be 0 for responder-chain events that are not // actually bound to an NSWindow (notably some WebKit key paths). @@ -13383,7 +13383,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .closeWorkspace) { - tabManagerForFocusedCloseShortcut(event: event)?.closeCurrentWorkspaceWithConfirmation() + _ = closeWorkspaceFromFocusedShortcut(event: event) return true } @@ -15747,7 +15747,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - private func activateMainWindowContext(_ context: MainWindowContext?) { + func activateMainWindowContext(_ context: MainWindowContext?) { guard let context else { tabManager = nil sidebarState = nil diff --git a/Sources/TabManager+CloseConfirmationTelemetry.swift b/Sources/TabManager+CloseConfirmationTelemetry.swift new file mode 100644 index 000000000000..6d13367f6230 --- /dev/null +++ b/Sources/TabManager+CloseConfirmationTelemetry.swift @@ -0,0 +1,37 @@ +import AppKit +import Foundation + +extension TabManager { + func recordCloseConfirmationTarget(workspaceIds: [UUID]) { +#if DEBUG + let joinedWorkspaceIds = workspaceIds.map(\.uuidString).joined(separator: ",") + UITestRecorder.record([ + "closeConfirmationTargetWindowId": AppDelegate.shared?.windowId(for: self)?.uuidString ?? "", + "closeConfirmationTargetWorkspaceId": workspaceIds.first?.uuidString ?? "", + "closeConfirmationTargetWorkspaceIds": joinedWorkspaceIds, + ]) +#endif + } + + func recordCloseConfirmationSheetPresentation(hostWindow: NSWindow) { +#if DEBUG + var updates = [ + "closeConfirmationPresentation": "sheet", + "closeConfirmationAttachedSheet": "1", + ] + if let hostWindowId = AppDelegate.shared?.mainWindowId(from: hostWindow)?.uuidString { + updates["closeConfirmationHostWindowId"] = hostWindowId + } + UITestRecorder.record(updates) +#endif + } + + func recordCloseConfirmationAppModalPresentation(hostWindowHadAttachedSheet: Bool) { +#if DEBUG + UITestRecorder.record([ + "closeConfirmationPresentation": "appModal", + "closeConfirmationAttachedSheet": hostWindowHadAttachedSheet ? "1" : "0", + ]) +#endif + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index fcf154a0325f..ec4b1f3ac0c3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2201,6 +2201,7 @@ class TabManager: ObservableObject { @discardableResult func closeWorkspaceWithConfirmation(_ workspace: Workspace) -> Bool { + recordCloseConfirmationTarget(workspaceIds: [workspace.id]) if workspace.isPinned { guard confirmPinnedWorkspaceClose(source: .workspace) else { return false } closeWorkspaceIfRunningProcess(workspace, requiresConfirmation: false) @@ -2264,6 +2265,7 @@ class TabManager: ObservableObject { closeWorkspaceFromCloseTabGesture(workspaces[0]) return } + recordCloseConfirmationTarget(workspaceIds: workspaces.map(\.id)) let plan = closeWorkspacesPlan(for: workspaces) if shouldConfirmClose(requiresConfirmation: true, source: .tabClose) { @@ -2394,24 +2396,12 @@ class TabManager: ObservableObject { alert, presentingWindow: closeConfirmationPresentingWindow() ) { presentation in - #if DEBUG switch presentation { case .sheet(let hostWindow): - // The sheet attaches after this hook returns, so read the - // attachment on the next runloop turn (during the modal loop). - DispatchQueue.main.async { - UITestRecorder.record([ - "closeConfirmationPresentation": "sheet", - "closeConfirmationAttachedSheet": hostWindow.attachedSheet == nil ? "0" : "1", - ]) - } + self.recordCloseConfirmationSheetPresentation(hostWindow: hostWindow) case .appModal(let hostWindowHadAttachedSheet): - UITestRecorder.record([ - "closeConfirmationPresentation": "appModal", - "closeConfirmationAttachedSheet": hostWindowHadAttachedSheet ? "1" : "0", - ]) + self.recordCloseConfirmationAppModalPresentation(hostWindowHadAttachedSheet: hostWindowHadAttachedSheet) } - #endif } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f024df30f9d7..bce94929dc2e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ C4160A030000000000000001 /* AppDelegate+ClosedItemHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A030000000000000002 /* AppDelegate+ClosedItemHistory.swift */; }; C3677004000000000000001 /* AppDelegate+CmuxSSHURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3677004000000000000002 /* AppDelegate+CmuxSSHURL.swift */; }; E3309A01 /* AppDelegate+EqualizeSplitsShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A02 /* AppDelegate+EqualizeSplitsShortcut.swift */; }; + F8C0A0010000000000000001 /* AppDelegate+FocusedWorkspaceCloseShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C0A0010000000000000002 /* AppDelegate+FocusedWorkspaceCloseShortcut.swift */; }; C4160A040000000000000001 /* AppDelegate+FocusHistoryContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A040000000000000002 /* AppDelegate+FocusHistoryContextMenu.swift */; }; 3865A0063865A0063865A006 /* AppDelegate+GlobalSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0063865B0063865B006 /* AppDelegate+GlobalSearch.swift */; }; D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */; }; @@ -174,6 +175,7 @@ B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; + F8C0A0030000000000000001 /* CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C0A0030000000000000002 /* CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift */; }; B900001BA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900001CA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */; }; C10D00010000000000000001 /* CloudVMActionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D00020000000000000002 /* CloudVMActionLauncher.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; @@ -812,6 +814,7 @@ D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */; }; + F8C0A0020000000000000001 /* TabManager+CloseConfirmationTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C0A0020000000000000002 /* TabManager+CloseConfirmationTelemetry.swift */; }; D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; E3309A03 /* TabManager+EqualizeSplits.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A04 /* TabManager+EqualizeSplits.swift */; }; E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */; }; @@ -1130,6 +1133,7 @@ C4160A030000000000000002 /* AppDelegate+ClosedItemHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+ClosedItemHistory.swift"; sourceTree = ""; }; C3677004000000000000002 /* AppDelegate+CmuxSSHURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CmuxSSHURL.swift"; sourceTree = ""; }; E3309A02 /* AppDelegate+EqualizeSplitsShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+EqualizeSplitsShortcut.swift"; sourceTree = ""; }; + F8C0A0010000000000000002 /* AppDelegate+FocusedWorkspaceCloseShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+FocusedWorkspaceCloseShortcut.swift"; sourceTree = ""; }; C4160A040000000000000002 /* AppDelegate+FocusHistoryContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+FocusHistoryContextMenu.swift"; sourceTree = ""; }; 3865B0063865B0063865B006 /* AppDelegate+GlobalSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Search/AppDelegate+GlobalSearch.swift"; sourceTree = ""; }; D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; @@ -1247,6 +1251,7 @@ B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; + F8C0A0030000000000000002 /* CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift"; sourceTree = ""; }; B900001CA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspacesConfirmDialogUITests.swift; sourceTree = ""; }; C10D00020000000000000002 /* CloudVMActionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudVMActionLauncher.swift; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1819,6 +1824,7 @@ D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceGroups.swift"; sourceTree = ""; }; + F8C0A0020000000000000002 /* TabManager+CloseConfirmationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+CloseConfirmationTelemetry.swift"; sourceTree = ""; }; D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; E3309A04 /* TabManager+EqualizeSplits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+EqualizeSplits.swift"; sourceTree = ""; }; E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+FocusHistoryHosting.swift"; sourceTree = ""; }; @@ -2160,6 +2166,7 @@ B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */, B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */, B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, + F8C0A0030000000000000002 /* CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift */, B900001CA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */, B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, @@ -2419,6 +2426,7 @@ C10D51700000000000000001 /* ClosedItemHistory.swift */, D1FFC0DE000000000000D001 /* DiffCommentSubmissionPool.swift */, D1FFC0DE000000000000A001 /* DiffCommentStore.swift */, + F8C0A0020000000000000002 /* TabManager+CloseConfirmationTelemetry.swift */, E3309A04 /* TabManager+EqualizeSplits.swift */, C5CC15CEAA80B779ADEE78AA /* WorkspaceActionDispatcher.swift */, D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */, @@ -2561,6 +2569,7 @@ C3677002000000000000002 /* CmuxSSHURLRequest.swift */, C3677004000000000000002 /* AppDelegate+CmuxSSHURL.swift */, E3309A02 /* AppDelegate+EqualizeSplitsShortcut.swift */, + F8C0A0010000000000000002 /* AppDelegate+FocusedWorkspaceCloseShortcut.swift */, D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */, 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */, 4EDE10010000000000000002 /* AppDelegate+NotificationDeliverySeams.swift */, @@ -3613,6 +3622,7 @@ C4160A030000000000000001 /* AppDelegate+ClosedItemHistory.swift in Sources */, C3677004000000000000001 /* AppDelegate+CmuxSSHURL.swift in Sources */, E3309A01 /* AppDelegate+EqualizeSplitsShortcut.swift in Sources */, + F8C0A0010000000000000001 /* AppDelegate+FocusedWorkspaceCloseShortcut.swift in Sources */, C4160A040000000000000001 /* AppDelegate+FocusHistoryContextMenu.swift in Sources */, 3865A0063865A0063865A006 /* AppDelegate+GlobalSearch.swift in Sources */, D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */, @@ -4009,6 +4019,7 @@ D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */, + F8C0A0020000000000000001 /* TabManager+CloseConfirmationTelemetry.swift in Sources */, D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */, E3309A03 /* TabManager+EqualizeSplits.swift in Sources */, E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */, @@ -4225,6 +4236,7 @@ B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, + F8C0A0030000000000000001 /* CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift in Sources */, B900001BA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift in Sources */, C0DE32470000000000000005 /* CommandPaletteIdentifierClipboardUITests.swift in Sources */, D10000000000000000B00000 /* ControlSocketReadinessUITestSupport.swift in Sources */, diff --git a/cmuxUITests/CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift b/cmuxUITests/CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift new file mode 100644 index 000000000000..f8d356efb2b1 --- /dev/null +++ b/cmuxUITests/CloseWorkspacesConfirmDialogUITests+FocusedWindowHelpers.swift @@ -0,0 +1,155 @@ +import Darwin +import Foundation +import XCTest + +extension CloseWorkspacesConfirmDialogUITests { + func requireUUID( + from response: String?, + context: String, + file: StaticString = #filePath, + line: UInt = #line + ) -> String { + guard let response, + let uuid = response + .split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" }) + .map(String.init) + .first(where: { UUID(uuidString: $0) != nil }) else { + XCTFail("Expected UUID in \(context) response. response=\(response ?? "")", file: file, line: line) + return "" + } + return uuid + } + + func waitForKeyWindow(_ windowId: String, timeout: TimeInterval) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.keyWindowId() == windowId + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + + private func keyWindowId() -> String? { + guard let response = socketCommand("list_windows") else { return nil } + for line in response.split(separator: "\n") { + let parts = line + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: " ") + .map(String.init) + guard parts.first == "*", parts.count >= 3 else { continue } + return parts[2] + } + return nil + } +} + +final class CloseWorkspacesControlSocketClient { + private let path: String + private let responseTimeout: TimeInterval + + init(path: String, responseTimeout: TimeInterval = 2.0) { + self.path = path + self.responseTimeout = responseTimeout + } + + func sendLine(_ line: String) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var socketTimeout = timeval( + tv_sec: Int(responseTimeout.rounded(.down)), + tv_usec: Int32(((responseTimeout - floor(responseTimeout)) * 1_000_000).rounded()) + ) + + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout.size) + ) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let bytes = Array(path.utf8CString) + guard bytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { p in + let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for i in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + bytes.count) + addr.sun_len = UInt8(min(Int(addrLen), 255)) + + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + connect(fd, sa, addrLen) + } + } + guard connected == 0 else { return nil } + + let payload = line + "\n" + let wrote: Bool = payload.withCString { cstr in + var remaining = strlen(cstr) + var p = UnsafeRawPointer(cstr) + while remaining > 0 { + let n = write(fd, p, remaining) + if n <= 0 { return false } + remaining -= n + p = p.advanced(by: n) + } + return true + } + guard wrote else { return nil } + _ = shutdown(fd, SHUT_WR) + + var buf = [UInt8](repeating: 0, count: 4096) + var accum = "" + while true { + let n = read(fd, &buf, buf.count) + if n < 0 { + let code = errno + if code == EAGAIN || code == EWOULDBLOCK { + break + } + return nil + } + if n <= 0 { break } + if let chunk = String(bytes: buf[0..")" + ) + XCTAssertEqual(socketCommand("current_workspace"), focusedWorkspaceId) + + app.typeKey("w", modifierFlags: [.command, .shift]) + + let target = waitForJSONKey( + "closeConfirmationTargetWorkspaceId", + equals: focusedWorkspaceId, + atPath: recorderPath, + timeout: 5.0 + ) + XCTAssertEqual( + target?["closeConfirmationTargetWindowId"], + focusedWindowId, + "Cmd+Shift+W should target the selected workspace in the focused/key window, not the other cmux window. recorder=\(target ?? loadJSON(atPath: recorderPath) ?? [:])" + ) + + let presentation = waitForJSONKey( + "closeConfirmationHostWindowId", + equals: focusedWindowId, + atPath: recorderPath, + timeout: 5.0 + ) + XCTAssertEqual( + presentation?["closeConfirmationPresentation"], + "sheet", + "Close workspace confirmation should attach to the same focused window that owns the target workspace. recorder=\(presentation ?? loadJSON(atPath: recorderPath) ?? [:])" + ) + + clickCancelOnCloseWorkspaceAlert(app: app) + } + private func configureSocketLaunchEnvironment(_ app: XCUIApplication) { app.launchArguments += ["-socketControlMode", "allowAll"] app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] @@ -222,7 +289,22 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { } ) if let resolvedPath { socketPath = resolvedPath } - return ready + if ready { + return true + } + if let diagnostics = loadJSON(atPath: diagnosticsPath), + controlSocketDiagnosticsReportReady(diagnostics) { + if let expectedPath = diagnostics["socketExpectedPath"], + !expectedPath.isEmpty, + FileManager.default.fileExists(atPath: expectedPath) { + socketPath = expectedPath + return true + } else if let readyCandidate = socketCandidates().first(where: { FileManager.default.fileExists(atPath: $0) }) { + socketPath = readyCandidate + return true + } + } + return false } private func socketCandidates() -> [String] { @@ -284,8 +366,8 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { return json } - private func socketCommand(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? { - if let response = ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(cmd) { + func socketCommand(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? { + if let response = CloseWorkspacesControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(cmd) { return response } return socketCommandViaNetcat(cmd, responseTimeout: responseTimeout) @@ -356,6 +438,23 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { } } + private func clickCancelOnCloseWorkspaceAlert(app: XCUIApplication) { + let dialog = closeWorkspaceDialog(app: app) + if dialog.exists { + dialog.buttons["Cancel"].firstMatch.click() + return + } + let alert = closeWorkspaceAlert(app: app) + if alert.exists { + alert.buttons["Cancel"].firstMatch.click() + return + } + let anyDialog = app.dialogs.firstMatch + if anyDialog.exists, anyDialog.buttons["Cancel"].exists { + anyDialog.buttons["Cancel"].firstMatch.click() + } + } + private func closeWorkspacesDialog(app: XCUIApplication) -> XCUIElement { app.dialogs.containing(.staticText, identifier: "Close workspaces?").firstMatch } @@ -364,113 +463,12 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { app.alerts.containing(.staticText, identifier: "Close workspaces?").firstMatch } - private final class ControlSocketClient { - private let path: String - private let responseTimeout: TimeInterval - - init(path: String, responseTimeout: TimeInterval = 2.0) { - self.path = path - self.responseTimeout = responseTimeout - } - - func sendLine(_ line: String) -> String? { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - defer { close(fd) } - - var socketTimeout = timeval( - tv_sec: Int(responseTimeout.rounded(.down)), - tv_usec: Int32(((responseTimeout - floor(responseTimeout)) * 1_000_000).rounded()) - ) - - var noSigPipe: Int32 = 1 - _ = withUnsafePointer(to: &noSigPipe) { ptr in - setsockopt( - fd, - SOL_SOCKET, - SO_NOSIGPIPE, - ptr, - socklen_t(MemoryLayout.size) - ) - } - _ = withUnsafePointer(to: &socketTimeout) { ptr in - setsockopt( - fd, - SOL_SOCKET, - SO_RCVTIMEO, - ptr, - socklen_t(MemoryLayout.size) - ) - } - _ = withUnsafePointer(to: &socketTimeout) { ptr in - setsockopt( - fd, - SOL_SOCKET, - SO_SNDTIMEO, - ptr, - socklen_t(MemoryLayout.size) - ) - } - - var addr = sockaddr_un() - memset(&addr, 0, MemoryLayout.size) - addr.sun_family = sa_family_t(AF_UNIX) - - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - let bytes = Array(path.utf8CString) - guard bytes.count <= maxLen else { return nil } - withUnsafeMutablePointer(to: &addr.sun_path) { p in - let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self) - memset(raw, 0, maxLen) - for i in 0...offset(of: \.sun_path) ?? 0 - let addrLen = socklen_t(pathOffset + bytes.count) - addr.sun_len = UInt8(min(Int(addrLen), 255)) + private func closeWorkspaceDialog(app: XCUIApplication) -> XCUIElement { + app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch + } - let connected = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - connect(fd, sa, addrLen) - } - } - guard connected == 0 else { return nil } - - let payload = line + "\n" - let wrote: Bool = payload.withCString { cstr in - var remaining = strlen(cstr) - var p = UnsafeRawPointer(cstr) - while remaining > 0 { - let n = write(fd, p, remaining) - if n <= 0 { return false } - remaining -= n - p = p.advanced(by: n) - } - return true - } - guard wrote else { return nil } - _ = shutdown(fd, SHUT_WR) - - var buf = [UInt8](repeating: 0, count: 4096) - var accum = "" - while true { - let n = read(fd, &buf, buf.count) - if n < 0 { - let code = errno - if code == EAGAIN || code == EWOULDBLOCK { - break - } - return nil - } - if n <= 0 { break } - if let chunk = String(bytes: buf[0.. XCUIElement { + app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch } + }