diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d7da81801726..dcb343fd4b13 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2017,6 +2017,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var browserAddressBarFocusedPanelId: UUID? private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? + private var screenChangeSnapshotWorkItem: DispatchWorkItem? private var browserOmnibarRepeatKeyCode: UInt16? private var browserOmnibarRepeatDelta: Int = 0 private var browserAddressBarFocusObserver: NSObjectProtocol? @@ -2324,6 +2325,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } NSWindow.allowsAutomaticWindowTabbing = false + // Disable macOS system window restoration to prevent duplicate windows + // when external displays are disconnected/reconnected (#1802). + // The app uses its own SessionPersistenceStore for window state. + // Uses register(defaults:) so we set the app default without overwriting + // a user-level preference if one exists. + UserDefaults.standard.register(defaults: ["NSQuitAlwaysKeepsWindows": false]) disableNativeTabbingShortcut() ensureApplicationIcon() if !isRunningUnderXCTest { @@ -2638,6 +2645,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { isTerminatingApp = true + screenChangeSnapshotWorkItem?.cancel() _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) return .terminateNow } @@ -3304,6 +3312,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } lifecycleSnapshotObservers.append(didWakeObserver) + + // When an external display is disconnected/reconnected, macOS can trigger + // window restoration that creates duplicate windows (#1802). Save a snapshot + // so the session state stays consistent after the display topology change. + // Debounced because this notification can fire many times in rapid succession + // during a single display reconfiguration event. + let screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.scheduleScreenChangeSnapshot() + } + lifecycleSnapshotObservers.append(screenChangeObserver) + } + + private func scheduleScreenChangeSnapshot() { + screenChangeSnapshotWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + guard let self, !self.isTerminatingApp else { return } + _ = self.saveSessionSnapshot(includeScrollback: false) + } + } + screenChangeSnapshotWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) } private func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { @@ -3735,6 +3769,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarSelectionState: SidebarSelectionState ) { tabManager.window = window + // Prevent macOS from restoring this window on display reconfiguration (#1802). + window.isRestorable = false let key = ObjectIdentifier(window) #if DEBUG diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dfb03aa7c08e..a6c90c544474 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -9353,6 +9353,7 @@ final class BrowserDataImportCoordinator { defaultValue: "Import Browser Data" ) panel.isReleasedWhenClosed = false + panel.isRestorable = false panel.delegate = self panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true @@ -10095,6 +10096,7 @@ final class BrowserDataImportCoordinator { ) window.title = title window.isReleasedWhenClosed = false + window.isRestorable = false window.standardWindowButton(.closeButton)?.isHidden = true window.standardWindowButton(.miniaturizeButton)?.isHidden = true window.standardWindowButton(.zoomButton)?.isHidden = true diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 0c97d5a8c67a..7e23be6665bf 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -155,6 +155,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { panel.level = NSWindow.Level.normal panel.hidesOnDeactivate = false panel.isReleasedWhenClosed = false + panel.isRestorable = false panel.minSize = NSSize(width: minWidth, height: minHeight) panel.title = String(localized: "browser.popup.loadingTitle", defaultValue: "Loading\u{2026}") self.panel = panel diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index debc66978b2e..7135a374a41c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1577,6 +1577,7 @@ private final class SettingsAboutTitlebarDebugWindowController: NSWindowControll window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false + window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("cmux.settingsAboutTitlebarDebug") window.center() window.contentView = NSHostingView(rootView: SettingsAboutTitlebarDebugView()) @@ -1805,6 +1806,7 @@ private final class DebugWindowControlsWindowController: NSWindowController, NSW window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false + window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("cmux.debugWindowControls") window.center() window.contentView = NSHostingView(rootView: DebugWindowControlsView()) @@ -2109,6 +2111,7 @@ private final class BrowserImportHintDebugWindowController: NSWindowController, window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false + window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("cmux.browserImportHintDebug") window.center() window.contentView = NSHostingView(rootView: BrowserImportHintDebugView()) @@ -2146,6 +2149,7 @@ private final class BrowserProfilePopoverDebugWindowController: NSWindowControll window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false + window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("cmux.browserProfilePopoverDebug") window.center() window.contentView = NSHostingView(rootView: BrowserProfilePopoverDebugView()) @@ -2756,6 +2760,7 @@ private final class SidebarDebugWindowController: NSWindowController, NSWindowDe window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false + window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("cmux.sidebarDebug") window.center() window.contentView = NSHostingView(rootView: SidebarDebugView()) @@ -3157,6 +3162,7 @@ private final class MenuBarExtraDebugWindowController: NSWindowController, NSWin window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false + window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("cmux.menubarDebug") window.center() window.contentView = NSHostingView(rootView: MenuBarExtraDebugView()) @@ -3327,6 +3333,7 @@ private final class BackgroundDebugWindowController: NSWindowController, NSWindo window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false + window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("cmux.backgroundDebug") window.center() window.contentView = NSHostingView(rootView: BackgroundDebugView())