diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index f8862985ff..26f7a0170c 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -406,6 +406,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? private var cloudSyncCoordinator: CloudSyncCoordinator? private var settingsWindowController: SettingsWindowController? + private lazy var placeholderSettingsWindowGuard = PlaceholderSettingsWindowGuard( + isKnownSettingsWindow: { [weak self] window in + self?.settingsWindowController?.window === window + }) private var hasInstalledLimitResetObservers = false #if DEBUG private var debugMemoryPressureObserver: NSObjectProtocol? @@ -438,6 +442,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillFinishLaunching(_ notification: Notification) { self.configureAppIconForMacOSVersion() + // The SwiftUI `Settings` scene is an empty placeholder; macOS otherwise presents it at launch. + self.placeholderSettingsWindowGuard.start() + } + + func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool { + // CodexBar lives in the menu bar and has no untitled document to open at launch or on reopen. + false } func applicationDidFinishLaunching(_ notification: Notification) { diff --git a/Sources/CodexBar/PlaceholderSettingsWindowGuard.swift b/Sources/CodexBar/PlaceholderSettingsWindowGuard.swift new file mode 100644 index 0000000000..d0ed86605c --- /dev/null +++ b/Sources/CodexBar/PlaceholderSettingsWindowGuard.swift @@ -0,0 +1,91 @@ +import AppKit +import CodexBarCore + +/// CodexBar's preferences live in ``SettingsWindowController``. The SwiftUI `Settings` scene is an empty +/// placeholder that only carries the app-menu command group, and because it is the app's only scene macOS +/// presents it during launch, showing an empty "CodexBar Settings" window (#3053). +enum PlaceholderSettingsWindowDecision { + /// SwiftUI names the window of a `Settings` scene with this fragment (identifier and frame autosave name). + static let swiftUISettingsNameFragment = "com_apple_SwiftUI_Settings" + + /// A window CodexBar never wants onscreen: SwiftUI's placeholder Settings window. + static func shouldClose(identifier: String?, frameAutosaveName: String, isKnownSettingsWindow: Bool) -> Bool { + guard !isKnownSettingsWindow, identifier != SettingsWindowIdentity.identifier else { return false } + if let identifier, identifier.contains(self.swiftUISettingsNameFragment) { return true } + return frameAutosaveName.contains(self.swiftUISettingsNameFragment) + } +} + +/// Closes the empty SwiftUI `Settings` placeholder window whenever macOS presents it, so the AppKit +/// Settings window stays CodexBar's only preferences surface. +@MainActor +final class PlaceholderSettingsWindowGuard { + typealias WindowsProvider = @MainActor () -> [NSWindow] + typealias WindowPredicate = @MainActor (NSWindow) -> Bool + typealias WindowAction = @MainActor (NSWindow) -> Void + + private let windows: WindowsProvider + private let isKnownSettingsWindow: WindowPredicate + private let closeWindow: WindowAction + private let logger = CodexBarLog.logger(LogCategories.app) + private var isStarted = false + + init( + windows: @escaping WindowsProvider = { NSApp?.windows ?? [] }, + isKnownSettingsWindow: @escaping WindowPredicate = { _ in false }, + closeWindow: @escaping WindowAction = { $0.close() }) + { + self.windows = windows + self.isKnownSettingsWindow = isKnownSettingsWindow + self.closeWindow = closeWindow + } + + /// Sweeps once and keeps sweeping as window state changes; SwiftUI presents the placeholder after + /// `applicationWillFinishLaunching`, and window restoration can bring it back later. + func start() { + guard !self.isStarted else { return } + self.isStarted = true + let center = NotificationCenter.default + for name in [ + NSApplication.didFinishLaunchingNotification, + NSWindow.didBecomeKeyNotification, + NSWindow.didBecomeMainNotification, + NSWindow.didUpdateNotification, + ] { + center.addObserver( + self, + selector: #selector(self.windowStateDidChange(_:)), + name: name, + object: nil) + } + self.sweep() + } + + @discardableResult + func sweep() -> Int { + var closedCount = 0 + for window in self.windows() { + guard PlaceholderSettingsWindowDecision.shouldClose( + identifier: window.identifier?.rawValue, + frameAutosaveName: window.frameAutosaveName, + isKnownSettingsWindow: self.isKnownSettingsWindow(window)) + else { continue } + self.closeWindow(window) + closedCount += 1 + } + if closedCount > 0 { + self.logger.info( + "Closed placeholder SwiftUI Settings window", + metadata: ["count": "\(closedCount)"]) + } + return closedCount + } + + @objc private func windowStateDidChange(_: Notification) { + self.sweep() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/Tests/CodexBarTests/AppDelegateTests.swift b/Tests/CodexBarTests/AppDelegateTests.swift index c036b9e77f..e3140ea43b 100644 --- a/Tests/CodexBarTests/AppDelegateTests.swift +++ b/Tests/CodexBarTests/AppDelegateTests.swift @@ -5,6 +5,14 @@ import Testing @MainActor struct AppDelegateTests { + @Test + func `refuses the untitled window macOS would fill with the empty Settings scene`() { + _ = NSApplication.shared + let appDelegate = AppDelegate() + + #expect(appDelegate.applicationShouldOpenUntitledFile(NSApplication.shared) == false) + } + @Test func `builds status controller after launch`() { let appDelegate = AppDelegate() diff --git a/Tests/CodexBarTests/PlaceholderSettingsWindowGuardTests.swift b/Tests/CodexBarTests/PlaceholderSettingsWindowGuardTests.swift new file mode 100644 index 0000000000..bb35af7db7 --- /dev/null +++ b/Tests/CodexBarTests/PlaceholderSettingsWindowGuardTests.swift @@ -0,0 +1,80 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +struct PlaceholderSettingsWindowGuardTests { + @Test + func `closes the empty SwiftUI Settings placeholder window`() { + _ = NSApplication.shared + let placeholder = self.makeWindow( + identifier: "com_apple_SwiftUI_Settings_window", + frameAutosaveName: "com_apple_SwiftUI_Settings_window") + var closed: [NSWindow] = [] + let guardian = PlaceholderSettingsWindowGuard( + windows: { [placeholder] }, + isKnownSettingsWindow: { _ in false }, + closeWindow: { closed.append($0) }) + + #expect(guardian.sweep() == 1) + #expect(closed.count == 1) + #expect(closed.first === placeholder) + } + + @Test + func `keeps the AppKit Settings window and unrelated windows onscreen`() { + _ = NSApplication.shared + let settingsWindow = self.makeWindow(identifier: SettingsWindowIdentity.identifier) + let updateWindow = self.makeWindow(identifier: "SUUpdateAlert") + var closed: [NSWindow] = [] + let guardian = PlaceholderSettingsWindowGuard( + windows: { [settingsWindow, updateWindow] }, + isKnownSettingsWindow: { $0 === settingsWindow }, + closeWindow: { closed.append($0) }) + + #expect(guardian.sweep() == 0) + #expect(closed.isEmpty) + } + + @Test + func `recognizes the placeholder by frame autosave name when the identifier is missing`() { + #expect(PlaceholderSettingsWindowDecision.shouldClose( + identifier: nil, + frameAutosaveName: "com_apple_SwiftUI_Settings_window", + isKnownSettingsWindow: false)) + } + + @Test + func `never closes the registered Settings window`() { + #expect(!PlaceholderSettingsWindowDecision.shouldClose( + identifier: "com_apple_SwiftUI_Settings_window", + frameAutosaveName: "com_apple_SwiftUI_Settings_window", + isKnownSettingsWindow: true)) + #expect(!PlaceholderSettingsWindowDecision.shouldClose( + identifier: SettingsWindowIdentity.identifier, + frameAutosaveName: SettingsWindowIdentity.frameAutosaveName, + isKnownSettingsWindow: false)) + } + + @Test + func `leaves windows without SwiftUI Settings naming alone`() { + #expect(!PlaceholderSettingsWindowDecision.shouldClose( + identifier: nil, + frameAutosaveName: "", + isKnownSettingsWindow: false)) + } + + private func makeWindow(identifier: String, frameAutosaveName: String = "") -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled, .closable], + backing: .buffered, + defer: true) + window.identifier = NSUserInterfaceItemIdentifier(identifier) + if !frameAutosaveName.isEmpty { + window.setFrameAutosaveName(frameAutosaveName) + } + window.isReleasedWhenClosed = false + return window + } +}