diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 6a3c7b419f..cd93de3d8a 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -328,7 +328,16 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } @objc func quit() { - NSApp.terminate(nil) + let openMenus = Array(self.openMenus.values) + for menu in openMenus { + menu.cancelTrackingWithoutAnimation() + } + + self.scheduleQuitTermination { [weak self] in + guard let self else { return } + self.prepareForAppShutdown() + self.terminateApplicationForQuit() + } } @objc func copyError(_ sender: NSMenuItem) { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 747a3dd480..e99c3963de 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -140,6 +140,16 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? var hasPreparedForAppShutdown = false + var scheduleQuitTermination: (@escaping @MainActor () -> Void) -> Void = { operation in + DispatchQueue.main.async { + Task { @MainActor in + operation() + } + } + } + var terminateApplicationForQuit: @MainActor () -> Void = { + NSApp.terminate(nil) + } var openMenuInvalidationRetryTask: Task? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift index 73a427366b..fb06f513f4 100644 --- a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -58,6 +58,64 @@ struct StatusItemControllerShutdownTests { #expect(controller.mergedMenu == nil) } + @Test + func `status menu quit defers shutdown until menu tracking can unwind`() { + let controller = self.makeController() + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + + var scheduledTermination: (@MainActor () -> Void)? + var didTerminate = false + controller.scheduleQuitTermination = { operation in + scheduledTermination = operation + } + controller.terminateApplicationForQuit = { + didTerminate = true + } + + controller.quit() + + #expect(scheduledTermination != nil) + #expect(!controller.hasPreparedForAppShutdown) + #expect(!didTerminate) + #expect(controller.openMenus[key] === menu) + + scheduledTermination?() + + #expect(controller.hasPreparedForAppShutdown) + #expect(controller.openMenus.isEmpty) + #expect(controller.statusItem.menu == nil) + #expect(didTerminate) + } + + private func makeController() -> StatusItemController { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + if let codexMetadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + private func makeSettings() -> SettingsStore { let suite = "StatusItemControllerShutdownTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)!