diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index edcc1d0dc..5ecff079d 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -314,6 +314,9 @@ private struct ProviderDetailInfoGrid: View { if self.store.refreshingProviders.contains(self.provider) { return "Refreshing" } + if self.store.unavailableMessage(for: self.provider) != nil { + return "Unavailable" + } return "Not fetched yet" } } diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index e3934d288..9f0613c33 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -115,7 +115,10 @@ struct ProviderRegistry { // Mac's Codex sessions, not as account-owned remote state. If we later want // account-scoped token history in the UI, that needs an explicit product decision and // presentation change so the two concepts are not conflated. - if provider == .codex, let managedHomePath = settings.activeManagedCodexRemoteHomePath { + if provider == .codex, + case .managedAccount = settings.codexActiveSource, + let managedHomePath = settings.activeManagedCodexRemoteHomePath + { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) } return env diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 7206412fd..468f2d566 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -217,8 +217,10 @@ extension StatusItemController { return false } - func applyIcon(phase: Double?) { - guard let button = self.statusItem.button else { return } + // swiftlint:disable function_body_length + @discardableResult + func applyIcon(phase: Double?) -> Bool { + guard let button = self.statusItem.button else { return false } let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed @@ -299,31 +301,92 @@ extension StatusItemController { } return .none }() + let debugDouble: (Double?) -> String = { value in + guard let value else { return "nil" } + return String(format: "%.3f", value) + } if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) + let signature = [ + "mode=brandPercent", + "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "text=\(displayText ?? "nil")", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } self.setButtonImage(brand, for: button) self.setButtonTitle(displayText, for: button) - return + return false } if Self.shouldUseOpenRouterBrandFallback(provider: primaryProvider, snapshot: snapshot), let brand = ProviderBrandIcon.image(for: primaryProvider) { + let signature = [ + "mode=openRouterFallback", + "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } self.setButtonTitle(nil, for: button) self.setButtonImage( Self.brandImageWithStatusOverlay(brand: brand, statusIndicator: statusIndicator), for: button) - return + return false } self.setButtonTitle(nil, for: button) if let morphProgress { + let signature = [ + "mode=morph", + "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", + "morph=\(debugDouble(morphProgress))", + "status=\(statusIndicator.rawValue)", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage(image, for: button) } else { + let signature = [ + "mode=icon", + "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "blink=\(debugDouble(Double(blink)))", + "wiggle=\(debugDouble(Double(wiggle)))", + "tilt=\(debugDouble(Double(tilt)))", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } let image = IconRenderer.makeIcon( primaryRemaining: primary, weeklyRemaining: weekly, @@ -336,6 +399,18 @@ extension StatusItemController { statusIndicator: statusIndicator) self.setButtonImage(image, for: button) } + return false + } + + // swiftlint:enable function_body_length + + private func shouldSkipMergedIconRender(_ signature: String) -> Bool { + guard self.shouldMergeIcons else { return false } + if self.lastAppliedMergedIconRenderSignature == signature { + return true + } + self.lastAppliedMergedIconRenderSignature = signature + return false } func applyIcon(for provider: UsageProvider, phase: Double?) { diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift new file mode 100644 index 000000000..1676db9b1 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -0,0 +1,153 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + private static let hostedSubviewWidth: CGFloat = 310 + + func makeHostedSubviewPlaceholderMenu(chartID: String, provider: UsageProvider? = nil) -> NSMenu { + let submenu = NSMenu() + submenu.delegate = self + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = chartID + chartItem.toolTip = provider?.rawValue + submenu.addItem(chartItem) + return submenu + } + + func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu) { + guard let placeholder = menu.items.first, + menu.items.count == 1, + placeholder.view == nil, + let chartID = placeholder.representedObject as? String + else { + return + } + + let width = Self.hostedSubviewWidth + menu.removeAllItems() + + let didHydrate: Bool = switch chartID { + case Self.usageBreakdownChartID: + self.appendUsageBreakdownChartItem(to: menu, width: width) + case Self.creditsHistoryChartID: + self.appendCreditsHistoryChartItem(to: menu, width: width) + case Self.costHistoryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendCostHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.usageHistoryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendUsageHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + default: + false + } + + guard !didHydrate else { return } + + let unavailableItem = NSMenuItem(title: "No data available", action: nil, keyEquivalent: "") + unavailableItem.isEnabled = false + unavailableItem.representedObject = chartID + unavailableItem.toolTip = placeholder.toolTip + menu.addItem(unavailableItem) + } + + @discardableResult + func appendUsageBreakdownChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { + let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + guard !breakdown.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.usageBreakdownChartID + submenu.addItem(chartItem) + return true + } + + let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.usageBreakdownChartID + submenu.addItem(chartItem) + return true + } + + @discardableResult + func appendCreditsHistoryChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { + let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] + guard !breakdown.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.creditsHistoryChartID + submenu.addItem(chartItem) + return true + } + + let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.creditsHistoryChartID + submenu.addItem(chartItem) + return true + } + + @discardableResult + func appendCostHistoryChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return false } + guard !tokenSnapshot.daily.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.costHistoryChartID + submenu.addItem(chartItem) + return true + } + + let chartView = CostHistoryChartMenuView( + provider: provider, + daily: tokenSnapshot.daily, + totalCostUSD: tokenSnapshot.last30DaysCostUSD, + width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.costHistoryChartID + submenu.addItem(chartItem) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 332ba5ab4..88055ad39 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -11,6 +11,10 @@ extension StatusItemController { private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit private static let overviewRowIdentifierPrefix = "overviewRow-" private static let menuOpenRefreshDelay: Duration = .seconds(1.2) + static let usageBreakdownChartID = "usageBreakdownChart" + static let creditsHistoryChartID = "creditsHistoryChart" + static let costHistoryChartID = "costHistoryChart" + static let usageHistoryChartID = "usageHistoryChart" private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { _ = menu @@ -29,6 +33,7 @@ extension StatusItemController { func menuWillOpen(_ menu: NSMenu) { if self.isHostedSubviewMenu(menu) { + self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") @@ -118,13 +123,15 @@ extension StatusItemController { currentProvider: currentProvider, showAllTokenAccounts: showAllTokenAccounts) - let hasAuxiliarySwitcher = menu.items.contains { - $0.view is TokenAccountSwitcherView || $0.view is CodexAccountSwitcherView - } + let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } + let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } let switcherProvidersMatch = enabledProviders == self.lastSwitcherProviders let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview + let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher + let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && + ((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher)) let canSmartUpdate = self.shouldMergeIcons && enabledProviders.count > 1 && !isOverviewSelected && @@ -132,9 +139,8 @@ extension StatusItemController { switcherUsageBarsShowUsedMatch && switcherSelectionMatches && switcherOverviewAvailabilityMatches && - codexAccountDisplay == nil && - tokenAccountDisplay == nil && - !hasAuxiliarySwitcher && + tokenSwitcherCompatible && + codexSwitcherCompatible && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView @@ -173,6 +179,7 @@ extension StatusItemController { self.lastSwitcherIncludesOverview = includesOverview } self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay) + self.lastCodexAccountMenuDisplay = codexAccountDisplay self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( currentProvider: currentProvider, @@ -222,6 +229,11 @@ extension StatusItemController { if menu.items.first?.view is ProviderSwitcherView { contentStartIndex = 2 } + if menu.items.count > contentStartIndex, + menu.items[contentStartIndex].view is CodexAccountSwitcherView + { + contentStartIndex += 2 + } if menu.items.count > contentStartIndex, menu.items[contentStartIndex].view is TokenAccountSwitcherView { @@ -850,7 +862,11 @@ extension StatusItemController { guard !Task.isCancelled else { return } guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard !self.store.isRefreshing else { return } - guard self.menuNeedsDelayedRefreshRetry(for: menu) else { return } + let retryProviders = self.delayedRefreshRetryProviders(for: menu) + let retryStaleProviderCount = retryProviders.count { self.store.isStale(provider: $0) } + let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } + let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 + guard willRetryRefresh else { return } self.refreshStore(forceTokenUsage: false) } } @@ -1214,112 +1230,27 @@ extension StatusItemController { } private func makeUsageBreakdownSubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] - let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu + guard !(self.store.openAIDashboard?.usageBreakdown ?? []).isEmpty else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageBreakdownChartID) } private func makeCreditsHistorySubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] - let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "creditsHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "creditsHistoryChart" - submenu.addItem(chartItem) - return submenu + guard !(self.store.openAIDashboard?.dailyBreakdown ?? []).isEmpty else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.creditsHistoryChartID) } private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } - let width = Self.menuCardBaseWidth - guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } - guard !tokenSnapshot.daily.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "costHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = CostHistoryChartMenuView( - provider: provider, - daily: tokenSnapshot.daily, - totalCostUSD: tokenSnapshot.last30DaysCostUSD, - width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "costHistoryChart" - submenu.addItem(chartItem) - return submenu + guard self.store.tokenSnapshot(for: provider)?.daily.isEmpty == false else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.costHistoryChartID, provider: provider) } private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ - "usageBreakdownChart", - "creditsHistoryChart", - "costHistoryChart", - "usageHistoryChart", + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, + Self.costHistoryChartID, + Self.usageHistoryChartID, ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } @@ -1329,8 +1260,8 @@ extension StatusItemController { private func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ - "usageBreakdownChart", - "creditsHistoryChart", + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 18243f3c1..3e8c6c3cc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -55,7 +55,7 @@ struct TokenAccountMenuDisplay { let showSwitcher: Bool } -struct CodexAccountMenuDisplay { +struct CodexAccountMenuDisplay: Equatable { let accounts: [CodexVisibleAccount] let activeVisibleAccountID: String? } diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 7bd0ea512..74bf8fae7 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -942,7 +942,6 @@ final class CodexAccountSwitcherView: NSView { let second = Array(self.accounts.dropFirst(perRow)) return [first, second] }() - let stack = NSStackView() stack.orientation = .vertical stack.alignment = .centerX @@ -959,8 +958,9 @@ final class CodexAccountSwitcherView: NSView { let buttonWidth = self.buttonWidth(for: rowAccounts.count) for account in rowAccounts { + let title = self.compactButtonTitle(for: account, buttonWidth: buttonWidth) let button = PaddedToggleButton( - title: self.compactButtonTitle(for: account, buttonWidth: buttonWidth), + title: title, target: self, action: #selector(self.handleSelect)) button.identifier = NSUserInterfaceItemIdentifier(account.id) diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 3c4e7f4c8..a2d5b9600 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -35,13 +35,10 @@ extension StatusItemController { private func makeUsageHistorySubmenu(provider: UsageProvider) -> NSMenu? { guard self.store.supportsPlanUtilizationHistory(for: provider) else { return nil } guard !self.store.shouldHidePlanUtilizationMenuItem(for: provider) else { return nil } - let width: CGFloat = 310 - let submenu = NSMenu() - submenu.delegate = self - return self.appendUsageHistoryChartItem(to: submenu, provider: provider, width: width) ? submenu : nil + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageHistoryChartID, provider: provider) } - private func appendUsageHistoryChartItem( + func appendUsageHistoryChartItem( to submenu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool @@ -52,7 +49,7 @@ extension StatusItemController { if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() chartItem.isEnabled = false - chartItem.representedObject = "usageHistoryChart" + chartItem.representedObject = Self.usageHistoryChartID submenu.addItem(chartItem) return true } @@ -70,7 +67,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.view = hosting chartItem.isEnabled = false - chartItem.representedObject = "usageHistoryChart" + chartItem.representedObject = Self.usageHistoryChartID submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index c818e13cc..b77865f96 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -120,6 +120,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastSwitcherProviders: [UsageProvider] = [] /// Tracks which switcher tab state was used for the current merged-menu switcher instance. var lastMergedSwitcherSelection: ProviderSwitcherSelection? + /// Tracks the visible Codex account switcher contents for merged-menu smart updates. + var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? + var lastAppliedMergedIconRenderSignature: String? let loginLogger = CodexBarLog.logger(LogCategories.login) var selectedMenuProvider: UsageProvider? { get { self.settings.selectedMenuProvider } @@ -279,6 +282,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private func wireBindings() { self.observeStoreChanges() + self.observeStoreIconChanges() self.observeDebugForceAnimation() self.observeSettingsChanges() self.observeUpdaterChanges() @@ -293,8 +297,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin guard let self else { return } self.observeStoreChanges() self.invalidateMenus() + } + } + } + + private func observeStoreIconChanges() { + withObservationTracking { + _ = self.store.iconObservationToken + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.observeStoreIconChanges() self.updateIcons() - self.updateBlinkingState() } } } @@ -435,7 +449,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil if self.shouldMergeIcons { - self.applyIcon(phase: phase) + let skippedMergedRender = self.applyIcon(phase: phase) + if skippedMergedRender, + let mergedMenu = self.mergedMenu, + self.statusItem.menu === mergedMenu + { + return + } self.attachMenus() } else { UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: phase) } diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 7f8f828be..743f405e2 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -35,9 +35,36 @@ extension UsageStore { } func userFacingError(for provider: UsageProvider) -> String? { - let raw = self.errors[provider] - guard provider == .codex else { return raw } - return CodexUIErrorMapper.userFacingMessage(raw) + if let raw = self.errors[provider] { + guard provider == .codex else { return raw } + return CodexUIErrorMapper.userFacingMessage(raw) + } + return self.unavailableMessage(for: provider) + } + + func unavailableMessage(for provider: UsageProvider) -> String? { + guard self.enabledProvidersForDisplay().contains(provider), + !self.isProviderAvailable(provider) + else { + return nil + } + + switch provider { + case .synthetic: + return SyntheticSettingsError.missingToken.errorDescription + case .zai: + return ZaiSettingsError.missingToken.errorDescription + case .openrouter: + return OpenRouterSettingsError.missingToken.errorDescription + case .perplexity: + return PerplexityAPIError.missingToken.errorDescription + case .minimax: + return MiniMaxAPISettingsError.missingToken.errorDescription + case .kimi: + return KimiAPIError.missingToken.errorDescription + default: + return "\(self.metadata(for: provider).displayName) is unavailable in the current environment." + } } func status(for provider: UsageProvider) -> ProviderStatus? { diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index cfe602df3..46f142347 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -3,22 +3,35 @@ import Foundation @MainActor extension UsageStore { + private func clearProviderState(_ provider: UsageProvider) { + self.refreshingProviders.remove(provider) + self.snapshots.removeValue(forKey: provider) + self.errors[provider] = nil + self.lastSourceLabels.removeValue(forKey: provider) + self.lastFetchAttempts.removeValue(forKey: provider) + self.accountSnapshots.removeValue(forKey: provider) + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.failureGates[provider]?.reset() + self.tokenFailureGates[provider]?.reset() + self.statuses.removeValue(forKey: provider) + self.lastKnownSessionRemaining.removeValue(forKey: provider) + self.lastKnownSessionWindowSource.removeValue(forKey: provider) + self.lastTokenFetchAt.removeValue(forKey: provider) + } + func clearDisabledProviderState(enabledProviders: Set) { for provider in UsageProvider.allCases where !enabledProviders.contains(provider) { - self.refreshingProviders.remove(provider) - self.snapshots.removeValue(forKey: provider) - self.errors[provider] = nil - self.lastSourceLabels.removeValue(forKey: provider) - self.lastFetchAttempts.removeValue(forKey: provider) - self.accountSnapshots.removeValue(forKey: provider) - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.failureGates[provider]?.reset() - self.tokenFailureGates[provider]?.reset() - self.statuses.removeValue(forKey: provider) - self.lastKnownSessionRemaining.removeValue(forKey: provider) - self.lastKnownSessionWindowSource.removeValue(forKey: provider) - self.lastTokenFetchAt.removeValue(forKey: provider) + self.clearProviderState(provider) + } + } + + func clearUnavailableProviderState( + displayEnabledProviders: Set, + availableProviders: Set) + { + for provider in displayEnabledProviders where !availableProviders.contains(provider) { + self.clearProviderState(provider) } } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0ecbc1820..354147954 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -22,8 +22,6 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin - _ = self.openAIDashboardCookieImportStatus - _ = self.openAIDashboardCookieImportDebugLog _ = self.versions _ = self.isRefreshing _ = self.refreshingProviders @@ -34,6 +32,21 @@ extension UsageStore { return 0 } + var iconObservationToken: Int { + _ = self.snapshots + _ = self.errors + _ = self.credits + _ = self.lastCreditsError + _ = self.openAIDashboard + _ = self.lastOpenAIDashboardError + _ = self.openAIDashboardRequiresLogin + _ = self.isRefreshing + _ = self.refreshingProviders + _ = self.statuses + _ = self.historicalPaceRevision + return 0 + } + func observeSettingsChanges() { withObservationTracking { _ = self.settings.refreshFrequency @@ -331,6 +344,11 @@ final class UsageStore { self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) } + /// Providers that should actually participate in background refresh/status/token work. + func enabledProvidersForBackgroundWork() -> [UsageProvider] { + self.enabledProviders() + } + var statusChecksEnabled: Bool { self.settings.statusChecksEnabled } @@ -436,8 +454,10 @@ final class UsageStore { guard !self.isRefreshing else { return } self.prepareRefreshState() let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup - let enabledProviders = self.enabledProvidersForDisplay() - let enabledProviderSet = Set(enabledProviders) + let displayEnabledProviders = self.enabledProvidersForDisplay() + let enabledProviderSet = Set(displayEnabledProviders) + let refreshProviders = self.enabledProvidersForBackgroundWork() + let availableRefreshProviders = Set(self.enabledProviders()) let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { @@ -448,11 +468,16 @@ final class UsageStore { } self.clearDisabledProviderState(enabledProviders: enabledProviderSet) + self.clearUnavailableProviderState( + displayEnabledProviders: enabledProviderSet, + availableProviders: availableRefreshProviders) await withTaskGroup(of: Void.self) { group in - for provider in enabledProviders { + for provider in refreshProviders { group.addTask { await self.refreshProvider(provider) } - group.addTask { await self.refreshStatus(provider) } + if availableRefreshProviders.contains(provider) { + group.addTask { await self.refreshStatus(provider) } + } } group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } } @@ -548,7 +573,7 @@ final class UsageStore { return } - let providers = self.enabledProvidersForDisplay() + let providers = self.enabledProvidersForBackgroundWork() self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in guard let self else { return } defer { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 64ca88b8d..fe654bfab 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -42,6 +42,36 @@ public struct OpenAIDashboardFetcher { 0.001 } + private struct DashboardSnapshotComponents { + let scrape: ScrapeResult + let codeReview: Double? + let codeReviewLimit: RateWindow? + let events: [CreditEvent] + let breakdown: [OpenAIDashboardDailyBreakdown] + let usageBreakdown: [OpenAIDashboardDailyBreakdown] + let rateLimits: (primary: RateWindow?, secondary: RateWindow?) + let creditsRemaining: Double? + let accountPlan: String? + } + + private nonisolated static func makeDashboardSnapshot(_ components: DashboardSnapshotComponents) + -> OpenAIDashboardSnapshot + { + OpenAIDashboardSnapshot( + signedInEmail: components.scrape.signedInEmail, + codeReviewRemainingPercent: components.codeReview, + codeReviewLimit: components.codeReviewLimit, + creditEvents: components.events, + dailyBreakdown: components.breakdown, + usageBreakdown: components.usageBreakdown, + creditsPurchaseURL: components.scrape.creditsPurchaseURL, + primaryLimit: components.rateLimits.primary, + secondaryLimit: components.rateLimits.secondary, + creditsRemaining: components.creditsRemaining, + accountPlan: components.accountPlan, + updatedAt: Date()) + } + public struct ProbeResult: Sendable { public let href: String? public let loginRequired: Bool @@ -105,7 +135,6 @@ public struct OpenAIDashboardFetcher { var creditsHeaderVisibleAt: Date? var lastUsageBreakdownDebug: String? var lastCreditsPurchaseURL: String? - while Date() < deadline { let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody @@ -222,19 +251,16 @@ public struct OpenAIDashboardFetcher { continue } } - return OpenAIDashboardSnapshot( - signedInEmail: scrape.signedInEmail, - codeReviewRemainingPercent: codeReview, + return Self.makeDashboardSnapshot(.init( + scrape: scrape, + codeReview: codeReview, codeReviewLimit: codeReviewLimit, - creditEvents: events, - dailyBreakdown: breakdown, + events: events, + breakdown: breakdown, usageBreakdown: usageBreakdown, - creditsPurchaseURL: scrape.creditsPurchaseURL, - primaryLimit: rateLimits.primary, - secondaryLimit: rateLimits.secondary, + rateLimits: rateLimits, creditsRemaining: creditsRemaining, - accountPlan: accountPlan, - updatedAt: Date()) + accountPlan: accountPlan)) } try? await Task.sleep(for: .milliseconds(500)) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index e7c9291e1..397e41138 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -34,6 +34,20 @@ final class OpenAIDashboardWebViewCache { private let idleTimeout: TimeInterval = 60 private let blankURL = URL(string: "about:blank")! + private func releaseCachedEntry(_ entry: Entry) { + entry.isBusy = false + entry.lastUsedAt = Date() + self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) + self.prune(now: Date()) + } + + private func releaseNewEntry(_ entry: Entry, webView: WKWebView) { + entry.isBusy = false + entry.lastUsedAt = Date() + self.prepareCachedWebViewForIdle(webView, host: entry.host) + self.prune(now: Date()) + } + // MARK: - Testing support #if DEBUG @@ -178,10 +192,7 @@ final class OpenAIDashboardWebViewCache { log: log, release: { [weak self, weak entry] in guard let self, let entry else { return } - entry.isBusy = false - entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) - self.prune(now: Date()) + self.releaseCachedEntry(entry) }) } @@ -215,10 +226,7 @@ final class OpenAIDashboardWebViewCache { log: log, release: { [weak self, weak entry] in guard let self, let entry else { return } - entry.isBusy = false - entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(webView, host: entry.host) - self.prune(now: Date()) + self.releaseNewEntry(entry, webView: webView) }) } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index 3226674b3..13359aedb 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -96,6 +96,8 @@ public struct CodexStatusProbe { default: throw error } + } catch { + throw error } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index b560f005e..962dc956d 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -568,54 +568,61 @@ public struct UsageFetcher: Sendable { private func loadRPCUsage() async throws -> UsageSnapshot { let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } - - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - // The app-server answers on a single stdout stream, so keep requests - // serialized to avoid starving one reader when multiple awaiters race - // for the same pipe. - let limits = try await rpc.fetchRateLimits().rateLimits - let account = try? await rpc.fetchAccount() - - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: account?.account.flatMap { details in - if case let .chatgpt(email, _) = details { email } else { nil } - }, - accountOrganization: nil, - loginMethod: account?.account.flatMap { details in - if case let .chatgpt(_, plan) = details { plan } else { nil } - }) - guard let state = CodexReconciledState.fromCLI( - primary: Self.makeWindow(from: limits.primary), - secondary: Self.makeWindow(from: limits.secondary), - identity: identity) - else { - throw UsageError.noRateLimitsFound + do { + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + // The app-server answers on a single stdout stream, so keep requests + // serialized to avoid starving one reader when multiple awaiters race + // for the same pipe. + let limits = try await rpc.fetchRateLimits().rateLimits + let account = try? await rpc.fetchAccount() + + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account?.account.flatMap { details in + if case let .chatgpt(email, _) = details { email } else { nil } + }, + accountOrganization: nil, + loginMethod: account?.account.flatMap { details in + if case let .chatgpt(_, plan) = details { plan } else { nil } + }) + guard let state = CodexReconciledState.fromCLI( + primary: Self.makeWindow(from: limits.primary), + secondary: Self.makeWindow(from: limits.secondary), + identity: identity) + else { + throw UsageError.noRateLimitsFound + } + return state.toUsageSnapshot() + } catch { + throw error } - return state.toUsageSnapshot() } private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - let status = try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) - .fetch() - guard let state = CodexReconciledState.fromCLI( - primary: Self.makeTTYWindow( - percentLeft: status.fiveHourPercentLeft, - windowMinutes: 300, - resetsAt: status.fiveHourResetsAt, - resetDescription: status.fiveHourResetDescription), - secondary: Self.makeTTYWindow( - percentLeft: status.weeklyPercentLeft, - windowMinutes: 10080, - resetsAt: status.weeklyResetsAt, - resetDescription: status.weeklyResetDescription), - identity: nil) - else { - throw UsageError.noRateLimitsFound + do { + let status = try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + environment: self.environment) + .fetch() + guard let state = CodexReconciledState.fromCLI( + primary: Self.makeTTYWindow( + percentLeft: status.fiveHourPercentLeft, + windowMinutes: 300, + resetsAt: status.fiveHourResetsAt, + resetDescription: status.fiveHourResetDescription), + secondary: Self.makeTTYWindow( + percentLeft: status.weeklyPercentLeft, + windowMinutes: 10080, + resetsAt: status.weeklyResetsAt, + resetDescription: status.weeklyResetDescription), + identity: nil) + else { + throw UsageError.noRateLimitsFound + } + return state.toUsageSnapshot() + } catch { + throw error } - return state.toUsageSnapshot() } public func loadLatestCredits(keepCLISessionsAlive: Bool = false) async throws -> CreditsSnapshot { @@ -627,20 +634,28 @@ public struct UsageFetcher: Sendable { private func loadRPCCredits() async throws -> CreditsSnapshot { let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - let limits = try await rpc.fetchRateLimits().rateLimits - guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } - let remaining = Self.parseCredits(credits.balance) - return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + do { + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + let limits = try await rpc.fetchRateLimits().rateLimits + guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } + let remaining = Self.parseCredits(credits.balance) + return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + } catch { + throw error + } } private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - let status = try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) - .fetch() - guard let credits = status.credits else { throw UsageError.noRateLimitsFound } - return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) + do { + let status = try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + environment: self.environment) + .fetch() + guard let credits = status.credits else { throw UsageError.noRateLimitsFound } + return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) + } catch { + throw error + } } private func withFallback( diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift index 39757119c..5bde40281 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift @@ -225,6 +225,55 @@ struct CodexManagedOpenAIWebTests { #expect(observedAllowAnyAccount == false) } + @Test + func `successful dashboard apply preserves cached open A I web view for same account`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebTests-preserve-cache-on-success") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + let cache = OpenAIDashboardWebViewCache.shared + cache.clearAllForTesting() + defer { + cache.clearAllForTesting() + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let websiteDataStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: managedAccount.email) + cache.cacheEntryForTesting(websiteDataStore: websiteDataStore) + + #expect(cache.hasCachedEntry(for: websiteDataStore)) + + await store.applyOpenAIDashboard( + OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 90, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 10, + accountPlan: "Pro", + updatedAt: Date()), + targetEmail: managedAccount.email) + + #expect(cache.hasCachedEntry(for: websiteDataStore)) + #expect(cache.entryCount == 1) + } + @Test func `dashboard refresh does not target stale last known live email`() async { let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebTests-live-system-refresh-strict-target") diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift new file mode 100644 index 000000000..d2950ca09 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -0,0 +1,73 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +struct StatusItemAnimationSignatureTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `merged render signature changes when unified icon style changes`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-style-signature"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + 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()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProviders() == [.codex, .synthetic]) + #expect(store.iconStyle == .combined) + controller.applyIcon(phase: nil) + let combinedSignature = controller.lastAppliedMergedIconRenderSignature + + settings.syntheticAPIToken = "" + + #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProviders() == [.codex]) + #expect(store.iconStyle == .codex) + controller.applyIcon(phase: nil) + let codexSignature = controller.lastAppliedMergedIconRenderSignature + + #expect(combinedSignature != nil) + #expect(codexSignature != nil) + #expect(combinedSignature != codexSignature) + #expect(codexSignature?.contains("style=codex") == true) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 13c5e0246..dc098aead 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -42,9 +42,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" if let geminiMeta = registry.metadata[.gemini] { settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) } @@ -88,9 +89,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index b0b676848..c74ecf69c 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -839,6 +839,62 @@ extension StatusMenuTests { #expect(try #require(creditsIndex) < costIndex!) } + @Test + func `hosted cost submenu preserves provider context after empty hydration`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + 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()) + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title == "No data available") + #expect(submenu.items.first?.toolTip == UsageProvider.codex.rawValue) + + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title != "No data available") + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + } + @Test func `shows extra usage for claude when using menu card sections`() { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 49dd6e681..9e9258447 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -203,6 +203,174 @@ struct UsageStoreCoverageTests { #expect(store.statuses[.synthetic]?.indicator == .major) } + @Test + func backgroundWorkExcludesEnabledButUnavailableProviders() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-unavailable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func visibleUnavailableProviderGetsExplicitUserFacingState() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-unavailable-message") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + + #expect(store.errors[.synthetic] == nil) + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.isProviderAvailable(.synthetic) == false) + #expect(store.userFacingError(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) + #expect(store.unavailableMessage(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) + } + + @Test + func refreshClearsEnabledButUnavailableCachedState() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let cachedSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(cachedSnapshot, provider: .synthetic) + let account = ProviderTokenAccount(id: UUID(), label: "Account", token: "token", addedAt: 0, lastUsed: nil) + store.accountSnapshots[.synthetic] = [ + TokenAccountUsageSnapshot(account: account, snapshot: cachedSnapshot, error: nil, sourceLabel: "api"), + ] + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1.23, + last30DaysTokens: 100, + last30DaysCostUSD: 4.56, + daily: [], + updatedAt: Date()), + provider: .synthetic) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + + await store.refresh() + #expect(store.snapshot(for: .synthetic) == nil) + #expect((store.accountSnapshots[.synthetic] ?? []).isEmpty) + #expect(store.tokenSnapshots[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func refreshClearsEnabledButUnavailableFailureState() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-failure-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store._setErrorForTesting("stale", provider: .synthetic) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + store.tokenErrors[.synthetic] = "token stale" + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + + await store.refresh() + + #expect(store.errors[.synthetic] == nil) + #expect(store.tokenErrors[.synthetic] == nil) + #expect(store.statuses[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func unavailableProviderWithOnlyCachedStatusGetsSingleCleanupPass() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + + let metadata = ProviderRegistry.shared.metadata + + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + + await store.refresh() + + #expect(store.statuses[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + @Test func statusIndicatorsAndFailureGate() { #expect(!ProviderStatusIndicator.none.hasIssue)