diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac0eae4cc..a578f8992a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7! - Provider status: decode status feeds on the concurrent executor and reuse ISO8601 formatters, removing a measured main-thread stall during refreshes (#1406). Thanks @ProspectOre! - Menu bar: keep one stable width across merged provider tabs and resize every hosted card row to AppKit's final menu width so provider switching no longer leaves a widened menu with inset submenu arrows (#1410). +- Menu bar: keep Codex `auth.json` reads, JWT parsing, and fingerprint hashing off the menu-build path by rendering a cached account snapshot and revalidating it asynchronously (#1401). Thanks @ProspectOre! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! - Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 1f7987b160..a0be2ddb38 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -141,6 +141,7 @@ extension SettingsStore { } set { self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil self.updateProviderConfig(provider: .codex) { entry in entry.codexActiveSource = newValue } @@ -166,6 +167,7 @@ extension SettingsStore { @discardableResult func refreshCodexAccountReconciliationAfterManagedAccountsDidChange() -> Bool { self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil return self.persistResolvedCodexActiveSourceCorrectionIfNeeded() } @@ -210,6 +212,7 @@ extension SettingsStore { func invalidateCodexAccountReconciliationSnapshotCache() { self.cachedCodexAccountReconciliationSnapshot = nil + self.codexAccountReconciliationGeneration &+= 1 } var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot { @@ -230,16 +233,84 @@ extension SettingsStore { return cached.snapshot } - let snapshot = self.codexAccountReconciler(activeSource: activeSource).loadSnapshot() + let snapshot = self.codexAccountSnapshotLoader(activeSource: activeSource)() + let loadedAt = Date() if cacheInterval > 0 { self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( activeSource: activeSource, - loadedAt: now, + loadedAt: loadedAt, snapshot: snapshot) } + if activeSource == self.codexPersistedActiveSource { + self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection( + activeSource: activeSource, + loadedAt: loadedAt, + projection: CodexVisibleAccountProjection.make(from: snapshot)) + } return snapshot } + /// Menu rendering must stay side-effect free: no `auth.json` reads, JWT parsing, or fingerprint hashing. + var codexVisibleAccountProjectionForMenuDisplay: CodexVisibleAccountProjection? { + let activeSource = self.codexPersistedActiveSource + guard let cached = self.cachedCodexAccountMenuProjection, + cached.activeSource == activeSource + else { + return nil + } + return cached.projection + } + + var codexAccountMenuProjectionNeedsRevalidation: Bool { + let activeSource = self.codexPersistedActiveSource + guard let cached = self.cachedCodexAccountMenuProjection, + cached.activeSource == activeSource + else { + return true + } + return Date().timeIntervalSince(cached.loadedAt) >= Self.codexAccountReconciliationSnapshotCacheInterval + } + + func revalidateCodexAccountMenuProjection() async -> CodexAccountMenuProjectionRevalidationResult { + guard self.codexAccountMenuProjectionNeedsRevalidation else { return .skipped } + + let activeSource = self.codexPersistedActiveSource + let generation = self.codexAccountReconciliationGeneration + let loader = self.codexAccountSnapshotLoader(activeSource: activeSource) + let snapshot = await Self.loadCodexAccountSnapshot(loader) + + guard generation == self.codexAccountReconciliationGeneration, + activeSource == self.codexPersistedActiveSource + else { + return .discarded + } + + let now = Date() + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let previousProjection = self.cachedCodexAccountMenuProjection.flatMap { cached in + cached.activeSource == activeSource ? cached.projection : nil + } + self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection( + activeSource: activeSource, + loadedAt: now, + projection: projection) + if Self.codexAccountReconciliationSnapshotCacheInterval > 0 { + self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: activeSource, + loadedAt: now, + snapshot: snapshot) + } + return previousProjection == projection ? .unchanged : .updated + } + + @concurrent + private nonisolated static func loadCodexAccountSnapshot( + _ loader: @escaping @Sendable () -> CodexAccountReconciliationSnapshot) + async -> CodexAccountReconciliationSnapshot + { + loader() + } + var codexVisibleAccountProjection: CodexVisibleAccountProjection { CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) } @@ -257,11 +328,8 @@ extension SettingsStore { } func selectDisplayedCodexVisibleAccount(_ account: CodexVisibleAccount) { - if self.selectCodexVisibleAccount(id: account.id) { - return - } - // An open menu can preserve a previously rendered account row while the live projection is briefly incomplete. - self.invalidateCodexAccountReconciliationSnapshotCache() + // The row already carries the exact source it represented. Re-resolving its ID would synchronously + // reload auth state from the menu click callback and can also fail after a stale snapshot is rendered. self.codexActiveSource = account.selectionSource } @@ -283,6 +351,18 @@ extension SettingsStore { self.codexVisibleAccountProjection.source(forVisibleAccountID: id) } + private func codexAccountSnapshotLoader( + activeSource: CodexActiveSource) -> @Sendable () -> CodexAccountReconciliationSnapshot + { + #if DEBUG + if let loader = self._test_codexAccountSnapshotLoader { + return { loader(activeSource) } + } + #endif + let reconciler = self.codexAccountReconciler(activeSource: activeSource) + return { reconciler.loadSnapshot() } + } + private func codexAccountReconciler(activeSource: CodexActiveSource) -> DefaultCodexAccountReconciler { let baseEnvironment = self.codexReconciliationEnvironment() #if DEBUG @@ -518,10 +598,15 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb } extension SettingsStore { + private func invalidateCodexAccountReconciliationCachesForTesting() { + self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil + } + var _test_activeManagedCodexRemoteHomePath: String? { get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) } } @@ -529,7 +614,7 @@ extension SettingsStore { var _test_activeManagedCodexAccount: ManagedCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.account(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) } } @@ -537,7 +622,7 @@ extension SettingsStore { var _test_unreadableManagedCodexAccountStore: Bool { get { CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) } } @@ -545,7 +630,7 @@ extension SettingsStore { var _test_managedCodexAccountStoreURL: URL? { get { CodexManagedRemoteHomeTestingOverride.managedStoreURL(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) } } @@ -553,7 +638,7 @@ extension SettingsStore { var _test_liveSystemCodexAccount: ObservedSystemCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) } } @@ -561,7 +646,7 @@ extension SettingsStore { var _test_codexReconciliationEnvironment: [String: String]? { get { CodexManagedRemoteHomeTestingOverride.reconciliationEnvironment(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) } } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index c6135391ce..7a05a305bc 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -114,6 +114,19 @@ struct CachedCodexAccountReconciliationSnapshot { let snapshot: CodexAccountReconciliationSnapshot } +struct CachedCodexAccountMenuProjection: Equatable { + let activeSource: CodexActiveSource + let loadedAt: Date + let projection: CodexVisibleAccountProjection +} + +enum CodexAccountMenuProjectionRevalidationResult: Equatable { + case skipped + case discarded + case unchanged + case updated +} + @MainActor @Observable final class SettingsStore { @@ -140,6 +153,12 @@ final class SettingsStore { @ObservationIgnored var tokenAccountsLoaded = false @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: CachedCodexAccountReconciliationSnapshot? + @ObservationIgnored var cachedCodexAccountMenuProjection: CachedCodexAccountMenuProjection? + @ObservationIgnored var codexAccountReconciliationGeneration: UInt = 0 + #if DEBUG + @ObservationIgnored var _test_codexAccountSnapshotLoader: + (@Sendable (CodexActiveSource) -> CodexAccountReconciliationSnapshot)? + #endif @ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState diff --git a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift index ea0bb9d2df..2c268511f4 100644 --- a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift +++ b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift @@ -2,6 +2,30 @@ import AppKit import CodexBarCore extension StatusItemController { + private static let defaultCodexAccountMenuProjectionRevalidationEnabled = !SettingsStore.isRunningTests + + #if DEBUG + private static var codexAccountMenuProjectionRevalidationEnabledForTesting = + defaultCodexAccountMenuProjectionRevalidationEnabled + + static func setCodexAccountMenuProjectionRevalidationEnabledForTesting(_ enabled: Bool) { + self.codexAccountMenuProjectionRevalidationEnabledForTesting = enabled + } + + static func resetCodexAccountMenuProjectionRevalidationEnabledForTesting() { + self.codexAccountMenuProjectionRevalidationEnabledForTesting = + self.defaultCodexAccountMenuProjectionRevalidationEnabled + } + #endif + + private static var codexAccountMenuProjectionRevalidationEnabled: Bool { + #if DEBUG + self.codexAccountMenuProjectionRevalidationEnabledForTesting + #else + self.defaultCodexAccountMenuProjectionRevalidationEnabled + #endif + } + func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) @@ -35,7 +59,7 @@ extension StatusItemController { func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { guard provider == .codex else { return nil } - let projection = self.settings.codexVisibleAccountProjection + guard let projection = self.settings.codexVisibleAccountProjectionForMenuDisplay else { return nil } guard projection.visibleAccounts.count > 1 else { return nil } let showAll = self.settings.multiAccountMenuLayout == .stacked let accounts = showAll @@ -52,6 +76,31 @@ extension StatusItemController { layout: showAll ? .stacked : .segmented) } + func scheduleCodexAccountMenuProjectionRevalidationIfNeeded(for providers: [UsageProvider]) { + guard Self.codexAccountMenuProjectionRevalidationEnabled else { return } + guard providers.contains(.codex) else { return } + guard self.settings.codexAccountMenuProjectionNeedsRevalidation else { return } + guard self.codexAccountMenuProjectionRevalidationTask == nil else { return } + + self.codexAccountMenuProjectionRevalidationTask = Task { @MainActor [weak self] in + guard let settings = self?.settings else { return } + let result = await settings.revalidateCodexAccountMenuProjection() + guard let self else { return } + guard !Task.isCancelled else { + self.codexAccountMenuProjectionRevalidationTask = nil + return + } + self.codexAccountMenuProjectionRevalidationTask = nil + + switch result { + case .updated: + self.invalidateMenus(refreshOpenMenus: false) + case .discarded, .skipped, .unchanged: + break + } + } + } + private func codexAccountSnapshots(matching accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] { var snapshotsByID: [String: CodexAccountUsageSnapshot] = [:] for snapshot in self.store.codexAccountSnapshots { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2e4f9c520c..d1a491b794 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -121,6 +121,8 @@ extension StatusItemController { let menuWasFreshBeforeOpen = !self.menuNeedsRefresh(menu) self.refreshMenuForOpenIfNeeded(menu, provider: provider) + self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( + for: self.renderedProviders(for: menu)) if self.isMenuRefreshEnabled { // 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. diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index f7acd4a4cd..7601eeb031 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -303,7 +303,7 @@ extension StatusItemController { parts.append(target.rawValue) parts.append(self.providerIdentitySignature(self.store.snapshot(for: target)?.identity(for: target))) - if self.store.metadata(for: target).usesAccountFallback { + if target != .codex, self.store.metadata(for: target).usesAccountFallback { let account = self.store.accountInfo(for: target) parts.append(Self.menuIdentityField(account.email)) parts.append(Self.menuIdentityField(account.plan)) @@ -316,7 +316,9 @@ extension StatusItemController { } if target == .codex { - for account in self.settings.codexVisibleAccountProjection.visibleAccounts { + parts.append(Self.menuIdentityField(self.account.email)) + parts.append(Self.menuIdentityField(self.account.plan)) + for account in self.settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts ?? [] { parts.append(Self.menuIdentityField(account.id)) parts.append(Self.menuIdentityField(account.email)) parts.append(Self.menuIdentityField(account.workspaceLabel)) diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 5ca3f397bf..6494086c96 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -52,6 +52,8 @@ extension StatusItemController { } self.openMenuInvalidationRetryTask?.cancel() self.openMenuInvalidationRetryTask = nil + self.codexAccountMenuProjectionRevalidationTask?.cancel() + self.codexAccountMenuProjectionRevalidationTask = nil self.providerSelectionUIRefreshTask?.cancel() self.providerSelectionUIRefreshTask = nil self.deferredMergedIconRenderAfterTracking = false diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index ca719199ad..60ad9d553a 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -139,6 +139,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 var menuIdentitySignatures: [ObjectIdentifier: String] = [:] + var codexAccountMenuProjectionRevalidationTask: Task? var openMenuRebuildsClosingHostedSubviewMenus: Set = [] var parentMenuRebuildsDeferredDuringTracking: Set = [] var deferredMenuInteractionRefreshProviders: Set = [] @@ -415,6 +416,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.wireBindings() self.updateVisibility() self.updateIcons() + self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( + for: self.store.enabledProvidersForDisplay()) self.scheduleStartupStatusItemVisibilityCheck() NotificationCenter.default.addObserver( self, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift index 25dcae7ac5..3b2f8a0758 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift @@ -119,7 +119,7 @@ public struct CodexAccountReconciliationSnapshot: Equatable, Sendable { } } -public struct DefaultCodexAccountReconciler { +public struct DefaultCodexAccountReconciler: Sendable { public let storeLoader: @Sendable () throws -> ManagedCodexAccountSet public let systemObserver: any CodexSystemAccountObserving public let activeSource: CodexActiveSource diff --git a/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift new file mode 100644 index 0000000000..5941c8b000 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift @@ -0,0 +1,346 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct CodexAccountMenuDisplaySnapshotTests { + private func makeSettings() -> SettingsStore { + let suite = "CodexAccountMenuDisplaySnapshotTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func liveSnapshot(email: String) -> CodexAccountReconciliationSnapshot { + CodexAccountReconciliationSnapshot( + storedAccounts: [], + activeStoredAccount: nil, + liveSystemAccount: ObservedSystemCodexAccount( + email: email, + codexHomePath: "/tmp/\(email)", + observedAt: Date()), + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .liveSystem, + hasUnreadableAddedAccountStore: false) + } + + private func cachedProjection( + snapshot: CodexAccountReconciliationSnapshot, + loadedAt: Date = Date(timeIntervalSinceNow: -3600)) -> CachedCodexAccountMenuProjection + { + CachedCodexAccountMenuProjection( + activeSource: snapshot.activeSource, + loadedAt: loadedAt, + projection: CodexVisibleAccountProjection.make(from: snapshot)) + } + + @Test + func `cold menu projection read never loads auth state`() async { + let settings = self.makeSettings() + let probe = CodexAccountSnapshotLoaderProbe(snapshot: self.liveSnapshot(email: "loaded@example.com")) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + defer { settings._test_codexAccountSnapshotLoader = nil } + + #expect(settings.codexVisibleAccountProjectionForMenuDisplay == nil) + #expect(probe.callCount == 0) + + let result = await settings.revalidateCodexAccountMenuProjection() + + #expect(result == .updated) + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "loaded@example.com") + } + + @Test + func `override snapshot load preserves persisted account menu projection`() { + let settings = self.makeSettings() + let activeSnapshot = self.liveSnapshot(email: "active@example.com") + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: activeSnapshot) + + let otherID = UUID() + let otherAccount = ManagedCodexAccount( + id: otherID, + email: "other@example.com", + managedHomePath: "/tmp/other", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let overrideSnapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [otherAccount], + activeStoredAccount: otherAccount, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: otherID), + hasUnreadableAddedAccountStore: false) + settings._test_codexAccountSnapshotLoader = { _ in overrideSnapshot } + defer { settings._test_codexAccountSnapshotLoader = nil } + + _ = settings.codexAccountReconciliationSnapshot(activeSourceOverride: .managedAccount(id: otherID)) + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "active@example.com") + } + + @Test + func `managed account change refreshes account menu projection`() { + let settings = self.makeSettings() + let activeSnapshot = self.liveSnapshot(email: "active@example.com") + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: activeSnapshot, loadedAt: Date()) + + let addedAccount = ManagedCodexAccount( + id: UUID(), + email: "added@example.com", + managedHomePath: "/tmp/added", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let refreshedSnapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [addedAccount], + activeStoredAccount: nil, + liveSystemAccount: activeSnapshot.liveSystemAccount, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .liveSystem, + hasUnreadableAddedAccountStore: false) + settings._test_codexAccountSnapshotLoader = { _ in refreshedSnapshot } + defer { settings._test_codexAccountSnapshotLoader = nil } + + settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.contains { + $0.email == "added@example.com" + } == true) + } + + @Test + func `stale menu projection returns immediately then refreshes concurrently`() async { + let settings = self.makeSettings() + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe(snapshot: self.liveSnapshot(email: "after@example.com")) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "before@example.com") + #expect(probe.callCount == 0) + #expect(settings.codexAccountMenuProjectionNeedsRevalidation) + + let result = await settings.revalidateCodexAccountMenuProjection() + + #expect(result == .updated) + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "after@example.com") + } + + @Test + func `revalidation discards result after reconciliation generation changes`() async { + let settings = self.makeSettings() + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe( + snapshot: self.liveSnapshot(email: "discarded@example.com"), + blocks: true) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + probe.release() + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + let task = Task { await settings.revalidateCodexAccountMenuProjection() } + await probe.waitUntilCalled() + settings.invalidateCodexAccountReconciliationSnapshotCache() + probe.release() + + #expect(await task.value == .discarded) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "before@example.com") + } + + @Test + func `fresh menu open coalesces account projection revalidation and identity stays read only`() async throws { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + StatusItemController.setCodexAccountMenuProjectionRevalidationEnabledForTesting(true) + defer { + StatusItemController.resetCodexAccountMenuProjectionRevalidationEnabledForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe( + snapshot: self.liveSnapshot(email: "after@example.com"), + blocks: true) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + probe.release() + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + let menu = NSMenu() + controller.menuProviders[ObjectIdentifier(menu)] = .codex + controller.markMenuFresh(menu) + #expect(controller.codexAccountMenuDisplay(for: .codex) == nil) + #expect(probe.callCount == 0) + + let versionBeforeOpen = controller.menuContentVersion + controller.menuWillOpen(menu) + let revalidation = try #require(controller.codexAccountMenuProjectionRevalidationTask) + controller.menuWillOpen(menu) + await probe.waitUntilCalled() + + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + probe.release() + await revalidation.value + + #expect(controller.codexAccountMenuProjectionRevalidationTask == nil) + #expect(controller.menuContentVersion == versionBeforeOpen + 1) + } + + @Test + func `selecting displayed account uses captured source without reconciliation`() throws { + let settings = self.makeSettings() + let firstID = UUID() + let secondID = UUID() + let first = ManagedCodexAccount( + id: firstID, + email: "first@example.com", + managedHomePath: "/tmp/first", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let second = ManagedCodexAccount( + id: secondID, + email: "second@example.com", + managedHomePath: "/tmp/second", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [first, second], + activeStoredAccount: first, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: firstID), + hasUnreadableAddedAccountStore: false) + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let displayedAccount = try #require(projection.visibleAccounts.first { + $0.selectionSource == .managedAccount(id: secondID) + }) + let probe = CodexAccountSnapshotLoaderProbe(snapshot: snapshot) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: snapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + defer { settings._test_codexAccountSnapshotLoader = nil } + + settings.selectDisplayedCodexVisibleAccount(displayedAccount) + + #expect(probe.callCount == 0) + #expect(settings.codexActiveSource == .managedAccount(id: secondID)) + #expect(settings.cachedCodexAccountMenuProjection == nil) + } +} + +private final class CodexAccountSnapshotLoaderProbe: @unchecked Sendable { + private let lock = NSLock() + private let snapshot: CodexAccountReconciliationSnapshot + private let blocks: Bool + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var _callCount = 0 + private var _loadedOffMainThread = false + private var released = false + + init(snapshot: CodexAccountReconciliationSnapshot, blocks: Bool = false) { + self.snapshot = snapshot + self.blocks = blocks + } + + var callCount: Int { + self.lock.withLock { self._callCount } + } + + var loadedOffMainThread: Bool { + self.lock.withLock { self._loadedOffMainThread } + } + + func load() -> CodexAccountReconciliationSnapshot { + self.lock.withLock { + self._callCount += 1 + self._loadedOffMainThread = self._loadedOffMainThread || !Thread.isMainThread + } + if self.blocks { + self.releaseSemaphore.wait() + } + return self.snapshot + } + + func waitUntilCalled() async { + while self.callCount == 0 { + await Task.yield() + } + } + + func release() { + let shouldSignal = self.lock.withLock { + guard !self.released else { return false } + self.released = true + return true + } + if shouldSignal { + self.releaseSemaphore.signal() + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index 4ad6ce8033..c7feea4789 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -223,6 +223,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -285,6 +286,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -449,6 +451,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)