Skip to content
146 changes: 122 additions & 24 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Comment on lines +915 to +921

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor visible-window flag when handling reopen

applicationShouldHandleReopen ignores hasVisibleWindows and always calls ensureInitialMainWindowIfNeeded(), 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 active TabManager to 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

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

}

func applicationDidFinishLaunching(_ notification: Notification) {
let env = ProcessInfo.processInfo.environment
let isRunningUnderXCTest = isRunningUnderXCTest(env)
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve tab manager window when dropping duplicate window

When this duplicate-window branch closes and returns, registerMainWindow has already assigned tabManager.window to the duplicate at function entry, so in the exact restore scenario this patch targets (two windows sharing one app model) the TabManager can be left pointing at a closing/deallocated window. That breaks subsequent title routing in TabManager.updateWindowTitle(for:) until the surviving window re-registers, which may not happen immediately. Rebind the manager to existingWindow (or defer assignment until after duplicate filtering) before returning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

}
Comment thread
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,
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Comment thread
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,
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3558,6 +3558,7 @@ struct ContentView: View {

view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
window.isRestorable = false
window.titlebarAppearsTransparent = true
// Keep window immovable; the sidebar's WindowDragHandleView handles
// drag-to-move via performDrag with temporary movable override.
Expand Down
55 changes: 28 additions & 27 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bootstrap main window without relying on onAppear

This change makes initial main-window creation depend entirely on WindowGroup’s .onAppear (bootstrapMainWindowScene()), but the same startup path already acknowledges that SwiftUI appearance callbacks can be delayed or skipped in UI-test environments. In that case the app never creates a real ContentView window, while configure has already wired AppDelegate to the placeholder state object, so automation/socket traffic can target detached state until a manual reopen occurs. Move the initial-window bootstrap to an AppDelegate launch hook so startup does not depend on view lifecycle timing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
.onChange(of: appearanceMode) { _ in
applyAppearance()
Expand Down Expand Up @@ -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()
}
}
}

Expand Down Expand Up @@ -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
)
Expand All @@ -786,6 +766,11 @@ struct cmuxApp: App {
}
}

private func bootstrapMainWindowScene() {
appDelegate.scheduleInitialMainWindowBootstrap(debugSource: "swiftUIBootstrap")
applyAppearance()
}

private var currentSocketMode: SocketControlMode {
SocketControlSettings.migrateMode(socketControlMode)
}
Expand Down Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
})
}
}

private let cmuxAuxiliaryWindowIdentifiers: Set<String> = [
"cmux.settings",
"cmux.about",
Expand Down
Loading