From 9e9dfbc26154d6640e096f6c344b99094731317f Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 07:05:22 +0100 Subject: [PATCH 01/10] Guard menu refresh teardown paths in tests --- Sources/CodexBar/StatusItemController+Menu.swift | 9 +++++++-- Sources/CodexBar/StatusItemController.swift | 4 +++- Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift | 8 +++----- Tests/CodexBarTests/StatusItemAnimationTests.swift | 8 +++----- Tests/CodexBarTests/StatusMenuTests.swift | 8 +++----- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2508e25f0..58d7bca5a 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -45,7 +45,9 @@ extension StatusItemController { if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } - self.openMenus[ObjectIdentifier(menu)] = menu + if Self.menuRefreshEnabled { + self.openMenus[ObjectIdentifier(menu)] = menu + } // Removed redundant async refresh - single pass is sufficient after initial layout return } @@ -75,7 +77,9 @@ extension StatusItemController { self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } - self.openMenus[ObjectIdentifier(menu)] = menu + if Self.menuRefreshEnabled { + self.openMenus[ObjectIdentifier(menu)] = menu + } // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -512,6 +516,7 @@ extension StatusItemController { } func refreshOpenMenusIfNeeded() { + guard Self.menuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } for (key, menu) in self.openMenus { guard key == ObjectIdentifier(menu) else { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dce63bf76..ab1b39d95 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -257,11 +257,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private func invalidateMenus() { self.menuContentVersion &+= 1 + guard Self.menuRefreshEnabled else { return } // Don't refresh menus while they're open - wait until they close and reopen // This prevents expensive rebuilds while user is navigating the menu guard self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded() - Task { @MainActor in + Task { @MainActor [weak self] in + guard let self else { return } // AppKit can ignore menu mutations while tracking; retry on the next run loop. await Task.yield() guard self.openMenus.isEmpty else { return } diff --git a/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift b/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift index 914c8aaed..a75bbd0ad 100644 --- a/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift +++ b/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift @@ -14,11 +14,9 @@ struct BatteryDrainDiagnosticTests { } private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } @Test("Fallback provider should not animate when all providers are disabled") diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index e4c8a5407..a1bb465c7 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -7,11 +7,9 @@ import Testing @Suite struct StatusItemAnimationTests { private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } @Test diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 55fc217c6..8afba87dd 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -12,11 +12,9 @@ struct StatusMenuTests { } private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } private func makeSettings() -> SettingsStore { From 674a2b8f72a93e8d23de4b2dace2bf2864b21210 Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 07:13:07 +0100 Subject: [PATCH 02/10] Restore strict no-UI keychain query policy --- .../KeychainAccessPreflight.swift | 4 ++-- Sources/CodexBarCore/KeychainNoUIQuery.swift | 5 ++-- .../KeychainNoUIQueryTests.swift | 23 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 Tests/CodexBarTests/KeychainNoUIQueryTests.swift diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 0ca26604e..7f0a4baa2 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -139,8 +139,8 @@ public enum KeychainAccessPreflight { kSecAttrService as String: service, kSecMatchLimit as String: kSecMatchLimitOne, // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because - // some macOS configurations have been observed to show the legacy keychain prompt even when - // `kSecUseAuthenticationUIFail` is set. + // some macOS configurations have been observed to show the legacy keychain prompt unless the query + // is strictly non-interactive. kSecReturnAttributes as String: true, ] KeychainNoUIQuery.apply(to: &query) diff --git a/Sources/CodexBarCore/KeychainNoUIQuery.swift b/Sources/CodexBarCore/KeychainNoUIQuery.swift index 29ef456a7..83cf6d443 100644 --- a/Sources/CodexBarCore/KeychainNoUIQuery.swift +++ b/Sources/CodexBarCore/KeychainNoUIQuery.swift @@ -10,9 +10,8 @@ enum KeychainNoUIQuery { context.interactionNotAllowed = true query[kSecUseAuthenticationContext as String] = context - // NOTE: While Apple recommends using LAContext.interactionNotAllowed, that alone is not sufficient to - // prevent the legacy keychain "Allow/Deny" prompt on some configurations. We also set the UI policy to fail - // so SecItemCopyMatching returns errSecInteractionNotAllowed instead of showing UI. + // Keep explicit UI-fail policy for legacy keychain behavior on macOS where + // `interactionNotAllowed` alone can still surface Allow/Deny prompts. query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail } } diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift new file mode 100644 index 000000000..f3a0e065c --- /dev/null +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -0,0 +1,23 @@ +import LocalAuthentication +import Security +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite +struct KeychainNoUIQueryTests { + @Test + func apply_setsNonInteractiveContextAndUIFailPolicy() { + var query: [String: Any] = [:] + + KeychainNoUIQuery.apply(to: &query) + + let context = query[kSecUseAuthenticationContext as String] as? LAContext + #expect(context != nil) + #expect(context?.interactionNotAllowed == true) + + let uiPolicy = query[kSecUseAuthenticationUI as String] as? String + #expect(uiPolicy == (kSecUseAuthenticationUIFail as String)) + } +} +#endif From 651a37e823069a6e8112466bedecc50add7ed853 Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 07:36:05 +0100 Subject: [PATCH 03/10] Harden menu refresh gating and test cleanup --- .../CodexBar/StatusItemController+Menu.swift | 18 +++++++--- Sources/CodexBar/StatusItemController.swift | 22 +++++++++++++ .../KeychainAccessPreflight.swift | 33 +++++++++++-------- Sources/CodexBarCore/KeychainNoUIQuery.swift | 3 +- .../BatteryDrainDiagnosticTests.swift | 3 ++ .../KeychainNoUIQueryTests.swift | 14 +++++++- .../StatusItemAnimationTests.swift | 6 ++++ Tests/CodexBarTests/StatusMenuTests.swift | 9 +++++ 8 files changed, 88 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 58d7bca5a..22cd55638 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -46,6 +46,7 @@ extension StatusItemController { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } if Self.menuRefreshEnabled { + // Intentionally skip open-menu tracking when refresh is disabled (tests). self.openMenus[ObjectIdentifier(menu)] = menu } // Removed redundant async refresh - single pass is sufficient after initial layout @@ -78,6 +79,7 @@ extension StatusItemController { // Heights are already set during populateMenu, no need to remeasure } if Self.menuRefreshEnabled { + // Intentionally skip open-menu tracking when refresh is disabled (tests). self.openMenus[ObjectIdentifier(menu)] = menu } // Only schedule refresh after menu is registered as open - refreshNow is called async @@ -518,13 +520,10 @@ extension StatusItemController { func refreshOpenMenusIfNeeded() { guard Self.menuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } + var orphanedKeys: [ObjectIdentifier] = [] for (key, menu) in self.openMenus { guard key == ObjectIdentifier(menu) else { - // Clean up orphaned menu entries from all tracking dictionaries - self.openMenus.removeValue(forKey: key) - self.menuRefreshTasks.removeValue(forKey: key)?.cancel() - self.menuProviders.removeValue(forKey: key) - self.menuVersions.removeValue(forKey: key) + orphanedKeys.append(key) continue } @@ -540,6 +539,14 @@ extension StatusItemController { // Heights are already set during populateMenu, no need to remeasure } } + + // Clean up orphaned menu entries from all tracking dictionaries. + for key in orphanedKeys { + self.openMenus.removeValue(forKey: key) + self.menuRefreshTasks.removeValue(forKey: key)?.cancel() + self.menuProviders.removeValue(forKey: key) + self.menuVersions.removeValue(forKey: key) + } } private func menuProvider(for menu: NSMenu) -> UsageProvider? { @@ -567,6 +574,7 @@ extension StatusItemController { guard let self, let menu else { return } try? await Task.sleep(for: Self.menuOpenRefreshDelay) guard !Task.isCancelled else { return } + guard Self.menuRefreshEnabled else { return } guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard !self.store.isRefreshing else { return } let provider = self.menuProvider(for: menu) ?? self.resolvedMenuProvider() diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index ab1b39d95..a8b590ed2 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -458,6 +458,28 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin return "\(prefix): \(base)" } + #if DEBUG + func releaseStatusItemsForTesting() { + self.blinkTask?.cancel() + self.loginTask?.cancel() + + for task in self.menuRefreshTasks.values { + task.cancel() + } + self.menuRefreshTasks.removeAll(keepingCapacity: false) + self.openMenus.removeAll(keepingCapacity: false) + + self.statusItem.menu = nil + self.statusBar.removeStatusItem(self.statusItem) + + for item in self.statusItems.values { + item.menu = nil + self.statusBar.removeStatusItem(item) + } + self.statusItems.removeAll(keepingCapacity: false) + } + #endif + deinit { self.blinkTask?.cancel() self.loginTask?.cancel() diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 7f0a4baa2..9c335d76c 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -134,19 +134,7 @@ public enum KeychainAccessPreflight { } #endif guard !KeychainAccessGate.isDisabled else { return .notFound } - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecMatchLimit as String: kSecMatchLimitOne, - // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because - // some macOS configurations have been observed to show the legacy keychain prompt unless the query - // is strictly non-interactive. - kSecReturnAttributes as String: true, - ] - KeychainNoUIQuery.apply(to: &query) - if let account { - query[kSecAttrAccount as String] = account - } + let query = self.makeGenericPasswordPreflightQuery(service: service, account: account) var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -174,4 +162,23 @@ public enum KeychainAccessPreflight { return .notFound #endif } + + #if os(macOS) + static func makeGenericPasswordPreflightQuery(service: String, account: String?) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because + // some macOS configurations have been observed to show the legacy keychain prompt unless the query + // is strictly non-interactive. + kSecReturnAttributes as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + if let account { + query[kSecAttrAccount as String] = account + } + return query + } + #endif } diff --git a/Sources/CodexBarCore/KeychainNoUIQuery.swift b/Sources/CodexBarCore/KeychainNoUIQuery.swift index 83cf6d443..a4bf1378b 100644 --- a/Sources/CodexBarCore/KeychainNoUIQuery.swift +++ b/Sources/CodexBarCore/KeychainNoUIQuery.swift @@ -12,7 +12,8 @@ enum KeychainNoUIQuery { // Keep explicit UI-fail policy for legacy keychain behavior on macOS where // `interactionNotAllowed` alone can still surface Allow/Deny prompts. - query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail + // Use the raw constant value to avoid deprecation warnings while preserving behavior. + query[kSecUseAuthenticationUI as String] = "kSecUseAuthenticationUIFail" as CFString } } #endif diff --git a/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift b/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift index a75bbd0ad..ef5c73142 100644 --- a/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift +++ b/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift @@ -52,6 +52,7 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == false, @@ -99,6 +100,7 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == false, @@ -139,6 +141,7 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == true, diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index f3a0e065c..7190425fb 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -17,7 +17,19 @@ struct KeychainNoUIQueryTests { #expect(context?.interactionNotAllowed == true) let uiPolicy = query[kSecUseAuthenticationUI as String] as? String - #expect(uiPolicy == (kSecUseAuthenticationUIFail as String)) + #expect(uiPolicy == "kSecUseAuthenticationUIFail") + } + + @Test + func preflightQuery_isStrictlyNonInteractiveAndDoesNotRequestSecretData() { + let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( + service: "test.service", + account: "test.account") + + #expect(query[kSecReturnData as String] == nil) + #expect(query[kSecReturnAttributes as String] as? Bool == true) + #expect((query[kSecUseAuthenticationContext as String] as? LAContext)?.interactionNotAllowed == true) + #expect((query[kSecUseAuthenticationUI as String] as? String) == "kSecUseAuthenticationUIFail") } } #endif diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index a1bb465c7..be59f2f42 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -43,6 +43,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -94,6 +95,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Enter loading state: no data, no stale error. store._setSnapshotForTesting(nil, provider: .codex) @@ -148,6 +150,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Primary used=10%. Bonus exhausted: used=100% (remaining=0%). let snapshot = UsageSnapshot( @@ -201,6 +204,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Bonus exists but is unused: used=0% (remaining=100%). let snapshot = UsageSnapshot( @@ -253,6 +257,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -292,6 +297,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 8afba87dd..0d8f86bde 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -57,6 +57,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let claudeMenu = controller.makeMenu() controller.menuWillOpen(claudeMenu) @@ -103,6 +104,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -153,6 +155,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -210,6 +213,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect(controller.statusItems[.claude]?.isVisible == true) @@ -258,6 +262,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -319,6 +324,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -388,6 +394,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -466,6 +473,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -521,6 +529,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) From 9ef63aebd60c7a3ea192cda03a88c0990ffd5874 Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 07:59:35 +0100 Subject: [PATCH 04/10] Add runtime-toggle regression coverage for menu refresh --- .../CodexBar/StatusItemController+Menu.swift | 24 +++++++++- Sources/CodexBar/StatusItemController.swift | 15 ++++++- Tests/CodexBarTests/StatusMenuTests.swift | 40 ++++++++++++++++- Tests/CodexBarTests/TestStores.swift | 45 +++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 22cd55638..301ae0370 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -8,7 +8,26 @@ import SwiftUI extension StatusItemController { private static let menuCardBaseWidth: CGFloat = 310 - private static let menuOpenRefreshDelay: Duration = .seconds(1.2) + private static let defaultMenuOpenRefreshDelay: Duration = .seconds(1.2) + #if DEBUG + private static var menuOpenRefreshDelayForTesting: Duration = .seconds(1.2) + static func setMenuOpenRefreshDelayForTesting(_ delay: Duration) { + self.menuOpenRefreshDelayForTesting = delay + } + + static func resetMenuOpenRefreshDelayForTesting() { + self.menuOpenRefreshDelayForTesting = self.defaultMenuOpenRefreshDelay + } + #endif + + private static var menuOpenRefreshDelay: Duration { + #if DEBUG + menuOpenRefreshDelayForTesting + #else + defaultMenuOpenRefreshDelay + #endif + } + private struct OpenAIWebMenuItems { let hasUsageBreakdown: Bool let hasCreditsHistory: Bool @@ -575,6 +594,9 @@ extension StatusItemController { try? await Task.sleep(for: Self.menuOpenRefreshDelay) guard !Task.isCancelled else { return } guard Self.menuRefreshEnabled else { return } + #if DEBUG + self.onDelayedMenuRefreshAttemptForTesting?() + #endif guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard !self.store.isRefreshing else { return } let provider = self.menuProvider(for: menu) ?? self.resolvedMenuProvider() diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index a8b590ed2..f1baf18fa 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -15,7 +15,17 @@ protocol StatusItemControlling: AnyObject { final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. static var menuCardRenderingEnabled = !SettingsStore.isRunningTests - static var menuRefreshEnabled = !SettingsStore.isRunningTests + private static let defaultMenuRefreshEnabled = !SettingsStore.isRunningTests + private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests + #if DEBUG + static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { + self.menuRefreshEnabled = enabled + } + + static func resetMenuRefreshEnabledForTesting() { + self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + } + #endif typealias Factory = (UsageStore, SettingsStore, AccountInfo, UpdaterProviding, PreferencesSelection) -> StatusItemControlling static let defaultFactory: Factory = { store, settings, account, updater, selection in @@ -45,6 +55,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] + #if DEBUG + var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? + #endif var blinkTask: Task? var loginTask: Task? { didSet { self.refreshMenusForLoginStateChange() } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 0d8f86bde..e812d909d 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -4,11 +4,11 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) struct StatusMenuTests { private func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) } private func makeStatusBarForTesting() -> NSStatusBar { @@ -184,6 +184,42 @@ struct StatusMenuTests { #expect(hasOpenAIWebSubmenus(menu) == false) } + @Test + func delayedMenuRefreshSkipsWhenRefreshDisabledDuringDelay() async { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + StatusItemController.setMenuOpenRefreshDelayForTesting(.milliseconds(50)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + var delayedRefreshWakeCount = 0 + + await withStatusItemControllerForTesting( + store: store, + settings: settings, + fetcher: fetcher, + statusBar: self.makeStatusBarForTesting()) + { controller in + controller.onDelayedMenuRefreshAttemptForTesting = { + delayedRefreshWakeCount += 1 + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + StatusItemController.setMenuRefreshEnabledForTesting(false) + try? await Task.sleep(for: .milliseconds(180)) + } + + #expect(delayedRefreshWakeCount == 0) + } + @Test func providerToggleUpdatesStatusItemVisibility() { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 7d8e27491..1eda92186 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -1,6 +1,9 @@ import CodexBarCore import Foundation @testable import CodexBar +#if os(macOS) +import AppKit +#endif final class InMemoryCookieHeaderStore: CookieHeaderStoring, @unchecked Sendable { var value: String? @@ -132,3 +135,45 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto } return CodexBarConfigStore(fileURL: url) } + +#if os(macOS) +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) throws -> T) rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try operation(controller) +} + +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) async throws -> T) async rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try await operation(controller) +} +#endif From 6b4152aaebd9ef91c8c66df8d5896726936768be Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 08:20:54 +0100 Subject: [PATCH 05/10] Fix keychain no-UI policy constant handling --- Sources/CodexBarCore/KeychainNoUIQuery.swift | 26 ++++++++++++++-- .../KeychainNoUIQueryTests.swift | 30 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/KeychainNoUIQuery.swift b/Sources/CodexBarCore/KeychainNoUIQuery.swift index a4bf1378b..6a7eb30a5 100644 --- a/Sources/CodexBarCore/KeychainNoUIQuery.swift +++ b/Sources/CodexBarCore/KeychainNoUIQuery.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation #if os(macOS) @@ -5,6 +6,8 @@ import LocalAuthentication import Security enum KeychainNoUIQuery { + private static let uiFailPolicy = KeychainNoUIQuery.resolveUIFailPolicy() + static func apply(to query: inout [String: Any]) { let context = LAContext() context.interactionNotAllowed = true @@ -12,8 +15,27 @@ enum KeychainNoUIQuery { // Keep explicit UI-fail policy for legacy keychain behavior on macOS where // `interactionNotAllowed` alone can still surface Allow/Deny prompts. - // Use the raw constant value to avoid deprecation warnings while preserving behavior. - query[kSecUseAuthenticationUI as String] = "kSecUseAuthenticationUIFail" as CFString + query[kSecUseAuthenticationUI as String] = self.uiFailPolicy as CFString + } + + static func uiFailPolicyForTesting() -> String { + self.uiFailPolicy + } + + private static func resolveUIFailPolicy() -> String { + // Resolve the Security symbol at runtime to preserve the true constant value + // without directly referencing deprecated API at compile time. + let securityPath = "/System/Library/Frameworks/Security.framework/Security" + guard let handle = dlopen(securityPath, RTLD_NOW) else { + return "u_AuthUIF" + } + defer { dlclose(handle) } + + guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else { + return "u_AuthUIF" + } + let valuePointer = symbol.assumingMemoryBound(to: CFString?.self) + return (valuePointer.pointee as String?) ?? "u_AuthUIF" } } #endif diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index 7190425fb..a0e8031e1 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -1,3 +1,4 @@ +import Darwin import LocalAuthentication import Security import Testing @@ -6,6 +7,19 @@ import Testing #if os(macOS) @Suite struct KeychainNoUIQueryTests { + private func resolveSecurityUIFailValue() -> String { + let securityPath = "/System/Library/Frameworks/Security.framework/Security" + guard let handle = dlopen(securityPath, RTLD_NOW) else { + return "u_AuthUIF" + } + defer { dlclose(handle) } + guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else { + return "u_AuthUIF" + } + let valuePointer = symbol.assumingMemoryBound(to: CFString?.self) + return (valuePointer.pointee as String?) ?? "u_AuthUIF" + } + @Test func apply_setsNonInteractiveContextAndUIFailPolicy() { var query: [String: Any] = [:] @@ -17,7 +31,9 @@ struct KeychainNoUIQueryTests { #expect(context?.interactionNotAllowed == true) let uiPolicy = query[kSecUseAuthenticationUI as String] as? String - #expect(uiPolicy == "kSecUseAuthenticationUIFail") + #expect(uiPolicy == self.resolveSecurityUIFailValue()) + #expect(uiPolicy == (KeychainNoUIQuery.uiFailPolicyForTesting() as String)) + #expect(uiPolicy != "kSecUseAuthenticationUIFail") } @Test @@ -29,7 +45,17 @@ struct KeychainNoUIQueryTests { #expect(query[kSecReturnData as String] == nil) #expect(query[kSecReturnAttributes as String] as? Bool == true) #expect((query[kSecUseAuthenticationContext as String] as? LAContext)?.interactionNotAllowed == true) - #expect((query[kSecUseAuthenticationUI as String] as? String) == "kSecUseAuthenticationUIFail") + #expect((query[kSecUseAuthenticationUI as String] as? String) == self.resolveSecurityUIFailValue()) + } + + @Test + func preflightQuery_executesWithoutInvalidUIPolicy() { + let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( + service: "codexbar.keychain.noui.\(UUID().uuidString)", + account: nil) + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + #expect(status == errSecItemNotFound || status == errSecInteractionNotAllowed) } } #endif From 34a36215ca77832aeafe840b1ea096d8d7189a2c Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 09:03:28 +0100 Subject: [PATCH 06/10] Guard Darwin import for non-macOS builds --- Sources/CodexBarCore/KeychainNoUIQuery.swift | 2 +- Tests/CodexBarTests/KeychainNoUIQueryTests.swift | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/KeychainNoUIQuery.swift b/Sources/CodexBarCore/KeychainNoUIQuery.swift index 6a7eb30a5..58df3e085 100644 --- a/Sources/CodexBarCore/KeychainNoUIQuery.swift +++ b/Sources/CodexBarCore/KeychainNoUIQuery.swift @@ -1,7 +1,7 @@ -import Darwin import Foundation #if os(macOS) +import Darwin import LocalAuthentication import Security diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index a0e8031e1..95614da02 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -1,4 +1,7 @@ +import Foundation +#if os(macOS) import Darwin +#endif import LocalAuthentication import Security import Testing From c6853548fd7858756430ba267d5b22e989e7df26 Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 09:35:05 +0100 Subject: [PATCH 07/10] Harden test teardown and serialize animation suite --- Sources/CodexBar/StatusItemController+Menu.swift | 2 ++ Sources/CodexBar/StatusItemController.swift | 15 +++++++++++++++ .../CodexBarTests/StatusItemAnimationTests.swift | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 301ae0370..35820e33f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -66,6 +66,7 @@ extension StatusItemController { } if Self.menuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). + // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu } // Removed redundant async refresh - single pass is sufficient after initial layout @@ -99,6 +100,7 @@ extension StatusItemController { } if Self.menuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). + // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu } // Only schedule refresh after menu is registered as open - refreshNow is called async diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index f1baf18fa..f8da71a40 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -17,6 +17,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin static var menuCardRenderingEnabled = !SettingsStore.isRunningTests private static let defaultMenuRefreshEnabled = !SettingsStore.isRunningTests private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests + // TODO(maintainer): Confirm whether runtime toggles outside tests are supported. Current semantics assume + // test-only usage; enabling refresh while a menu is already open does not retro-register/schedule that menu. #if DEBUG static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { self.menuRefreshEnabled = enabled @@ -475,12 +477,25 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin func releaseStatusItemsForTesting() { self.blinkTask?.cancel() self.loginTask?.cancel() + self.animationDriver?.stop() + self.animationDriver = nil + self.animationPhase = 0 + self.blinkForceUntil = nil + self.blinkStates.removeAll(keepingCapacity: false) + self.blinkAmounts.removeAll(keepingCapacity: false) + self.wiggleAmounts.removeAll(keepingCapacity: false) + self.tiltAmounts.removeAll(keepingCapacity: false) for task in self.menuRefreshTasks.values { task.cancel() } self.menuRefreshTasks.removeAll(keepingCapacity: false) self.openMenus.removeAll(keepingCapacity: false) + self.menuProviders.removeAll(keepingCapacity: false) + self.menuVersions.removeAll(keepingCapacity: false) + self.providerMenus.removeAll(keepingCapacity: false) + self.mergedMenu = nil + self.fallbackMenu = nil self.statusItem.menu = nil self.statusBar.removeStatusItem(self.statusItem) diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index be59f2f42..163e3a880 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -4,7 +4,7 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) struct StatusItemAnimationTests { private func makeStatusBarForTesting() -> NSStatusBar { // Use the real system status bar in tests. Creating standalone NSStatusBar instances From ddb1c94afd54b44abbba9f2ce651c6d64c31bf95 Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 10:16:10 +0100 Subject: [PATCH 08/10] Gate test-time UI mutations after teardown --- .../CodexBar/StatusItemController+Animation.swift | 9 +++++++++ Sources/CodexBar/StatusItemController.swift | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3c77d03e0..1dd1b5dd3 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -422,6 +422,9 @@ extension StatusItemController { } @objc func handleDebugBlinkNotification() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.forceBlinkNow() } @@ -500,6 +503,9 @@ extension StatusItemController { } private func updateAnimationFrame() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.animationPhase += 0.045 // half-speed animation if self.shouldMergeIcons { self.applyIcon(phase: self.animationPhase) @@ -519,6 +525,9 @@ extension StatusItemController { } @objc func handleDebugReplayNotification(_ notification: Notification) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif if let raw = notification.userInfo?["pattern"] as? String, let selected = LoadingPattern(rawValue: raw) { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index f8da71a40..1c95c0ca6 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -59,6 +59,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuRefreshTasks: [ObjectIdentifier: Task] = [:] #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? + var isReleasedForTesting = false #endif var blinkTask: Task? var loginTask: Task? { @@ -245,6 +246,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } @objc private func handleProviderConfigDidChange(_ notification: Notification) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let reason = notification.userInfo?["reason"] as? String ?? "unknown" if let source = notification.object as? SettingsStore, source !== self.settings @@ -271,6 +275,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func invalidateMenus() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.menuContentVersion &+= 1 guard Self.menuRefreshEnabled else { return } // Don't refresh menus while they're open - wait until they close and reopen @@ -327,6 +334,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateIcons() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil @@ -353,6 +363,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateVisibility() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let anyEnabled = !self.store.enabledProviders().isEmpty let force = self.store.debugForceAnimation let mergeIcons = self.shouldMergeIcons @@ -475,6 +488,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #if DEBUG func releaseStatusItemsForTesting() { + guard !self.isReleasedForTesting else { return } + self.isReleasedForTesting = true self.blinkTask?.cancel() self.loginTask?.cancel() self.animationDriver?.stop() From 71451a3e94aae567df372e59ec6892718391d906 Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 10:33:14 +0100 Subject: [PATCH 09/10] Close remaining teardown guard gaps --- Sources/CodexBar/StatusItemController+Animation.swift | 3 +++ Sources/CodexBar/StatusItemController.swift | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 1dd1b5dd3..5b45d98b8 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -14,6 +14,9 @@ extension StatusItemController { } func updateBlinkingState() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif // During the loading animation, blink ticks can overwrite the animated menu bar icon and cause flicker. if self.needsMenuBarIconAnimation() { self.stopBlinking() diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 1c95c0ca6..94d674c38 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -319,6 +319,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func handleSettingsChange(reason: String) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() @@ -454,6 +457,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func rebuildProviderStatusItems() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif for item in self.statusItems.values { self.statusBar.removeStatusItem(item) } From 428fd68cf4576199df69129a38ebe78df5eb17f0 Mon Sep 17 00:00:00 2001 From: Artus KG Date: Mon, 16 Feb 2026 11:04:54 +0100 Subject: [PATCH 10/10] Guard login refresh after test teardown --- Sources/CodexBar/StatusItemController.swift | 3 ++ Tests/CodexBarTests/StatusMenuTests.swift | 33 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 94d674c38..10f724183 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -406,6 +406,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func refreshMenusForLoginStateChange() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.invalidateMenus() if self.shouldMergeIcons { self.attachMenus() diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index e812d909d..952978c8e 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -220,6 +220,39 @@ struct StatusMenuTests { #expect(delayedRefreshWakeCount == 0) } + @Test + func loginStateCallbacksDoNotAttachMenusAfterRelease() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.releaseStatusItemsForTesting() + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + + controller.activeLoginProvider = .codex + let loginTask = Task {} + controller.loginTask = loginTask + loginTask.cancel() + controller.loginTask = nil + controller.activeLoginProvider = nil + + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + } + @Test func providerToggleUpdatesStatusItemVisibility() { self.disableMenuCardsForTesting()