Repository navigation
Route main window bootstrap through AppDelegate #3164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4913d17
9e805f9
ae9bdb1
b10ef23
9d9a250
79ca949
557b105
39dac43
57220bd
70e6a3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -566,8 +566,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| let tabManager: TabManager | ||
| let sidebarState: SidebarState | ||
| let sidebarSelectionState: SidebarSelectionState | ||
| var fileExplorerState: FileExplorerState? | ||
| let keyboardFocusCoordinator: MainWindowFocusController | ||
| weak var fileExplorerState: FileExplorerState? | ||
| var cmuxConfigStore: CmuxConfigStore? | ||
| weak var window: NSWindow? | ||
|
|
||
|
|
@@ -813,6 +813,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| private var lastSessionAutosavePersistedAt: Date = .distantPast | ||
| private var lastTypingActivityAt: TimeInterval = 0 | ||
| private var didHandleExplicitOpenIntentAtStartup = false | ||
| private var didScheduleInitialMainWindowBootstrap = false | ||
| private var didBootstrapInitialMainWindow = false | ||
| private var isTerminatingApp = false | ||
| // Set to true when the user has already confirmed quit via the warning dialog, | ||
| // so applicationShouldTerminate does not show a second alert. | ||
|
|
@@ -910,6 +912,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| } | ||
| } | ||
|
|
||
| func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { | ||
| if hasVisibleMainTerminalWindow() { | ||
| _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) | ||
| return true | ||
| } | ||
| ensureInitialMainWindowIfNeeded() | ||
| return true | ||
| } | ||
|
|
||
| func applicationDidFinishLaunching(_ notification: Notification) { | ||
| let env = ProcessInfo.processInfo.environment | ||
| let isRunningUnderXCTest = isRunningUnderXCTest(env) | ||
|
|
@@ -1054,6 +1065,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| installShortcutDefaultsObserver() | ||
| SystemWideHotkeyController.shared.start() | ||
| NSApp.servicesProvider = self | ||
|
|
||
| scheduleInitialMainWindowBootstrap(debugSource: "didFinishLaunching") | ||
| #if DEBUG | ||
| UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) | ||
| if env["CMUX_UI_TEST_MODE"] == "1" { | ||
|
|
@@ -1444,23 +1457,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| setupMultiWindowNotificationsUITestIfNeeded() | ||
| setupDisplayResolutionUITestDiagnosticsIfNeeded() | ||
|
|
||
| // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. | ||
| // The automation socket is a core testing primitive, so ensure it's started here when | ||
| // we detect XCTest, even if the main view lifecycle is flaky. | ||
| let env = ProcessInfo.processInfo.environment | ||
| if isRunningUnderXCTest(env) { | ||
| let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) | ||
| ?? SocketControlSettings.defaultMode.rawValue | ||
| let userMode = SocketControlSettings.migrateMode(raw) | ||
| let mode = SocketControlSettings.effectiveMode(userMode: userMode) | ||
| if mode != .off { | ||
| TerminalController.shared.start( | ||
| tabManager: tabManager, | ||
| socketPath: SocketControlSettings.socketPath(), | ||
| accessMode: mode | ||
| ) | ||
| scheduleUITestSocketSanityCheckIfNeeded() | ||
| } | ||
| if isRunningUnderXCTest(env) || env["CMUX_UI_TEST_MODE"] == "1" { | ||
| scheduleUITestSocketSanityCheckIfNeeded() | ||
| } | ||
| #endif | ||
| } | ||
|
|
@@ -2872,6 +2871,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| return (mode: mode, path: SocketControlSettings.socketPath()) | ||
| } | ||
|
|
||
| private func startSocketListenerIfEnabled(tabManager: TabManager, source: String) { | ||
| guard let config = socketListenerConfigurationIfEnabled() else { | ||
| TerminalController.shared.stop() | ||
| return | ||
| } | ||
| let path = TerminalController.shared.activeSocketPath(preferredPath: config.path) | ||
| sentryBreadcrumb("socket.listener.start", category: "socket", data: [ | ||
| "mode": config.mode.rawValue, | ||
| "path": path, | ||
| "source": source | ||
| ]) | ||
| TerminalController.shared.start(tabManager: tabManager, socketPath: path, accessMode: config.mode) | ||
| } | ||
|
|
||
| private func restartSocketListenerIfEnabled(source: String) { | ||
| guard let tabManager, | ||
| let config = socketListenerConfigurationIfEnabled() else { return } | ||
|
|
@@ -3323,39 +3336,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| tabManager: TabManager, | ||
| sidebarState: SidebarState, | ||
| sidebarSelectionState: SidebarSelectionState, | ||
| fileExplorerState: FileExplorerState, | ||
| fileExplorerState: FileExplorerState? = nil, | ||
| cmuxConfigStore: CmuxConfigStore? = nil | ||
| ) { | ||
| tabManager.window = window | ||
|
|
||
| let key = ObjectIdentifier(window) | ||
| #if DEBUG | ||
| let priorManagerToken = debugManagerToken(self.tabManager) | ||
| #endif | ||
| if let existing = mainWindowContexts[key] { | ||
| tabManager.window = window | ||
| existing.window = window | ||
| existing.fileExplorerState = fileExplorerState | ||
| let resolvedFileExplorerState = fileExplorerState ?? existing.fileExplorerState | ||
| if let fileExplorerState { | ||
| existing.fileExplorerState = fileExplorerState | ||
| } | ||
| existing.keyboardFocusCoordinator.update( | ||
| window: window, | ||
| tabManager: tabManager, | ||
| fileExplorerState: fileExplorerState | ||
| fileExplorerState: resolvedFileExplorerState | ||
| ) | ||
| if let cmuxConfigStore { | ||
| existing.cmuxConfigStore = cmuxConfigStore | ||
| } | ||
| } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { | ||
| if let existingWindow = existing.window, | ||
| existingWindow !== window, | ||
| existingWindow.isVisible || existingWindow.isMiniaturized { | ||
| #if DEBUG | ||
| cmuxDebugLog( | ||
| "mainWindow.register.duplicateIgnored windowId=\(String(windowId.uuidString.prefix(8))) " + | ||
| "existing={\(debugWindowToken(existingWindow))} duplicate={\(debugWindowToken(window))}" | ||
| ) | ||
| #endif | ||
| existing.tabManager.window = existingWindow | ||
| existing.keyboardFocusCoordinator.update( | ||
| window: existingWindow, | ||
| tabManager: existing.tabManager, | ||
| fileExplorerState: existing.fileExplorerState | ||
| ) | ||
| window.orderOut(nil) | ||
| window.close() | ||
| return | ||
|
Comment on lines
+3377
to
+3379
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this duplicate-window branch closes and returns, Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in ae9bdb1. registerMainWindow now assigns tabManager.window only after accepting the incoming window, and the duplicate-reject branch keeps the existing manager bound to the surviving window. — Claude Code |
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| tabManager.window = window | ||
| existing.window = window | ||
| existing.fileExplorerState = fileExplorerState | ||
| let resolvedFileExplorerState = fileExplorerState ?? existing.fileExplorerState | ||
| if let fileExplorerState { | ||
| existing.fileExplorerState = fileExplorerState | ||
| } | ||
| existing.keyboardFocusCoordinator.update( | ||
| window: window, | ||
| tabManager: tabManager, | ||
| fileExplorerState: fileExplorerState | ||
| fileExplorerState: resolvedFileExplorerState | ||
| ) | ||
| if let cmuxConfigStore { | ||
| existing.cmuxConfigStore = cmuxConfigStore | ||
| } | ||
| reindexMainWindowContextIfNeeded(existing, for: window) | ||
| } else { | ||
| tabManager.window = window | ||
| mainWindowContexts[key] = MainWindowContext( | ||
| windowId: windowId, | ||
| tabManager: tabManager, | ||
|
|
@@ -4722,11 +4761,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| tabManager = nextContext.tabManager | ||
| sidebarState = nextContext.sidebarState | ||
| sidebarSelectionState = nextContext.sidebarSelectionState | ||
| fileExplorerState = nextContext.fileExplorerState | ||
| TerminalController.shared.setActiveTabManager(nextContext.tabManager) | ||
| } else { | ||
| tabManager = nil | ||
| sidebarState = nil | ||
| sidebarSelectionState = nil | ||
| fileExplorerState = nil | ||
| TerminalController.shared.setActiveTabManager(nil) | ||
| } | ||
| } | ||
|
|
@@ -4964,6 +5005,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| tabManager = context.tabManager | ||
| sidebarState = context.sidebarState | ||
| sidebarSelectionState = context.sidebarSelectionState | ||
| fileExplorerState = context.fileExplorerState | ||
| TerminalController.shared.setActiveTabManager(context.tabManager) | ||
| } | ||
| #if DEBUG | ||
|
|
@@ -5433,6 +5475,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| _ = createMainWindow() | ||
| } | ||
|
|
||
| func scheduleInitialMainWindowBootstrap(debugSource: String) { | ||
| guard !didScheduleInitialMainWindowBootstrap else { return } | ||
| didScheduleInitialMainWindowBootstrap = true | ||
| DispatchQueue.main.async { [weak self] in | ||
| self?.bootstrapInitialMainWindowIfNeeded(debugSource: debugSource) | ||
| } | ||
| } | ||
|
|
||
| @discardableResult | ||
| func bootstrapInitialMainWindowIfNeeded(debugSource: String, shouldActivate: Bool = true) -> UUID { | ||
| let windowId = ensureInitialMainWindowIfNeeded(shouldActivate: shouldActivate) | ||
| if let manager = tabManagerFor(windowId: windowId) { | ||
| startSocketListenerIfEnabled( | ||
| tabManager: manager, | ||
| source: "bootstrapInitialMainWindow.\(debugSource)" | ||
| ) | ||
| } | ||
| guard !didBootstrapInitialMainWindow else { return windowId } | ||
|
|
||
| didBootstrapInitialMainWindow = true | ||
| if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { | ||
| openPreferencesWindow(debugSource: "uiTestShowSettings.\(debugSource)") | ||
| } | ||
| return windowId | ||
| } | ||
|
|
||
| @discardableResult | ||
| func ensureInitialMainWindowIfNeeded(shouldActivate: Bool = true) -> UUID { | ||
| for context in sortedMainWindowContextsForSessionSnapshot() { | ||
| guard let window = resolvedWindow(for: context) else { continue } | ||
| if shouldActivate { | ||
| if window.isMiniaturized { | ||
| window.deminiaturize(nil) | ||
| } | ||
| window.makeKeyAndOrderFront(nil) | ||
| setActiveMainWindow(window) | ||
| } | ||
| return context.windowId | ||
| } | ||
|
|
||
| return createMainWindow(shouldActivate: shouldActivate) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| private func hasVisibleMainTerminalWindow() -> Bool { | ||
| mainWindowContexts.values.contains { context in | ||
| guard let window = resolvedWindow(for: context) else { return false } | ||
| return window.isVisible && !window.isMiniaturized | ||
| } | ||
| } | ||
|
|
||
| @discardableResult | ||
| func performNewWorkspaceAction( | ||
| tabManager preferredTabManager: TabManager? = nil, | ||
|
|
@@ -6072,6 +6164,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| tabManager = context.tabManager | ||
| sidebarState = context.sidebarState | ||
| sidebarSelectionState = context.sidebarSelectionState | ||
| fileExplorerState = context.fileExplorerState | ||
| TerminalController.shared.setActiveTabManager(context.tabManager) | ||
| } | ||
|
|
||
|
|
@@ -6177,6 +6270,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| window.title = "" | ||
| window.titleVisibility = .hidden | ||
| window.titlebarAppearsTransparent = true | ||
| // cmux persists and restores main windows itself. Disable AppKit window | ||
| // restoration so the OS cannot resurrect stale duplicate main windows. | ||
| window.isRestorable = false | ||
| window.isMovableByWindowBackground = false | ||
| window.isMovable = false | ||
| let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) | ||
|
|
@@ -12307,11 +12403,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| tabManager = nextContext.tabManager | ||
| sidebarState = nextContext.sidebarState | ||
| sidebarSelectionState = nextContext.sidebarSelectionState | ||
| fileExplorerState = nextContext.fileExplorerState | ||
| TerminalController.shared.setActiveTabManager(nextContext.tabManager) | ||
| } else { | ||
| tabManager = nil | ||
| sidebarState = nil | ||
| sidebarSelectionState = nil | ||
| fileExplorerState = nil | ||
| TerminalController.shared.setActiveTabManager(nil) | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,11 +9,7 @@ struct cmuxApp: App { | |
| @StateObject private var tabManager: TabManager | ||
| @StateObject private var notificationStore = TerminalNotificationStore.shared | ||
| @StateObject private var sidebarState = SidebarState() | ||
| @StateObject private var sidebarSelectionState = SidebarSelectionState() | ||
| @StateObject private var fileExplorerState = FileExplorerState() | ||
| @StateObject private var cmuxConfigStore = CmuxConfigStore() | ||
| @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared | ||
| private let primaryWindowId = UUID() | ||
| @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue | ||
| @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue | ||
| @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints | ||
|
|
@@ -190,31 +186,14 @@ struct cmuxApp: App { | |
|
|
||
| var body: some Scene { | ||
| WindowGroup { | ||
| ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId) | ||
| .environmentObject(tabManager) | ||
| .environmentObject(notificationStore) | ||
| .environmentObject(sidebarState) | ||
| .environmentObject(sidebarSelectionState) | ||
| .environmentObject(fileExplorerState) | ||
| .environmentObject(cmuxConfigStore) | ||
| MainWindowBootstrapView() | ||
| .onAppear { | ||
| #if DEBUG | ||
| if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { | ||
| UpdateLogStore.shared.append("ui test: cmuxApp onAppear") | ||
| } | ||
| #endif | ||
| // Start the Unix socket controller for programmatic access | ||
| updateSocketController() | ||
| appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) | ||
| appDelegate.fileExplorerState = fileExplorerState | ||
| cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) | ||
| cmuxConfigStore.loadAll() | ||
| applyAppearance() | ||
| if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { | ||
| DispatchQueue.main.async { | ||
| appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") | ||
| } | ||
| } | ||
| bootstrapMainWindowScene() | ||
| } | ||
|
Comment on lines
190
to
197
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This change makes initial main-window creation depend entirely on Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9d9a250. AppDelegate now bootstraps the first real main window during applicationDidFinishLaunching, and SwiftUI's hidden bootstrap view only re-enters the same idempotent path if it appears later. — Claude Code
coderabbitai[bot] marked this conversation as resolved.
|
||
| .onChange(of: appearanceMode) { _ in | ||
| applyAppearance() | ||
|
|
@@ -594,10 +573,11 @@ struct cmuxApp: App { | |
|
|
||
| splitCommandButton(title: String(localized: "menu.view.focusRightSidebar", defaultValue: "Focus Right Sidebar"), shortcut: menuShortcut(for: .focusRightSidebar)) { | ||
| if AppDelegate.shared?.toggleRightSidebarKeyboardFocusInActiveMainWindow() != true { | ||
| fileExplorerState.setVisible(true) | ||
| _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( | ||
| if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( | ||
| preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow | ||
| ) | ||
| ) != true { | ||
| NSSound.beep() | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -777,7 +757,7 @@ struct cmuxApp: App { | |
| let mode = SocketControlSettings.effectiveMode(userMode: currentSocketMode) | ||
| if mode != .off { | ||
| TerminalController.shared.start( | ||
| tabManager: tabManager, | ||
| tabManager: activeTabManager, | ||
| socketPath: SocketControlSettings.socketPath(), | ||
| accessMode: mode | ||
| ) | ||
|
|
@@ -786,6 +766,11 @@ struct cmuxApp: App { | |
| } | ||
| } | ||
|
|
||
| private func bootstrapMainWindowScene() { | ||
| appDelegate.scheduleInitialMainWindowBootstrap(debugSource: "swiftUIBootstrap") | ||
| applyAppearance() | ||
| } | ||
|
|
||
| private var currentSocketMode: SocketControlMode { | ||
| SocketControlSettings.migrateMode(socketControlMode) | ||
| } | ||
|
|
@@ -1071,6 +1056,22 @@ struct cmuxApp: App { | |
| } | ||
| } | ||
|
|
||
| private struct MainWindowBootstrapView: View { | ||
| var body: some View { | ||
| Color.clear | ||
| .frame(width: 1, height: 1) | ||
| .background(WindowAccessor { window in | ||
| window.identifier = NSUserInterfaceItemIdentifier("cmux.bootstrap") | ||
| window.isRestorable = false | ||
| window.orderOut(nil) | ||
| Task { @MainActor [weak window] in | ||
| window?.orderOut(nil) | ||
| window?.close() | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| private let cmuxAuxiliaryWindowIdentifiers: Set<String> = [ | ||
| "cmux.settings", | ||
| "cmux.about", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applicationShouldHandleReopenignoreshasVisibleWindowsand always callsensureInitialMainWindowIfNeeded(), which promotes the first registered main-window context to key. With multiple open main windows, reopening from the Dock while the app is inactive can therefore switch the activeTabManagerto an arbitrary window (UUID-order fallback) and misroute subsequent menu/socket actions to the wrong window. This handler should only force-create/activate when no main window is visible, or preserve the current key/main window when one already exists.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 39dac431. Reopen now only creates or activates a main terminal window when none is visible; if a main terminal window is already visible, it preserves the current key/main routing instead of selecting a fallback context.
— Claude Code