From 3387cc8b2d47712e7adb06945ed915e9c508b15c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 15:54:09 +0100 Subject: [PATCH 01/93] chore: start 0.32.5 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4502f85..1713be4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.32.5 — Unreleased + ## 0.32.4 — 2026-06-02 ### Fixed diff --git a/version.env b/version.env index ed69b0493..1e9901f05 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.32.4 -BUILD_NUMBER=79 +MARKETING_VERSION=0.32.5 +BUILD_NUMBER=80 From 65e39f4dcb3a0c0f33e93111ebd8fcda97798485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Larry=20Hao=EF=BC=88=E9=83=9D=E5=8D=93=E8=BF=9C=EF=BC=89?= <107194248+hhh2210@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:21:25 +0800 Subject: [PATCH 02/93] Improve merged menu dismiss latency (#1286) Defer merged-menu close rebuilds, cache repeated menu-card height measurements, and coalesce rapid switcher rebuild requests. Co-authored-by: hhh2210 --- CHANGELOG.md | 3 + ...tatusItemController+CodexStackedMenu.swift | 6 +- .../CodexBar/StatusItemController+Menu.swift | 126 ++++-------------- ...usItemController+MenuCardHeightCache.swift | 32 +++++ .../StatusItemController+MenuCardItems.swift | 84 ++++++++++++ ...ItemController+MenuRefreshScheduling.swift | 15 ++- .../StatusItemController+MenuTracking.swift | 19 +++ .../StatusItemController+Shutdown.swift | 2 + ...tatusItemController+UsageHistoryMenu.swift | 1 + ...tusItemController+ZaiHourlyChartMenu.swift | 1 + Sources/CodexBar/StatusItemController.swift | 3 + .../StatusMenuHeightCacheTests.swift | 104 +++++++++++++++ .../StatusMenuOpenRefreshTests.swift | 106 +++++++++++++++ 13 files changed, 398 insertions(+), 104 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift create mode 100644 Sources/CodexBar/StatusItemController+MenuCardItems.swift create mode 100644 Tests/CodexBarTests/StatusMenuHeightCacheTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1713be4ec..340e699ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.5 — Unreleased +### Fixed +- Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286). Thanks @hhh2210! + ## 0.32.4 — 2026-06-02 ### Fixed diff --git a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift index c26f9aa85..019be0963 100644 --- a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift +++ b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift @@ -32,7 +32,8 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(cardIndex)", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: account.id)) cardIndex += 1 if account.id != section.accounts.last?.id { menu.addItem(.separator()) @@ -48,7 +49,8 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue)) } menu.addItem(.separator()) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 225331c5b..9e6dd5572 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -163,10 +163,9 @@ extension StatusItemController { menu === self.fallbackMenu || self.providerMenus.values.contains { $0 === menu } if !isPersistentMenu { - self.menuProviders.removeValue(forKey: key) - self.menuVersions.removeValue(forKey: key) + self.clearTransientMenuTrackingState(key) } else if self.menuNeedsRefresh(menu) { - self.rebuildClosedMenuIfNeeded(menu) + self.handleClosedPersistentMenuNeedingRefresh(menu) } self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.scheduleDeferredMenuInteractionRefreshIfNeeded() @@ -292,7 +291,8 @@ extension StatusItemController { menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext)) + openAIContext: openAIContext, + descriptor: descriptor)) return } @@ -324,7 +324,8 @@ extension StatusItemController { menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext)) + openAIContext: openAIContext, + descriptor: descriptor)) return } @@ -379,6 +380,7 @@ extension StatusItemController { let codexAccountDisplay: CodexAccountMenuDisplay? let tokenAccountDisplay: TokenAccountMenuDisplay? let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor } /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. @@ -409,16 +411,6 @@ extension StatusItemController { width: context.menuWidth) self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay - let descriptor = MenuDescriptor.build( - provider: context.provider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, - includeContextualActions: context.switcherSelection != .overview) - let menuContext = MenuCardContext( currentProvider: context.currentProvider, selectedProvider: context.provider, @@ -427,7 +419,7 @@ extension StatusItemController { tokenAccountDisplay: context.tokenAccountDisplay, openAIContext: context.openAIContext) self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) - self.addActionableSections(descriptor.sections, to: menu, width: context.menuWidth) + self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) } } @@ -557,6 +549,7 @@ extension StatusItemController { OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), id: identifier, width: menuWidth, + heightCacheScope: row.provider.rawValue, submenu: submenu, onClick: { [weak self, weak menu] in guard let self, let menu else { return } @@ -639,7 +632,8 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue)) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { menu.addItem(.separator()) } @@ -659,14 +653,16 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue)) menu.addItem(.separator()) } else { for (index, model) in cards.enumerated() { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(index)", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: "\(context.currentProvider.rawValue)-\(index)")) if index < cards.count - 1 { menu.addItem(.separator()) } @@ -1169,86 +1165,6 @@ extension StatusItemController { return enabledProviders } - private func refreshMenuCardHeights(in menu: NSMenu) { - // Re-measure the menu card height right before display to avoid stale/incorrect sizing when content - // changes (e.g. dashboard error lines causing wrapping). - let cardItems = menu.items.filter { item in - (item.representedObject as? String)?.hasPrefix("menuCard") == true - } - for item in cardItems { - guard let view = item.view else { continue } - let width = self.renderedMenuWidth(for: menu) - let height = self.menuCardHeight(for: view, width: width) - view.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: height)) - } - } - - func makeMenuCardItem( - _ view: some View, - id: String, - width: CGFloat, - submenu: NSMenu? = nil, - submenuIndicatorAlignment: Alignment = .topTrailing, - submenuIndicatorTopPadding: CGFloat = 8, - onClick: (() -> Void)? = nil) -> NSMenuItem - { - if !Self.menuCardRenderingEnabled { - let item = NSMenuItem() - item.isEnabled = true - item.representedObject = id - item.submenu = submenu - if submenu != nil { - item.target = self - item.action = #selector(self.menuCardNoOp(_:)) - } - return item - } - - let highlightState = MenuCardHighlightState() - let wrapped = MenuCardSectionContainerView( - highlightState: highlightState, - showsSubmenuIndicator: submenu != nil, - submenuIndicatorAlignment: submenuIndicatorAlignment, - submenuIndicatorTopPadding: submenuIndicatorTopPadding) - { - view - } - let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) - // Set frame with target width immediately - let height = self.menuCardHeight(for: hosting, width: width) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) - let item = NSMenuItem() - item.view = hosting - item.isEnabled = true - item.representedObject = id - item.submenu = submenu - if submenu != nil { - item.target = self - item.action = #selector(self.menuCardNoOp(_:)) - } - return item - } - - private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { - let basePadding: CGFloat = 6 - let descenderSafety: CGFloat = 1 - - // Fast path: use protocol-based measurement when available (avoids layout passes) - if let measured = view as? MenuCardMeasuring { - return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) - } - - // Set frame with target width before measuring. - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - - // Use fittingSize directly - SwiftUI hosting views respect the frame width for wrapping - let fitted = view.fittingSize - - return max(1, ceil(fitted.height + basePadding + descenderSafety)) - } - private func addMenuCardSections( to menu: NSMenu, model: UsageMenuCardView.Model, @@ -1280,13 +1196,18 @@ extension StatusItemController { usageView, id: "menuCardUsage", width: width, + heightCacheScope: provider.rawValue, submenu: usageSubmenu)) } else { let headerView = UsageMenuCardHeaderSectionView( model: model, showDivider: false, width: width) - menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) + menu.addItem(self.makeMenuCardItem( + headerView, + id: "menuCardHeader", + width: width, + heightCacheScope: provider.rawValue)) } if hasStorage || hasCredits || hasExtraUsage || hasCost { @@ -1314,6 +1235,7 @@ extension StatusItemController { creditsView, id: "menuCardCredits", width: width, + heightCacheScope: provider.rawValue, submenu: creditsSubmenu)) if webItems.canShowBuyCredits { menu.addItem(self.makeBuyCreditsItem()) @@ -1333,6 +1255,7 @@ extension StatusItemController { extraUsageView, id: "menuCardExtraUsage", width: width, + heightCacheScope: provider.rawValue, submenu: extraUsageSubmenu)) } if hasCost { @@ -1358,6 +1281,7 @@ extension StatusItemController { storageView, id: "menuCardStorage", width: width, + heightCacheScope: provider.rawValue, submenu: storageSubmenu)) return true } @@ -1611,7 +1535,7 @@ extension StatusItemController { } } - @objc private func menuCardNoOp(_ sender: NSMenuItem) { + @objc func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } diff --git a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift new file mode 100644 index 000000000..91fd232bd --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift @@ -0,0 +1,32 @@ +import AppKit + +extension StatusItemController { + struct MenuCardHeightCacheKey: Hashable { + let id: String + let scope: String + let width: Int + let version: Int + } + + func cachedMenuCardHeight( + for id: String, + scope: String, + width: CGFloat, + measure: () -> CGFloat) -> CGFloat + { + let key = MenuCardHeightCacheKey( + id: id, + scope: scope, + width: Int((width * 100).rounded()), + version: self.menuContentVersion) + if let cached = self.menuCardHeightCache[key] { + return cached + } + let height = measure() + if self.menuCardHeightCache.count > 256 { + self.menuCardHeightCache.removeAll(keepingCapacity: true) + } + self.menuCardHeightCache[key] = height + return height + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift new file mode 100644 index 000000000..0111b537a --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -0,0 +1,84 @@ +import AppKit +import SwiftUI + +extension StatusItemController { + func refreshMenuCardHeights(in menu: NSMenu) { + let cardItems = menu.items.filter { item in + (item.representedObject as? String)?.hasPrefix("menuCard") == true + } + for item in cardItems { + guard let view = item.view else { continue } + let width = self.renderedMenuWidth(for: menu) + let id = item.representedObject as? String ?? "menuCard" + let scope = self.menuProvider(for: menu)?.rawValue ?? id + let height = self.cachedMenuCardHeight(for: id, scope: scope, width: width) { + self.menuCardHeight(for: view, width: width) + } + view.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: height)) + } + } + + func makeMenuCardItem( + _ view: some View, + id: String, + width: CGFloat, + heightCacheScope: String? = nil, + submenu: NSMenu? = nil, + submenuIndicatorAlignment: Alignment = .topTrailing, + submenuIndicatorTopPadding: CGFloat = 8, + onClick: (() -> Void)? = nil) -> NSMenuItem + { + if !Self.menuCardRenderingEnabled { + let item = NSMenuItem() + item.isEnabled = true + item.representedObject = id + item.submenu = submenu + if submenu != nil { + item.target = self + item.action = #selector(self.menuCardNoOp(_:)) + } + return item + } + + let highlightState = MenuCardHighlightState() + let wrapped = MenuCardSectionContainerView( + highlightState: highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) + let height = self.cachedMenuCardHeight(for: id, scope: heightCacheScope ?? id, width: width) { + self.menuCardHeight(for: hosting, width: width) + } + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) + + let item = NSMenuItem() + item.view = hosting + item.isEnabled = true + item.representedObject = id + item.submenu = submenu + if submenu != nil { + item.target = self + item.action = #selector(self.menuCardNoOp(_:)) + } + return item + } + + private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { + let basePadding: CGFloat = 6 + let descenderSafety: CGFloat = 1 + + if let measured = view as? MenuCardMeasuring { + return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) + } + + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + let fitted = view.fittingSize + return max(1, ceil(fitted.height + basePadding + descenderSafety)) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 4742e30ec..fe739d106 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -3,6 +3,8 @@ import CodexBarCore import QuartzCore extension StatusItemController { + private static let providerSwitcherMenuRebuildDebounceNanoseconds: UInt64 = 45_000_000 + func didMenuAdjunctReadinessChange() -> Bool { let signature = self.menuAdjunctReadinessSignature() defer { self.lastMenuAdjunctReadinessSignature = signature } @@ -101,10 +103,17 @@ extension StatusItemController { func deferSwitcherMenuRebuildIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { self.providerSwitcherUpdateToken &+= 1 let updateToken = self.providerSwitcherUpdateToken + #if DEBUG + let debounceNanoseconds = self._test_providerSwitcherMenuRebuildDebounceNanoseconds ?? ( + self._test_openMenuRebuildObserver == nil ? Self.providerSwitcherMenuRebuildDebounceNanoseconds : 0) + #else + let debounceNanoseconds = Self.providerSwitcherMenuRebuildDebounceNanoseconds + #endif self.scheduleOpenMenuRebuildIfStillVisible( menu, provider: provider, - closeHostedSubviewMenusBeforeRebuild: true) + closeHostedSubviewMenusBeforeRebuild: true, + debounceNanoseconds: debounceNanoseconds) { [weak self] in guard let self else { return false } return self.providerSwitcherUpdateToken == updateToken @@ -115,6 +124,7 @@ extension StatusItemController { _ menu: NSMenu, provider: UsageProvider?, closeHostedSubviewMenusBeforeRebuild: Bool = false, + debounceNanoseconds: UInt64 = 0, beforeRebuild: (@MainActor () -> Bool)? = nil) { let key = ObjectIdentifier(menu) @@ -137,6 +147,9 @@ extension StatusItemController { #else await Task.yield() #endif + if debounceNanoseconds > 0 { + try? await Task.sleep(nanoseconds: debounceNanoseconds) + } guard !Task.isCancelled else { return } guard self.openMenuRebuildTokens[key] == rebuildToken else { return } defer { diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 4922f9cc6..666cb8e08 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -32,6 +32,7 @@ extension StatusItemController { guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 + self.menuCardHeightCache.removeAll(keepingCapacity: true) if !allowStaleContentDuringDataRefresh { self.latestRequiredMenuRebuildVersion = self.menuContentVersion } @@ -52,6 +53,7 @@ extension StatusItemController { guard self.openMenus.isEmpty else { return } guard !self.isMenuDataRefreshInFlight else { return } for menu in self.attachedMenusForClosedPreparation() { + guard !self.closedMenusDeferredUntilNextOpen.contains(ObjectIdentifier(menu)) else { continue } self.rebuildClosedMenuIfNeeded(menu) } } @@ -61,7 +63,24 @@ extension StatusItemController { UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } } + func clearTransientMenuTrackingState(_ key: ObjectIdentifier) { + self.menuProviders.removeValue(forKey: key) + self.menuVersions.removeValue(forKey: key) + self.closedMenusDeferredUntilNextOpen.remove(key) + } + + func handleClosedPersistentMenuNeedingRefresh(_ menu: NSMenu) { + if menu === self.mergedMenu { + // Closing the merged menu is on the user's dismiss path. Leave stale content attached and let + // menuWillOpen rebuild it, while other closed-menu invalidations can still prepare in the background. + self.closedMenusDeferredUntilNextOpen.insert(ObjectIdentifier(menu)) + } else { + self.rebuildClosedMenuIfNeeded(menu) + } + } + func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { + self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu)) guard self.menuNeedsRefresh(menu) else { return } if self.canPreserveStaleMenuContentDuringRefresh(menu) { #if DEBUG diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 0277aca62..15a9c6801 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -59,12 +59,14 @@ extension StatusItemController { self.menuRefreshTasks.removeAll(keepingCapacity: false) self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) + self.closedMenusDeferredUntilNextOpen.removeAll(keepingCapacity: false) self.openMenuRebuildTasks.removeAll(keepingCapacity: false) self.openMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) self.parentMenuRebuildsDeferredDuringTracking.removeAll(keepingCapacity: false) self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) + self.menuCardHeightCache.removeAll(keepingCapacity: false) self.menuProviders.removeAll(keepingCapacity: false) self.menuVersions.removeAll(keepingCapacity: false) self.providerMenus.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 2e6639216..8f295684d 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -24,6 +24,7 @@ extension StatusItemController { }, id: "usageHistorySubmenu", width: width, + heightCacheScope: provider.rawValue, submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift index 3751e1c61..4ccb1a081 100644 --- a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -24,6 +24,7 @@ extension StatusItemController { }, id: "zaiHourlyUsageSubmenu", width: width, + heightCacheScope: provider.rawValue, submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index cbe0ec801..fa60ca95a 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -117,6 +117,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuContentVersion: Int = 0 var latestRequiredMenuRebuildVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] + var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] @@ -126,6 +127,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var closedMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var closedMenuRebuildTokenCounter = 0 + var closedMenusDeferredUntilNextOpen: Set = [] var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 @@ -147,6 +149,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastLoggedClosedMenuRebuildVersion: Int? var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? + var _test_providerSwitcherMenuRebuildDebounceNanoseconds: UInt64? var _test_codexAmbientLoginRunnerOverride: (@MainActor (TimeInterval) async -> CodexLoginRunner.Result)? #endif diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift new file mode 100644 index 000000000..464af5616 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -0,0 +1,104 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `menu card height cache is reused within one content version`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstKeys = Set(controller.menuCardHeightCache.keys) + + #expect(!firstKeys.isEmpty) + + controller.populateMenu(menu, provider: .codex) + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + + controller.invalidateMenus() + #expect(controller.menuCardHeightCache.isEmpty) + } + + @Test + func `menu card height cache scopes same row ids by provider`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let fetcher = UsageFetcher() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Claude Pro")), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.populateMenu(menu, provider: .claude) + + let scopes = Set(controller.menuCardHeightCache.keys.map(\.scope)) + #expect(scopes.contains(UsageProvider.codex.rawValue)) + #expect(scopes.contains(UsageProvider.claude.rawValue)) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 3d622dd3d..bec5681a7 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -345,6 +345,50 @@ extension StatusMenuTests { #expect(controller.closedMenuRebuildTokens[key] == nil) } + @Test + func `merged menu close defers stale rebuild until next open`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + controller.invalidateMenus(refreshOpenMenus: false) + #expect(controller.menuNeedsRefresh(menu)) + + controller.menuDidClose(menu) + await self.waitUntilClosedMenuRebuildRemainsDeferred(controller, key: key, openedVersion: openedVersion) + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + @Test func `menu open keeps stale nonempty content while store refresh is active`() { self.disableMenuCardsForTesting() @@ -654,6 +698,55 @@ extension StatusMenuTests { #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) } + @Test + func `rapid switcher rebuild requests coalesce before populating open menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + controller.menuRefreshEnabledOverrideForTesting = true + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 50_000_000 + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + try? await Task.sleep(nanoseconds: 10_000_000) + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + + try? await Task.sleep(nanoseconds: 25_000_000) + #expect(rebuildCount == 0) + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(nanoseconds: 5_000_000) + } + + #expect(rebuildCount == 1) + try? await Task.sleep(nanoseconds: 75_000_000) + #expect(rebuildCount == 1) + } + @Test func `codex parent menu open defers stale OpenAI web refresh until tracking ends`() async { self.disableMenuCardsForTesting() @@ -1389,6 +1482,19 @@ extension StatusMenuTests { #expect(controller.menuVersions[key] == controller.menuContentVersion) } + private func waitUntilClosedMenuRebuildRemainsDeferred( + _ controller: StatusItemController, + key: ObjectIdentifier, + openedVersion: Int?) async + { + for _ in 0..<40 + where controller.closedMenuRebuildTasks[key] != nil || + controller.menuVersions[key] != openedVersion + { + await Task.yield() + } + } + private func makeOpenAIDashboard( dailyBreakdown: [OpenAIDashboardDailyBreakdown], updatedAt: Date) -> OpenAIDashboardSnapshot From de55f4850b8d8832aa56ce57079ef74abb11ce35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Larry=20Hao=EF=BC=88=E9=83=9D=E5=8D=93=E8=BF=9C=EF=BC=89?= <107194248+hhh2210@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:20:05 +0800 Subject: [PATCH 03/93] Reduce merged icon observation churn (#1297) * Reduce icon observation churn * Remove residual reflection from icon observation signature A main-thread sample of the packaged build (Merge Icons on, macOS 26.5) showed providerStoreIconObservationSignature still bottoming out in String(describing:) -> _adHocPrint_unlocked reflection, from two String(describing:) calls over the payload-free IconStyle enum. Make IconStyle String-raw-represented (rawValue == case name, so the signature string is byte-identical) and replace both String(describing:) calls with .rawValue. The icon-observation leaf is now reflection-free; a re-sample shows providerStoreIconObservationSignature and _adHocPrint_unlocked gone from the path entirely. Co-Authored-By: Claude Opus 4.8 * Derive icon credits fallback from the Codex projection, not a hand-rolled predicate menuBarCreditsRemainingForIcon (used by both the rendered menu-bar icon and the icon observation signature) reimplemented the menu-bar credits fallback with its own rate-window predicate over snapshot.primary/secondary. That is a second source of truth for a decision the Codex projection already owns (codexConsumerProjection -> menuBarFallback == .creditsBalance): equivalent today, but free to drift from the rendered/menu fallback semantics as the projection evolves. Delegate to store.codexMenuBarCreditsRemaining instead, so render, signature, and the menu-bar fallback all read one projection. The projection is pure value composition over already-loaded snapshot/credits state (no IO), so the icon/signature path stays cheap. Behavior is unchanged in the covered cases (8 icon observation signature tests still pass). Co-Authored-By: Claude Opus 4.8 * Stabilize flaky switcher coalesce test timing * docs: add changelog for icon observation fix --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../StatusItemController+Animation.swift | 46 +++--- ...StatusItemController+IconObservation.swift | 70 +++++++++ Sources/CodexBar/StatusItemController.swift | 45 ------ .../CodexBarCore/Providers/Providers.swift | 2 +- ...tusItemIconObservationSignatureTests.swift | 146 +++++++++++++++++- .../StatusMenuOpenRefreshTests.swift | 5 +- 7 files changed, 235 insertions(+), 80 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+IconObservation.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 340e699ca..c47b38be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286). Thanks @hhh2210! +- Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! ## 0.32.4 — 2026-06-02 diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index a2181b83c..54175bb94 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -232,7 +232,7 @@ extension StatusItemController { } @discardableResult - func applyIcon(phase: Double?) -> Bool { // swiftlint:disable:this function_body_length + func applyIcon(phase: Double?) -> Bool { guard let button = self.statusItem.button else { return false } let style = self.store.iconStyle @@ -269,17 +269,7 @@ extension StatusItemController { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = Self.loadingPercentEpsilon } - let codexProjection = self.store.codexConsumerProjectionIfNeeded( - for: primaryProvider, - surface: .menuBar, - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - var credits: Double? = - codexProjection?.menuBarFallback == .creditsBalance - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil + var credits = self.menuBarCreditsRemainingForIcon(provider: primaryProvider, snapshot: snapshot) var stale = self.store.isStale(provider: primaryProvider) var morphProgress: Double? @@ -486,17 +476,7 @@ extension StatusItemController { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = Self.loadingPercentEpsilon } - let codexProjection = self.store.codexConsumerProjectionIfNeeded( - for: provider, - surface: .menuBar, - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - var credits: Double? = - codexProjection?.menuBarFallback == .creditsBalance - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil + var credits = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot) var stale = self.store.isStale(provider: provider) var morphProgress: Double? @@ -587,11 +567,25 @@ extension StatusItemController { return false } - private static func iconSignatureValue(_ value: Double?) -> String { + static func iconSignatureValue(_ value: Double?) -> String { guard let value else { return "nil" } return String(format: "%.3f", value) } + func menuBarCreditsRemainingForIcon(provider: UsageProvider, snapshot: UsageSnapshot?) -> Double? { + // Derive the menu-bar credits fallback from the same Codex projection path the rendered + // icon and menu use (`codexConsumerProjection` -> `menuBarFallback`), instead of a + // hand-rolled rate-window predicate. The projection is pure value composition over + // already-loaded snapshot/credits state (no IO), so this stays cheap while keeping the + // icon render, this signature input, and the menu-bar fallback semantics on a single + // source of truth — a hand-rolled approximation can silently drift from the projection + // as its fallback logic evolves. + guard provider == .codex else { return nil } + return self.store.codexMenuBarCreditsRemaining( + snapshotOverride: snapshot, + now: snapshot?.updatedAt ?? Date()) + } + func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool { guard let until = self.quotaWarningFlashUntil[provider] else { return false } if until > now { return true } @@ -894,7 +888,7 @@ extension StatusItemController { self.menuBarMetricWindow(for: provider, snapshot: snapshot) } - private func primaryProviderForUnifiedIcon() -> UsageProvider { + func primaryProviderForUnifiedIcon() -> UsageProvider { // When "show highest usage" is enabled, auto-select the provider closest to rate limit. if self.settings.menuBarShowsHighestUsage, self.shouldMergeIcons, @@ -966,7 +960,7 @@ extension StatusItemController { self.tickBlink(now: now) } - private func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool { + func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool { if self.store.debugForceAnimation { return true } let isMerged = mergeIcons ?? self.shouldMergeIcons diff --git a/Sources/CodexBar/StatusItemController+IconObservation.swift b/Sources/CodexBar/StatusItemController+IconObservation.swift new file mode 100644 index 000000000..a205bbc84 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+IconObservation.swift @@ -0,0 +1,70 @@ +import CodexBarCore +import Foundation + +extension StatusItemController { + func storeIconObservationSignature() -> String { + let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent + let mergeIcons = self.shouldMergeIcons + let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") + let providerSignatures: String + let primaryProvider: UsageProvider? + if mergeIcons { + let primary = self.primaryProviderForUnifiedIcon() + primaryProvider = primary + providerSignatures = [ + self.providerStoreIconObservationSignature(for: primary, showBrandPercent: showBrandPercent), + "mergedStatus=\(self.mergedIconStatusIndicator().rawValue)", + ].joined(separator: "||") + } else { + primaryProvider = nil + providerSignatures = UsageProvider.allCases + .filter { self.isVisible($0) } + .map { self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) } + .joined(separator: "||") + } + return [ + "merge=\(mergeIcons ? "1" : "0")", + "visible=\(visibleProviders)", + "primary=\(primaryProvider?.rawValue ?? "nil")", + "iconStyle=\(self.store.iconStyle.rawValue)", + "showUsed=\(self.settings.usageBarsShowUsed ? "1" : "0")", + "brandPercent=\(showBrandPercent ? "1" : "0")", + "needsAnimation=\(self.needsMenuBarIconAnimation() ? "1" : "0")", + providerSignatures, + ].joined(separator: "|") + } + + private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { + let snapshot = self.store.snapshot(for: provider) + let style = self.store.style(for: provider) + let resolved = snapshot.map { + IconRemainingResolver.resolvedPercents( + snapshot: $0, + style: style, + showUsed: self.settings.usageBarsShowUsed) + } + let creditsRemaining = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot) + let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil + + return [ + provider.rawValue, + "style=\(style.rawValue)", + "primary=\(Self.iconSignatureValue(resolved?.primary))", + "weekly=\(Self.iconSignatureValue(resolved?.secondary))", + "credits=\(Self.iconSignatureValue(creditsRemaining))", + "stale=\(self.store.isStale(provider: provider) ? "1" : "0")", + "status=\(self.store.statusIndicator(for: provider).rawValue)", + "anim=\(self.shouldAnimate(provider: provider) ? "1" : "0")", + "refreshing=\(self.store.refreshingProviders.contains(provider) ? "1" : "0")", + "text=\(displayText ?? "nil")", + ].joined(separator: "|") + } + + private func mergedIconStatusIndicator() -> ProviderStatusIndicator { + for provider in self.store.enabledProvidersForDisplay() { + let indicator = self.store.statusIndicator(for: provider) + if indicator.hasIssue { return indicator } + } + return .none + } +} diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index fa60ca95a..747a3dd48 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -467,51 +467,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - func storeIconObservationSignature() -> String { - let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent - let mergeIcons = self.shouldMergeIcons - let needsAnimation = self.needsMenuBarIconAnimation() - let providerSignatures = UsageProvider.allCases.map { - self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) - }.joined(separator: "||") - let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") - return [ - "merge=\(mergeIcons ? "1" : "0")", - "visible=\(visibleProviders)", - "iconStyle=\(String(describing: self.store.iconStyle))", - "brandPercent=\(showBrandPercent ? "1" : "0")", - "needsAnimation=\(needsAnimation ? "1" : "0")", - providerSignatures, - ].joined(separator: "|") - } - - private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { - let snapshot = self.store.snapshot(for: provider) - let stale = self.store.isStale(provider: provider) - let status = self.store.statusIndicator(for: provider).rawValue - let isVisibleForAnimation = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) - let isAnimating = isVisibleForAnimation && !stale && snapshot == nil - let isRefreshingWarpPlaceholder = self.store.refreshingProviders.contains(provider) - let creditsRemaining = provider == .codex - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil - let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil - - return [ - provider.rawValue, - "style=\(String(describing: self.store.style(for: provider)))", - "snapshot=\(String(describing: snapshot))", - "stale=\(stale ? "1" : "0")", - "status=\(status)", - "anim=\(isAnimating ? "1" : "0")", - "refreshing=\(isRefreshingWarpPlaceholder ? "1" : "0")", - "credits=\(String(describing: creditsRemaining))", - "text=\(displayText ?? "nil")", - ].joined(separator: "|") - } - private func observeDebugForceAnimation() { withObservationTracking { _ = self.store.debugForceAnimation diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index e71f36370..0727067ab 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -55,7 +55,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { // swiftformat:enable sortDeclarations -public enum IconStyle: Sendable, CaseIterable { +public enum IconStyle: String, Sendable, CaseIterable { case codex case openai case claude diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift index 6660c264e..5d2968a9e 100644 --- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -20,6 +20,9 @@ struct StatusItemIconObservationSignatureTests { 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: false) + } let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -55,6 +58,126 @@ struct StatusItemIconObservationSignatureTests { #expect(controller.storeIconObservationSignature() == baseline) } + @Test + func `store icon observation signature ignores non visual snapshot churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-snapshot-metadata") + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "rotated-account@example.com", + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + let signature = controller.storeIconObservationSignature() + + #expect(signature == baseline) + #expect(!signature.contains("rotated-account@example.com")) + } + + @Test + func `merged store icon observation signature ignores non primary snapshot churn`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-snapshot") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + store._setSnapshotForTesting( + Self.makeSnapshot(provider: .claude, email: "claude@example.com"), + provider: .claude) + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .claude, + email: "changed@example.com", + primaryUsedPercent: 99, + secondaryUsedPercent: 88, + updatedAt: Date(timeIntervalSince1970: 300)), + provider: .claude) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when icon percentages change`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-percent-change") + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "icon@example.com", + primaryUsedPercent: 42, + secondaryUsedPercent: 63), + provider: .codex) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature changes when credit fallback changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-credit-fallback") + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "icon@example.com", + primaryUsedPercent: 100, + secondaryUsedPercent: 20), + provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100)) + let baseline = controller.storeIconObservationSignature() + + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature ignores unused credit balance`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-unused-credits") + defer { controller.releaseStatusItemsForTesting() } + + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100)) + let baseline = controller.storeIconObservationSignature() + + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `merged store icon observation signature changes when non primary status changes`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-status") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.claude] = ProviderStatus( + indicator: .major, + description: "Claude status issue", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + @Test func `store icon observation signature changes when status indicator changes`() { let (_, store, controller) = self.makeController( @@ -75,11 +198,26 @@ struct StatusItemIconObservationSignatureTests { #expect(controller.storeIconObservationSignature() != baseline) } - private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + private static func makeSnapshot( + provider: UsageProvider, + email: String, + primaryUsedPercent: Double = 10, + secondaryUsedPercent: Double = 20, + updatedAt: Date = Date(timeIntervalSince1970: 100)) + -> UsageSnapshot + { UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 100), + primary: RateWindow( + usedPercent: primaryUsedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: secondaryUsedPercent, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: updatedAt, identity: ProviderIdentitySnapshot( providerID: provider, accountEmail: email, diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index bec5681a7..7a67bf8fc 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -734,10 +734,7 @@ extension StatusMenuTests { try? await Task.sleep(nanoseconds: 10_000_000) controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) - try? await Task.sleep(nanoseconds: 25_000_000) - #expect(rebuildCount == 0) - - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<40 where rebuildCount == 0 { await Task.yield() try? await Task.sleep(nanoseconds: 5_000_000) } From b2d5129a6fc3ae3d61d725079a3d2ebf48693936 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 12:22:50 -0700 Subject: [PATCH 04/93] fix: skip unchanged quota indicator constraints --- CHANGELOG.md | 1 + .../StatusItemController+SwitcherViews.swift | 19 +++++--- .../StatusMenuSwitcherRefreshTests.swift | 44 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c47b38be2..741bb1c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286). Thanks @hhh2210! - Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! +- Menu bar: keep provider-switcher quota bars from replacing Auto Layout constraints when the visible ratio is unchanged, making tab switches responsive with many providers enabled (#1303, #1315). Thanks @juanjoseluisgarcia! ## 0.32.4 — 2026-06-02 diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index a7fe4c684..f0c006e03 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -613,11 +613,14 @@ final class ProviderSwitcherView: NSView { let key = ObjectIdentifier(button) if let remaining { if var indicator = self.quotaIndicators[key] { - Self.updateQuotaIndicatorFill( - indicator: &indicator, - remainingPercent: remaining, - selection: segment.selection) - self.quotaIndicators[key] = indicator + let newRatio = Self.quotaIndicatorRatio(remainingPercent: remaining) + if newRatio != indicator.fillRatio { + Self.updateQuotaIndicatorFill( + indicator: &indicator, + remainingPercent: remaining, + selection: segment.selection) + self.quotaIndicators[key] = indicator + } } else { self.addQuotaIndicator(to: button, selection: segment.selection, remainingPercent: remaining) } @@ -691,6 +694,12 @@ final class ProviderSwitcherView: NSView { self.quotaIndicators[ObjectIdentifier(button)]?.fill.frame } } + + func _test_quotaIndicatorConstraintIdentifiers() -> [ObjectIdentifier] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)].map { ObjectIdentifier($0.fillWidthConstraint) } + } + } #endif private func isLightMode() -> Bool { diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 4d71f58e1..bd2da02ec 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -73,6 +73,50 @@ struct StatusMenuSwitcherRefreshTests { #expect(Self.switcherButtons(in: menu).first { $0.tag == nextProviderButton.tag }?.state == .on) } + @Test + func `tab switch does not replace quota indicator constraints`() { + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in 75.0 }, + onSelect: { _ in }) + + let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(initialConstraints.count == 2, "both providers should have quota indicators") + + switcher.updateQuotaIndicators() + + let afterFirstCall = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(afterFirstCall == initialConstraints, "same ratio: constraints must not be replaced") + } + + @Test + func `quota indicator constraints are replaced when ratio changes`() { + var currentRemaining = 75.0 + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in currentRemaining }, + onSelect: { _ in }) + + let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(initialConstraints.count == 2) + + currentRemaining = 40.0 + switcher.updateQuotaIndicators() + + let afterDataChange = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(afterDataChange != initialConstraints, "changed ratio: constraints should be replaced") + } + private static func makeSettings() -> SettingsStore { let suite = "StatusMenuSwitcherRefreshTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! From 7c083fab0c082c9187b5b56b31a4be08aa2718a3 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Fri, 5 Jun 2026 12:21:40 +0800 Subject: [PATCH 05/93] Cache menu card heights by content --- .../CodexBar/MenuCardHeightFingerprint.swift | 126 ++++++++++++++++++ ...tatusItemController+CodexStackedMenu.swift | 6 +- .../CodexBar/StatusItemController+Menu.swift | 19 ++- ...usItemController+MenuCardHeightCache.swift | 5 +- .../StatusItemController+MenuCardItems.swift | 8 +- .../StatusItemController+MenuTracking.swift | 1 - ...tatusItemController+UsageHistoryMenu.swift | 1 + ...tusItemController+ZaiHourlyChartMenu.swift | 1 + .../StatusMenuHeightCacheTests.swift | 113 +++++++++++++++- 9 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 Sources/CodexBar/MenuCardHeightFingerprint.swift diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift new file mode 100644 index 000000000..172be0cac --- /dev/null +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -0,0 +1,126 @@ +extension UsageMenuCardView.Model { + func heightFingerprint(section: String, additional: [String] = []) -> String { + MenuCardHeightFingerprint.join([ + "section=\(section)", + "provider=\(self.provider.rawValue)", + "name=\(self.providerName)", + "email=\(self.email)", + "subtitle=\(self.subtitleText)", + "subtitleStyle=\(self.subtitleStyle.heightFingerprint)", + "plan=\(self.planText ?? "")", + "placeholder=\(self.placeholder ?? "")", + "credits=\(self.creditsText ?? "")", + "creditsHint=\(self.creditsHintText ?? "")", + "creditsCopy=\(self.creditsHintCopyText ?? "")", + "metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))", + "notes=\(MenuCardHeightFingerprint.join(self.usageNotes))", + "dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")", + "providerCost=\(self.providerCost?.heightFingerprint ?? "")", + "tokenUsage=\(self.tokenUsage?.heightFingerprint ?? "")", + "openaiAPI=\(self.openAIAPIUsage == nil ? "0" : "1")", + ] + additional) + } +} + +private enum MenuCardHeightFingerprint { + static func join(_ values: [String]) -> String { + values.map { "\($0.count):\($0)" }.joined(separator: "|") + } +} + +extension UsageMenuCardView.Model.SubtitleStyle { + fileprivate var heightFingerprint: String { + switch self { + case .info: "info" + case .loading: "loading" + case .error: "error" + } + } +} + +extension UsageMenuCardView.Model.Metric { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.id, + self.title, + self.percentLabel, + self.statusText ?? "", + self.resetText ?? "", + self.detailText ?? "", + self.detailLeftText ?? "", + self.detailRightText ?? "", + self.pacePercent == nil ? "pace=0" : "pace=1", + self.paceOnTop ? "paceTop=1" : "paceTop=0", + self.cardStyle ? "card=1" : "card=0", + "markers=\(self.warningMarkerPercents.count)", + ]) + } +} + +extension UsageMenuCardView.Model.ProviderCostSection { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.title, + self.spendLine, + self.percentLine ?? "", + self.percentUsed == nil ? "percent=0" : "percent=1", + ]) + } +} + +extension UsageMenuCardView.Model.TokenUsageSection { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.sessionLine, + self.monthLine, + self.hintLine ?? "", + self.errorLine ?? "", + self.errorCopyText ?? "", + ]) + } +} + +extension InlineUsageDashboardModel { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.accessibilityLabel, + self.valueStyle.heightFingerprint, + MenuCardHeightFingerprint.join(self.kpis.map(\.heightFingerprint)), + MenuCardHeightFingerprint.join(self.points.map(\.heightFingerprint)), + MenuCardHeightFingerprint.join(self.detailLines), + ]) + } +} + +extension InlineUsageDashboardModel.KPI { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.title, + self.value, + self.emphasis ? "1" : "0", + ]) + } +} + +extension InlineUsageDashboardModel.Point { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.id, + self.label, + self.accessibilityValue, + ]) + } +} + +extension InlineUsageDashboardModel.ValueStyle { + fileprivate var heightFingerprint: String { + switch self { + case .currencyUSD: + "currencyUSD" + case let .currency(symbol): + "currency:\(symbol)" + case .tokens: + "tokens" + } + } +} diff --git a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift index 019be0963..0b3a64c29 100644 --- a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift +++ b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift @@ -33,7 +33,8 @@ extension StatusItemController { UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(cardIndex)", width: context.menuWidth, - heightCacheScope: account.id)) + heightCacheScope: account.id, + heightCacheFingerprint: model.heightFingerprint(section: "card"))) cardIndex += 1 if account.id != section.accounts.last?.id { menu.addItem(.separator()) @@ -50,7 +51,8 @@ extension StatusItemController { UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", width: context.menuWidth, - heightCacheScope: context.currentProvider.rawValue)) + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "card"))) } menu.addItem(.separator()) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 9e6dd5572..74d4db0f3 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -550,6 +550,9 @@ extension StatusItemController { id: identifier, width: menuWidth, heightCacheScope: row.provider.rawValue, + heightCacheFingerprint: row.model.heightFingerprint( + section: "overview", + additional: ["storage=\(storageText ?? "")"]), submenu: submenu, onClick: { [weak self, weak menu] in guard let self, let menu else { return } @@ -633,7 +636,8 @@ extension StatusItemController { UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", width: context.menuWidth, - heightCacheScope: context.currentProvider.rawValue)) + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "card"))) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { menu.addItem(.separator()) } @@ -654,7 +658,8 @@ extension StatusItemController { UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", width: context.menuWidth, - heightCacheScope: context.currentProvider.rawValue)) + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "card"))) menu.addItem(.separator()) } else { for (index, model) in cards.enumerated() { @@ -662,7 +667,8 @@ extension StatusItemController { UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(index)", width: context.menuWidth, - heightCacheScope: "\(context.currentProvider.rawValue)-\(index)")) + heightCacheScope: "\(context.currentProvider.rawValue)-\(index)", + heightCacheFingerprint: model.heightFingerprint(section: "card"))) if index < cards.count - 1 { menu.addItem(.separator()) } @@ -1197,6 +1203,7 @@ extension StatusItemController { id: "menuCardUsage", width: width, heightCacheScope: provider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "usage"), submenu: usageSubmenu)) } else { let headerView = UsageMenuCardHeaderSectionView( @@ -1207,7 +1214,8 @@ extension StatusItemController { headerView, id: "menuCardHeader", width: width, - heightCacheScope: provider.rawValue)) + heightCacheScope: provider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "header"))) } if hasStorage || hasCredits || hasExtraUsage || hasCost { @@ -1236,6 +1244,7 @@ extension StatusItemController { id: "menuCardCredits", width: width, heightCacheScope: provider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "credits"), submenu: creditsSubmenu)) if webItems.canShowBuyCredits { menu.addItem(self.makeBuyCreditsItem()) @@ -1256,6 +1265,7 @@ extension StatusItemController { id: "menuCardExtraUsage", width: width, heightCacheScope: provider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "extraUsage"), submenu: extraUsageSubmenu)) } if hasCost { @@ -1282,6 +1292,7 @@ extension StatusItemController { id: "menuCardStorage", width: width, heightCacheScope: provider.rawValue, + heightCacheFingerprint: "storage=\(storageText)", submenu: storageSubmenu)) return true } diff --git a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift index 91fd232bd..e5669f7c6 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift @@ -5,20 +5,21 @@ extension StatusItemController { let id: String let scope: String let width: Int - let version: Int + let fingerprint: String } func cachedMenuCardHeight( for id: String, scope: String, width: CGFloat, + fingerprint: String? = nil, measure: () -> CGFloat) -> CGFloat { let key = MenuCardHeightCacheKey( id: id, scope: scope, width: Int((width * 100).rounded()), - version: self.menuContentVersion) + fingerprint: fingerprint ?? "version:\(self.menuContentVersion)") if let cached = self.menuCardHeightCache[key] { return cached } diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index 0111b537a..768b0fcf8 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -25,6 +25,7 @@ extension StatusItemController { id: String, width: CGFloat, heightCacheScope: String? = nil, + heightCacheFingerprint: String? = nil, submenu: NSMenu? = nil, submenuIndicatorAlignment: Alignment = .topTrailing, submenuIndicatorTopPadding: CGFloat = 8, @@ -52,7 +53,12 @@ extension StatusItemController { view } let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) - let height = self.cachedMenuCardHeight(for: id, scope: heightCacheScope ?? id, width: width) { + let height = self.cachedMenuCardHeight( + for: id, + scope: heightCacheScope ?? id, + width: width, + fingerprint: heightCacheFingerprint) + { self.menuCardHeight(for: hosting, width: width) } hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 666cb8e08..18901fd23 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -32,7 +32,6 @@ extension StatusItemController { guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 - self.menuCardHeightCache.removeAll(keepingCapacity: true) if !allowStaleContentDuringDataRefresh { self.latestRequiredMenuRebuildVersion = self.menuContentVersion } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 8f295684d..6b5dbc1f7 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -25,6 +25,7 @@ extension StatusItemController { id: "usageHistorySubmenu", width: width, heightCacheScope: provider.rawValue, + heightCacheFingerprint: "usageHistorySubmenu:\(provider.rawValue)", submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift index 4ccb1a081..712fb3137 100644 --- a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -25,6 +25,7 @@ extension StatusItemController { id: "zaiHourlyUsageSubmenu", width: width, heightCacheScope: provider.rawValue, + heightCacheFingerprint: "zaiHourlyUsageSubmenu:\(provider.rawValue)", submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift index 464af5616..d990a681f 100644 --- a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -5,7 +5,7 @@ import Testing extension StatusMenuTests { @Test - func `menu card height cache is reused within one content version`() { + func `menu card height cache is reused for stable card content`() { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = true defer { @@ -42,7 +42,101 @@ extension StatusMenuTests { #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) controller.invalidateMenus() - #expect(controller.menuCardHeightCache.isEmpty) + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + } + + @Test + func `fingerprinted menu card height cache survives content version invalidation`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + measureCount += 1 + return 42 + } + + controller.invalidateMenus() + + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 42) + #expect(measureCount == 1) + } + + @Test + func `fingerprinted menu card height cache remeasures when content changes`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:a") + { + measureCount += 1 + return 42 + } + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:b") + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 99) + #expect(measureCount == 2) + } + + @Test + func `unfingerprinted menu card height cache remains content version scoped`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320) + { + measureCount += 1 + return 42 + } + + controller.invalidateMenus() + + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320) + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 99) + #expect(measureCount == 2) } @Test @@ -101,4 +195,19 @@ extension StatusMenuTests { #expect(scopes.contains(UsageProvider.codex.rawValue)) #expect(scopes.contains(UsageProvider.claude.rawValue)) } + + private func makeHeightCacheController() -> StatusItemController { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } } From 10239cc617cff8c33aa943097fa02baef455ccc1 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Fri, 5 Jun 2026 12:41:34 +0800 Subject: [PATCH 06/93] Harden menu height fingerprints --- .../CodexBar/MenuCardHeightFingerprint.swift | 92 ++++++++++++------- .../CodexBar/StatusItemController+Menu.swift | 4 +- ...usItemController+MenuCardHeightCache.swift | 9 ++ .../StatusItemController+MenuTracking.swift | 1 + .../MenuCardHeightFingerprintTests.swift | 56 +++++++++++ .../StatusMenuHeightCacheTests.swift | 34 ++++++- 6 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 172be0cac..cec642f46 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -1,31 +1,61 @@ +import CryptoKit +import Foundation + extension UsageMenuCardView.Model { func heightFingerprint(section: String, additional: [String] = []) -> String { - MenuCardHeightFingerprint.join([ + let notesFingerprint = MenuCardHeightFingerprint.join(self.usageNotes.map { + MenuCardHeightFingerprint.field("note", $0) + }) + return MenuCardHeightFingerprint.join([ "section=\(section)", "provider=\(self.provider.rawValue)", - "name=\(self.providerName)", - "email=\(self.email)", - "subtitle=\(self.subtitleText)", + MenuCardHeightFingerprint.field("name", self.providerName), + MenuCardHeightFingerprint.field("email", self.email), + MenuCardHeightFingerprint.field("subtitle", self.subtitleText), "subtitleStyle=\(self.subtitleStyle.heightFingerprint)", - "plan=\(self.planText ?? "")", - "placeholder=\(self.placeholder ?? "")", - "credits=\(self.creditsText ?? "")", - "creditsHint=\(self.creditsHintText ?? "")", - "creditsCopy=\(self.creditsHintCopyText ?? "")", + MenuCardHeightFingerprint.field("plan", self.planText), + MenuCardHeightFingerprint.field("placeholder", self.placeholder), + MenuCardHeightFingerprint.field("credits", self.creditsText), + "creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")", + MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText), + MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText), "metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))", - "notes=\(MenuCardHeightFingerprint.join(self.usageNotes))", + "notes=\(notesFingerprint)", "dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")", "providerCost=\(self.providerCost?.heightFingerprint ?? "")", "tokenUsage=\(self.tokenUsage?.heightFingerprint ?? "")", "openaiAPI=\(self.openAIAPIUsage == nil ? "0" : "1")", ] + additional) } + + static func heightFingerprintField(_ name: String, _ value: String?) -> String { + MenuCardHeightFingerprint.field(name, value) + } } private enum MenuCardHeightFingerprint { + private static let digestSalt = UUID().uuidString + static func join(_ values: [String]) -> String { values.map { "\($0.count):\($0)" }.joined(separator: "|") } + + static func field(_ name: String, _ value: String?) -> String { + guard let value else { + return "\(name)=nil" + } + return "\(name)=\(Self.stringShape(value))" + } + + private static func stringShape(_ value: String) -> String { + let digestInput = "\(Self.digestSalt)\u{0}\(value)" + let digest = SHA256.hash(data: Data(digestInput.utf8)) + .prefix(12) + .map { String(format: "%02x", $0) } + .joined() + let lineCount = value.split(separator: "\n", omittingEmptySubsequences: false).count + return "chars:\(value.count),utf8:\(value.utf8.count),lines:\(lineCount),sha:\(digest)" + } } extension UsageMenuCardView.Model.SubtitleStyle { @@ -42,13 +72,13 @@ extension UsageMenuCardView.Model.Metric { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ self.id, - self.title, - self.percentLabel, - self.statusText ?? "", - self.resetText ?? "", - self.detailText ?? "", - self.detailLeftText ?? "", - self.detailRightText ?? "", + MenuCardHeightFingerprint.field("title", self.title), + MenuCardHeightFingerprint.field("percent", self.percentLabel), + MenuCardHeightFingerprint.field("status", self.statusText), + MenuCardHeightFingerprint.field("reset", self.resetText), + MenuCardHeightFingerprint.field("detail", self.detailText), + MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText), + MenuCardHeightFingerprint.field("detailRight", self.detailRightText), self.pacePercent == nil ? "pace=0" : "pace=1", self.paceOnTop ? "paceTop=1" : "paceTop=0", self.cardStyle ? "card=1" : "card=0", @@ -60,9 +90,9 @@ extension UsageMenuCardView.Model.Metric { extension UsageMenuCardView.Model.ProviderCostSection { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ - self.title, - self.spendLine, - self.percentLine ?? "", + MenuCardHeightFingerprint.field("title", self.title), + MenuCardHeightFingerprint.field("spend", self.spendLine), + MenuCardHeightFingerprint.field("percentLine", self.percentLine), self.percentUsed == nil ? "percent=0" : "percent=1", ]) } @@ -71,11 +101,11 @@ extension UsageMenuCardView.Model.ProviderCostSection { extension UsageMenuCardView.Model.TokenUsageSection { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ - self.sessionLine, - self.monthLine, - self.hintLine ?? "", - self.errorLine ?? "", - self.errorCopyText ?? "", + MenuCardHeightFingerprint.field("session", self.sessionLine), + MenuCardHeightFingerprint.field("month", self.monthLine), + MenuCardHeightFingerprint.field("hint", self.hintLine), + MenuCardHeightFingerprint.field("error", self.errorLine), + MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText), ]) } } @@ -83,11 +113,11 @@ extension UsageMenuCardView.Model.TokenUsageSection { extension InlineUsageDashboardModel { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ - self.accessibilityLabel, + MenuCardHeightFingerprint.field("accessibility", self.accessibilityLabel), self.valueStyle.heightFingerprint, MenuCardHeightFingerprint.join(self.kpis.map(\.heightFingerprint)), MenuCardHeightFingerprint.join(self.points.map(\.heightFingerprint)), - MenuCardHeightFingerprint.join(self.detailLines), + MenuCardHeightFingerprint.join(self.detailLines.map { MenuCardHeightFingerprint.field("detail", $0) }), ]) } } @@ -95,8 +125,8 @@ extension InlineUsageDashboardModel { extension InlineUsageDashboardModel.KPI { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ - self.title, - self.value, + MenuCardHeightFingerprint.field("title", self.title), + MenuCardHeightFingerprint.field("value", self.value), self.emphasis ? "1" : "0", ]) } @@ -106,8 +136,8 @@ extension InlineUsageDashboardModel.Point { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ self.id, - self.label, - self.accessibilityValue, + MenuCardHeightFingerprint.field("label", self.label), + MenuCardHeightFingerprint.field("accessibilityValue", self.accessibilityValue), ]) } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 74d4db0f3..fb4081a2e 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -552,7 +552,7 @@ extension StatusItemController { heightCacheScope: row.provider.rawValue, heightCacheFingerprint: row.model.heightFingerprint( section: "overview", - additional: ["storage=\(storageText ?? "")"]), + additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), submenu: submenu, onClick: { [weak self, weak menu] in guard let self, let menu else { return } @@ -1292,7 +1292,7 @@ extension StatusItemController { id: "menuCardStorage", width: width, heightCacheScope: provider.rawValue, - heightCacheFingerprint: "storage=\(storageText)", + heightCacheFingerprint: UsageMenuCardView.Model.heightFingerprintField("storage", storageText), submenu: storageSubmenu)) return true } diff --git a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift index e5669f7c6..c6d56ca3c 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift @@ -30,4 +30,13 @@ extension StatusItemController { self.menuCardHeightCache[key] = height return height } + + func pruneVersionScopedMenuCardHeightCache() { + let currentVersionFingerprint = "version:\(self.menuContentVersion)" + for key in self.menuCardHeightCache.keys + where key.fingerprint.hasPrefix("version:") && key.fingerprint != currentVersionFingerprint + { + self.menuCardHeightCache.removeValue(forKey: key) + } + } } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 18901fd23..56df9ed31 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -32,6 +32,7 @@ extension StatusItemController { guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 + self.pruneVersionScopedMenuCardHeightCache() if !allowStaleContentDuringDataRefresh { self.latestRequiredMenuRebuildVersion = self.menuContentVersion } diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift new file mode 100644 index 000000000..96c823a2d --- /dev/null +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Testing +@testable import CodexBar + +struct MenuCardHeightFingerprintTests { + @Test + func `height fingerprint does not retain raw text fields`() { + let model = UsageMenuCardView.Model( + provider: .codex, + providerName: "Secret Provider Name", + email: "very-secret@example.com", + subtitleText: "Signed in as very-secret@example.com", + subtitleStyle: .info, + planText: "Secret Plan", + metrics: [ + .init( + id: "primary", + title: "Secret Metric", + percent: 42, + percentStyle: .left, + statusText: "Secret status", + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true), + ], + usageNotes: ["Secret note"], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: nil, + progressColor: .blue) + + let fingerprint = model.heightFingerprint(section: "card") + + #expect(!fingerprint.contains("very-secret@example.com")) + #expect(!fingerprint.contains("Secret Provider Name")) + #expect(!fingerprint.contains("Secret Metric")) + #expect(!fingerprint.contains("Secret note")) + } + + @Test + func `height fingerprint field distinguishes nil from empty string`() { + let nilField = UsageMenuCardView.Model.heightFingerprintField("storage", nil) + let emptyField = UsageMenuCardView.Model.heightFingerprintField("storage", "") + + #expect(nilField != emptyField) + } +} diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift index d990a681f..f7e4874a6 100644 --- a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -26,7 +26,7 @@ extension StatusMenuTests { let controller = StatusItemController( store: store, settings: settings, - account: UsageFetcher().loadAccountInfo(), + account: AccountInfo(email: nil, plan: nil), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) @@ -139,6 +139,33 @@ extension StatusMenuTests { #expect(measureCount == 2) } + @Test + func `menu invalidation prunes old version scoped height cache entries`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + _ = controller.cachedMenuCardHeight( + for: "versioned", + scope: UsageProvider.codex.rawValue, + width: 320) + { + 42 + } + _ = controller.cachedMenuCardHeight( + for: "fingerprinted", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + 99 + } + + controller.invalidateMenus() + + #expect(controller.menuCardHeightCache.keys.allSatisfy { !$0.fingerprint.hasPrefix("version:") }) + #expect(controller.menuCardHeightCache.keys.contains { $0.fingerprint == "content:stable" }) + } + @Test func `menu card height cache scopes same row ids by provider`() { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled @@ -160,7 +187,6 @@ extension StatusMenuTests { enabled: provider == .codex || provider == .claude) } - let fetcher = UsageFetcher() let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) store._setSnapshotForTesting( UsageSnapshot( @@ -181,7 +207,7 @@ extension StatusMenuTests { let controller = StatusItemController( store: store, settings: settings, - account: fetcher.loadAccountInfo(), + account: AccountInfo(email: nil, plan: nil), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) @@ -205,7 +231,7 @@ extension StatusMenuTests { return StatusItemController( store: store, settings: settings, - account: UsageFetcher().loadAccountInfo(), + account: AccountInfo(email: nil, plan: nil), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) From c7f8336e1314c25ed17ddb39f4bc9b5c34c74566 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Fri, 5 Jun 2026 17:58:14 +0800 Subject: [PATCH 07/93] Trim menu card rebuild hot paths --- Sources/CodexBar/Localization.swift | 6 +- .../CodexBar/MenuCardHeightFingerprint.swift | 26 ++++---- .../StatusItemController+MenuCardModel.swift | 6 +- Sources/CodexBar/UsageStore+Accessors.swift | 33 +++++++--- Sources/CodexBar/UsageStore.swift | 12 ++++ .../MenuCardHeightFingerprintTests.swift | 53 ++++++++++------ .../UsageStoreCoverageTests.swift | 60 +++++++++++++++++++ 7 files changed, 157 insertions(+), 39 deletions(-) diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 8fbdf3217..3e45ee22b 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -16,12 +16,16 @@ private func appLanguageDefaults() -> UserDefaults { return UserDefaults(suiteName: "CodexBar") ?? .standard } -private func isRunningTestsProcess() -> Bool { +private let isRunningTestsProcessAtStartup: Bool = { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } if env["TESTING_LIBRARY_VERSION"] != nil { return true } if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil +}() + +private func isRunningTestsProcess() -> Bool { + isRunningTestsProcessAtStartup } private let standardAppLanguageAtProcessStart = UserDefaults.standard.string(forKey: "appLanguage") diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index cec642f46..440c24ca2 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -1,4 +1,3 @@ -import CryptoKit import Foundation extension UsageMenuCardView.Model { @@ -9,6 +8,7 @@ extension UsageMenuCardView.Model { return MenuCardHeightFingerprint.join([ "section=\(section)", "provider=\(self.provider.rawValue)", + "localization=\(codexBarLocalizationSignature())", MenuCardHeightFingerprint.field("name", self.providerName), MenuCardHeightFingerprint.field("email", self.email), MenuCardHeightFingerprint.field("subtitle", self.subtitleText), @@ -34,7 +34,7 @@ extension UsageMenuCardView.Model { } private enum MenuCardHeightFingerprint { - private static let digestSalt = UUID().uuidString + private static let hashSalt = UUID() static func join(_ values: [String]) -> String { values.map { "\($0.count):\($0)" }.joined(separator: "|") @@ -48,13 +48,18 @@ private enum MenuCardHeightFingerprint { } private static func stringShape(_ value: String) -> String { - let digestInput = "\(Self.digestSalt)\u{0}\(value)" - let digest = SHA256.hash(data: Data(digestInput.utf8)) - .prefix(12) - .map { String(format: "%02x", $0) } - .joined() - let lineCount = value.split(separator: "\n", omittingEmptySubsequences: false).count - return "chars:\(value.count),utf8:\(value.utf8.count),lines:\(lineCount),sha:\(digest)" + var hasher = Hasher() + hasher.combine(Self.hashSalt) + hasher.combine(value) + let digest = String(UInt(bitPattern: hasher.finalize()), radix: 16) + return "chars:\(value.count),utf8:\(value.utf8.count),lines:\(Self.lineCount(value)),hash:\(digest)" + } + + private static func lineCount(_ value: String) -> Int { + guard !value.isEmpty else { return 0 } + return value.utf8.reduce(1) { count, byte in + byte == 10 ? count + 1 : count + } } } @@ -73,7 +78,8 @@ extension UsageMenuCardView.Model.Metric { MenuCardHeightFingerprint.join([ self.id, MenuCardHeightFingerprint.field("title", self.title), - MenuCardHeightFingerprint.field("percent", self.percentLabel), + "percent=\(Int(self.percent.rounded()))", + "percentStyle=\(self.percentStyle.rawValue)", MenuCardHeightFingerprint.field("status", self.statusText), MenuCardHeightFingerprint.field("reset", self.resetText), MenuCardHeightFingerprint.field("detail", self.detailText), diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 1518d1223..7c17a6559 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -85,6 +85,10 @@ extension StatusItemController { self.store.weeklyPace(provider: target, window: window, now: now) } } + let fallbackAccount = accountOverride + ?? (metadata.usesAccountFallback + ? self.store.accountInfo(for: target) + : AccountInfo(email: nil, plan: nil)) let input = UsageMenuCardView.Model.Input( provider: target, metadata: metadata, @@ -96,7 +100,7 @@ extension StatusItemController { dashboardError: dashboardError, tokenSnapshot: tokenSnapshot, tokenError: tokenError, - account: accountOverride ?? self.store.accountInfo(for: target), + account: fallbackAccount, isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), lastError: errorOverride ?? codexProjection?.userFacingErrors.usage diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 4224e8d72..ccdec060b 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -83,15 +83,30 @@ extension UsageStore { } func accountInfo(for provider: UsageProvider) -> AccountInfo { - guard provider == .codex else { - return self.codexFetcher.loadAccountInfo() + let now = Date() + let configRevision = self.settings.configRevision + if let cached = self.accountInfoCache[provider], + cached.isValid(now: now, configRevision: configRevision) + { + return cached.account } - let env = ProviderRegistry.makeEnvironment( - base: self.environmentBase, - provider: .codex, - settings: self.settings, - tokenOverride: nil) - let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: .codex, env: env) - return fetcher.loadAccountInfo() + + let account: AccountInfo + if provider == .codex { + let env = ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: .codex, + settings: self.settings, + tokenOverride: nil) + let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: .codex, env: env) + account = fetcher.loadAccountInfo() + } else { + account = self.codexFetcher.loadAccountInfo() + } + self.accountInfoCache[provider] = AccountInfoCacheEntry( + account: account, + configRevision: configRevision, + expiresAt: now.addingTimeInterval(self.accountInfoCacheTTL)) + return account } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 1e8d5ebf0..5f62b0f95 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -111,6 +111,16 @@ final class UsageStore { } } + struct AccountInfoCacheEntry { + let account: AccountInfo + let configRevision: Int + let expiresAt: Date + + func isValid(now: Date, configRevision: Int) -> Bool { + self.configRevision == configRevision && self.expiresAt > now + } + } + enum CodexCreditsSource { case none case api @@ -214,6 +224,7 @@ final class UsageStore { @ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata] @ObservationIgnored var providerRuntimes: [UsageProvider: any ProviderRuntime] = [:] @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] + @ObservationIgnored var accountInfoCache: [UsageProvider: AccountInfoCacheEntry] = [:] @ObservationIgnored private var timerTask: Task? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? @@ -245,6 +256,7 @@ final class UsageStore { @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let providerAvailabilityCacheTTL: TimeInterval = 1 + @ObservationIgnored let accountInfoCacheTTL: TimeInterval = 30 @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @ObservationIgnored let startupBehavior: StartupBehavior diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift index 96c823a2d..e81a29331 100644 --- a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -5,7 +5,39 @@ import Testing struct MenuCardHeightFingerprintTests { @Test func `height fingerprint does not retain raw text fields`() { - let model = UsageMenuCardView.Model( + let model = Self.model() + + let fingerprint = model.heightFingerprint(section: "card") + + #expect(!fingerprint.contains("very-secret@example.com")) + #expect(!fingerprint.contains("Secret Provider Name")) + #expect(!fingerprint.contains("Secret Metric")) + #expect(!fingerprint.contains("Secret note")) + } + + @Test + func `height fingerprint field distinguishes nil from empty string`() { + let nilField = UsageMenuCardView.Model.heightFingerprintField("storage", nil) + let emptyField = UsageMenuCardView.Model.heightFingerprintField("storage", "") + + #expect(nilField != emptyField) + } + + @Test + func `height fingerprint keeps cheap metric percent identity`() { + let left = Self.model(percent: 42, percentStyle: .left).heightFingerprint(section: "card") + let used = Self.model(percent: 42, percentStyle: .used).heightFingerprint(section: "card") + let changedPercent = Self.model(percent: 43, percentStyle: .left).heightFingerprint(section: "card") + + #expect(left != used) + #expect(left != changedPercent) + } + + private static func model( + percent: Double = 42, + percentStyle: UsageMenuCardView.Model.PercentStyle = .left) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( provider: .codex, providerName: "Secret Provider Name", email: "very-secret@example.com", @@ -16,8 +48,8 @@ struct MenuCardHeightFingerprintTests { .init( id: "primary", title: "Secret Metric", - percent: 42, - percentStyle: .left, + percent: percent, + percentStyle: percentStyle, statusText: "Secret status", resetText: nil, detailText: nil, @@ -37,20 +69,5 @@ struct MenuCardHeightFingerprintTests { tokenUsage: nil, placeholder: nil, progressColor: .blue) - - let fingerprint = model.heightFingerprint(section: "card") - - #expect(!fingerprint.contains("very-secret@example.com")) - #expect(!fingerprint.contains("Secret Provider Name")) - #expect(!fingerprint.contains("Secret Metric")) - #expect(!fingerprint.contains("Secret note")) - } - - @Test - func `height fingerprint field distinguishes nil from empty string`() { - let nilField = UsageMenuCardView.Model.heightFingerprintField("storage", nil) - let emptyField = UsageMenuCardView.Model.heightFingerprintField("storage", "") - - #expect(nilField != emptyField) } } diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index d17aa9245..c0d6bbba3 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -70,6 +70,34 @@ struct UsageStoreCoverageTests { #expect(label.contains("openai-web")) } + @Test + func `account info caches codex auth parsing until config revision changes`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-account-info-cache") + let home = FileManager.default.temporaryDirectory.appendingPathComponent( + "usage-store-account-info-\(UUID().uuidString)", + isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + try Self.writeCodexAuthFile(homeURL: home, email: "first@example.com", plan: "plus") + let env = ["CODEX_HOME": home.path] + let store = UsageStore( + fetcher: UsageFetcher(environment: env), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: env) + + let first = store.accountInfo(for: .codex) + try Self.writeCodexAuthFile(homeURL: home, email: "second@example.com", plan: "pro") + let cached = store.accountInfo(for: .codex) + settings.configRevision &+= 1 + let refreshed = store.accountInfo(for: .codex) + + #expect(first.email == "first@example.com") + #expect(cached.email == "first@example.com") + #expect(refreshed.email == "second@example.com") + } + @Test func `source label uses configured kilo source`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-kilo-source") @@ -595,6 +623,38 @@ struct UsageStoreCoverageTests { environmentBase: [:]) } + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth = try [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeCodexJWT(email: email, plan: plan), + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json"), options: .atomic) + } + + private static func fakeCodexJWT(email: String, plan: String) throws -> String { + let header = try JSONSerialization.data(withJSONObject: ["alg": "none"]) + let payload = try JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": [ + "chatgpt_plan_type": plan, + ], + ]) + return "\(Self.base64URL(header)).\(Self.base64URL(payload))." + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { let metadata = ProviderRegistry.shared.metadata for provider in UsageProvider.allCases { From 989a7572747d52bb7be19fa8fb9ea93ade1e35da Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Fri, 5 Jun 2026 18:28:41 +0800 Subject: [PATCH 08/93] Fold system text scale into menu card height cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu cards size from semantic text styles (.body/.footnote/.headline/…) that scale with the macOS system text-size / Dynamic Type setting. That scale was in neither the content fingerprint nor the cache key, and no observer clears the cache when it changes, so a runtime text-size change could return a height measured at the old scale (clipped or over-tall cards) until the next data refresh or the 256-entry flush. Add the resolved .body point size to MenuCardHeightCacheKey. Widening the key is the safe direction: identical scale keeps hitting as before, a changed scale forces a fresh measurement. It can never return a stale height, only (at worst) re-measure once after a scale change. Co-Authored-By: Claude Opus 4.8 --- .../StatusItemController+MenuCardHeightCache.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift index c6d56ca3c..3cf08ad87 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift @@ -5,9 +5,20 @@ extension StatusItemController { let id: String let scope: String let width: Int + let textScale: Int let fingerprint: String } + /// Measured card height also depends on the resolved font sizes, which the menu cards + /// derive from semantic text styles (`.body`, `.footnote`, …). Those scale with the + /// macOS system text-size / Dynamic Type setting, which is neither part of the content + /// fingerprint nor invalidated on rebuild. Fold the current resolved scale into the key + /// so a runtime text-size change forces a fresh measurement instead of returning a + /// height measured at the old scale (clipped / over-tall cards). + static func menuCardHeightTextScaleToken() -> Int { + Int((NSFont.preferredFont(forTextStyle: .body).pointSize * 100).rounded()) + } + func cachedMenuCardHeight( for id: String, scope: String, @@ -19,6 +30,7 @@ extension StatusItemController { id: id, scope: scope, width: Int((width * 100).rounded()), + textScale: Self.menuCardHeightTextScaleToken(), fingerprint: fingerprint ?? "version:\(self.menuContentVersion)") if let cached = self.menuCardHeightCache[key] { return cached From ef17fbd45d91d4117f243069eb76a78b51150a0b Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Fri, 5 Jun 2026 19:18:58 +0800 Subject: [PATCH 09/93] Skip closed merged-menu rebuild until next open In Merge Icons mode prepareAttachedClosedMenusIfNeeded eagerly rebuilt the closed merged menu on every store tick, running a full main-thread populateMenu (incl. SwiftUI hosting-view layout) that menuWillOpen redoes on display anyway. Defer the merged menu until next open instead, taking the residual close-path freeze to zero. Refs #1274. --- .../StatusItemController+MenuTracking.swift | 11 ++++++- .../StatusMenuOpenRefreshTests.swift | 30 +++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 56df9ed31..a04c50c70 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -53,7 +53,16 @@ extension StatusItemController { guard self.openMenus.isEmpty else { return } guard !self.isMenuDataRefreshInFlight else { return } for menu in self.attachedMenusForClosedPreparation() { - guard !self.closedMenusDeferredUntilNextOpen.contains(ObjectIdentifier(menu)) else { continue } + let key = ObjectIdentifier(menu) + guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue } + // Pre-warming the merged menu while it is closed runs a full main-thread populateMenu + // (incl. SwiftUI hosting-view layout) that menuWillOpen redoes synchronously on display + // anyway. In Merge Icons mode it is the only attached menu, so this just relocates that + // work into a background freeze on every store tick (#1274). Defer it until next open. + if menu === self.mergedMenu { + self.closedMenusDeferredUntilNextOpen.insert(key) + continue + } self.rebuildClosedMenuIfNeeded(menu) } } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 7a67bf8fc..cf31c6025 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -169,7 +169,7 @@ extension StatusMenuTests { } @Test - func `closed attached menu is prepared before next open after invalidation`() async { + func `closed merged menu defers rebuild until next open instead of pre-warming`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -199,13 +199,29 @@ extension StatusMenuTests { let key = ObjectIdentifier(menu) let openedVersion = controller.menuVersions[key] + // Background data-refresh tick (stale allowed): the closed merged menu must not be pre-warmed. + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.openMenus.isEmpty) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.closedMenusDeferredUntilNextOpen.contains(key)) + + // A required (non-stale) invalidation must also leave the closed merged menu deferred. controller.invalidateMenus() - for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + for _ in 0..<40 { await Task.yield() } + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.closedMenusDeferredUntilNextOpen.contains(key)) - #expect(controller.openMenus.isEmpty) + // The deferred merged menu is repopulated synchronously on the next open. + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) } @Test @@ -231,7 +247,9 @@ extension StatusMenuTests { controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() - controller.mergedMenu = menu + // Use a non-merged attached menu: the merged menu is intentionally never pre-warmed while + // closed (#1274), so the in-flight-refresh prep machinery is exercised via the fallback menu. + controller.fallbackMenu = menu controller.statusItem.menu = menu controller.populateMenu(menu, provider: nil) @@ -281,7 +299,9 @@ extension StatusMenuTests { controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() - controller.mergedMenu = menu + // Use a non-merged attached menu: the merged menu is intentionally never pre-warmed while + // closed (#1274), so the in-flight-refresh prep machinery is exercised via the fallback menu. + controller.fallbackMenu = menu controller.statusItem.menu = menu controller.populateMenu(menu, provider: nil) From 400f98aa0e9bf2801ea86563fe5fcfda52b70162 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 12:07:55 -0700 Subject: [PATCH 10/93] docs: reference menu height cache PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c47b38be2..70a83df73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.32.5 — Unreleased ### Fixed -- Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286). Thanks @hhh2210! +- Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! - Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! ## 0.32.4 — 2026-06-02 From 8c043a4598cb43bf937663724a1254f2f320e2d1 Mon Sep 17 00:00:00 2001 From: YanxinXue Date: Mon, 1 Jun 2026 10:50:47 +0800 Subject: [PATCH 11/93] Fix Alibaba token plan SEC token fallback --- .../AlibabaTokenPlanUsageFetcher.swift | 92 +++++++++++++++++-- .../AlibabaTokenPlanProviderTests.swift | 46 +++++++++- 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index e59e90a81..7f5e9fa45 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -26,6 +26,7 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { } } +// swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { private static let log = CodexBarLog.logger("alibaba-token-plan") private static let gatewayBaseURLString = "https://bailian.console.aliyun.com" @@ -306,6 +307,14 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return token } + if let token = await self.fetchSECTokenFromUserInfo( + cookieHeader: dashboardCookieHeader, + environment: environment, + session: session) + { + return token + } + if let cookieSECToken, !cookieSECToken.isEmpty { Self.log.info("Resolved Alibaba Token Plan sec_token from cookies") return cookieSECToken @@ -320,6 +329,55 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return nil } + private static func fetchSECTokenFromUserInfo( + cookieHeader: String, + environment: [String: String], + session: URLSession) async -> String? + { + let baseURL = self.consoleBaseURL(environment: environment) + let userInfoURL = baseURL.appendingPathComponent("tool/user/info.json") + var request = URLRequest(url: userInfoURL) + request.httpMethod = "GET" + request.timeoutInterval = 10 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue(Self.safariLikeUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + let referer = baseURL.absoluteString.hasSuffix("/") ? baseURL.absoluteString : "\(baseURL.absoluteString)/" + request.setValue(referer, forHTTPHeaderField: "Referer") + + guard let (data, response) = try? await session.data(for: request), + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return nil + } + + let expanded = self.expandedJSON(object) + guard let token = self.findFirstString(forKeys: ["secToken", "sec_token"], in: expanded), + !token.isEmpty + else { + return nil + } + + Self.log.info( + "Resolved Alibaba Token Plan sec_token from user info", + metadata: [ + "userInfoHost": userInfoURL.host ?? "unknown", + "bodyBytes": "\(data.count)", + ]) + return token + } + + private static func consoleBaseURL(environment: [String: String]) -> URL { + let dashboard = self.dashboardURL(environment: environment) + var components = URLComponents() + components.scheme = dashboard.scheme + components.host = dashboard.host + components.port = dashboard.port + return components.url ?? URL(string: Self.dashboardOriginURLString)! + } + private static func quotaURL(from rawHost: String) -> URL? { let cleaned = AlibabaTokenPlanSettingsReader.cleaned(rawHost) guard let cleaned else { return nil } @@ -408,11 +466,22 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { + if self.parseBool(dictionary["successResponse"]) == false { + let code = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary) + let message = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary) ?? + code ?? + "request was not successful" + if self.isLoginOrTokenError(code: code, message: message) { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.apiError(message) + } + if self.findBoolValues(forKeys: ["Success", "success"], in: dictionary).contains(false) { + let code = self.findFirstString(forKeys: ["Code", "code"], in: dictionary) let message = self.findFirstString(forKeys: ["Message", "message", "msg", "Code", "code"], in: dictionary) ?? "request was not successful" - let lowered = message.lowercased() - if lowered.contains("needlogin") || lowered.contains("login") { + if self.isLoginOrTokenError(code: code, message: message) { throw AlibabaTokenPlanUsageError.loginRequired } throw AlibabaTokenPlanUsageError.apiError(message) @@ -435,15 +504,24 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let codeText = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary)?.lowercased() let messageText = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary)? .lowercased() - if codeText?.contains("needlogin") == true || - codeText?.contains("login") == true || - messageText?.contains("log in") == true || - messageText?.contains("login") == true - { + if self.isLoginOrTokenError(code: codeText, message: messageText) { throw AlibabaTokenPlanUsageError.loginRequired } } + private static func isLoginOrTokenError(code: String?, message: String?) -> Bool { + let combined = [code, message] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + return combined.contains("needlogin") || + combined.contains("login") || + combined.contains("postonlyortokenerror") || + combined.contains("tokenerror") || + combined.contains("request has expired") || + combined.contains("refresh page") || + combined.contains("请求已经过期") + } + private static let planNameKeys = [ "planName", "plan_name", diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 4916dd0e5..b5fe11201 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -260,6 +260,21 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `post only token payload maps to login required`() { + let json = """ + { + "code": "PostonlyOrTokenError", + "message": "Your request has expired. Please refresh the page.", + "successResponse": false + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + @Test func `nested unsuccessful subscription summary maps to API error`() throws { let body = """ @@ -311,7 +326,7 @@ struct AlibabaTokenPlanUsageParsingTests { } @Test - func `cookie only request continues without SEC token`() async throws { + func `SEC token preflight falls back to user info`() async throws { defer { AlibabaTokenPlanStubURLProtocol.handler = nil } @@ -319,17 +334,40 @@ struct AlibabaTokenPlanUsageParsingTests { AlibabaTokenPlanStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } - if url.host == "alibaba-token-plan.test", request.httpMethod == "GET" { + if url.host == "alibaba-token-plan.test", + url.path == "/cn-beijing", + request.httpMethod == "GET" + { + #expect(url.port == 9443) return Self.makeResponse(url: url, body: "", statusCode: 200) } + if url.host == "alibaba-token-plan.test", + url.path == "/tool/user/info.json", + request.httpMethod == "GET" + { + #expect(url.port == 9443) + #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json, text/plain, */*") + let json = """ + { + "code": "200", + "data": { + "secToken": "user-info-token" + }, + "successResponse": true + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" { #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") #expect(request.value(forHTTPHeaderField: "Referer") == AlibabaTokenPlanUsageFetcher.dashboardURL .absoluteString) let body = Self.requestBodyString(from: request) - #expect(!body.contains("sec_token=")) + #expect(body.contains("sec_token=user-info-token")) #expect(body.contains("GetSubscriptionSummary")) #expect(body.contains("BssOpenAPI-V3")) #expect(body.contains("ProductCode")) @@ -356,7 +394,7 @@ struct AlibabaTokenPlanUsageParsingTests { let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", - environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test"], + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test:9443"], session: session) #expect(snapshot.planName == "TOKEN PLAN") From e652f74e0e4e50bad1a9d4b642ce5facc3ea30f7 Mon Sep 17 00:00:00 2001 From: YanxinXue Date: Mon, 1 Jun 2026 11:20:24 +0800 Subject: [PATCH 12/93] Preserve Alibaba credential error mapping --- .../Alibaba/AlibabaTokenPlanUsageFetcher.swift | 5 +++++ .../AlibabaTokenPlanProviderTests.swift | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 7f5e9fa45..93bc5d93a 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -467,6 +467,11 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { if self.parseBool(dictionary["successResponse"]) == false { + if let statusCode = self.findFirstInt(forKeys: ["statusCode", "status_code", "code"], in: dictionary), + statusCode == 401 || statusCode == 403 + { + throw AlibabaTokenPlanUsageError.invalidCredentials + } let code = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary) let message = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary) ?? code ?? diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index b5fe11201..fb17b4d0f 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -305,6 +305,21 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `failed forbidden payload maps to invalid credentials`() { + let json = """ + { + "successResponse": false, + "statusCode": 403, + "message": "Forbidden" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + @Test func `html login payload maps to login required`() { let html = """ From fa9b7570f4b64c53adc903b1265042633507d7b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 12:15:14 -0700 Subject: [PATCH 13/93] fix: retry login shell path capture --- CHANGELOG.md | 1 + .../Host/PTY/TTYCommandRunner.swift | 6 ++- Sources/CodexBarCore/PathEnvironment.swift | 44 ++++++++++++++++++- Tests/CodexBarTests/PathBuilderTests.swift | 37 ++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1171abe..edd747a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! - Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! - Menu bar: keep provider-switcher quota bars from replacing Auto Layout constraints when the visible ratio is unchanged, making tab switches responsive with many providers enabled (#1303, #1315). Thanks @juanjoseluisgarcia! +- Kiro: retry login-shell PATH capture when CLI discovery races a slow cold shell startup, so `kiro-cli` is no longer stuck as missing for the whole app session (#1316). Thanks @bt-justtrack! ## 0.32.4 — 2026-06-02 diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 90356cfae..3e90105bd 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -981,7 +981,11 @@ public struct TTYCommandRunner { proc.executableURL = URL(fileURLWithPath: "/usr/bin/which") proc.arguments = [tool] var env = ProcessInfo.processInfo.environment - env["PATH"] = PathBuilder.effectivePATH(purposes: [.tty, .nodeTooling], env: env) + let loginPATH = LoginShellPathCache.shared.currentOrCapture() + env["PATH"] = PathBuilder.effectivePATH( + purposes: [.tty, .nodeTooling], + env: env, + loginPATH: loginPATH) proc.environment = env let pipe = Pipe() proc.standardOutput = pipe diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 05f99cb46..2e60e2c5d 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -905,9 +905,11 @@ public enum PathBuilder { } enum LoginShellPathCapturer { + static let defaultTimeout: TimeInterval = 6.0 + static func capture( shell: String? = ProcessInfo.processInfo.environment["SHELL"], - timeout: TimeInterval = 2.0) -> [String]? + timeout: TimeInterval = Self.defaultTimeout) -> [String]? { let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) @@ -957,7 +959,7 @@ public final class LoginShellPathCache: @unchecked Sendable { public func captureOnce( shell: String? = ProcessInfo.processInfo.environment["SHELL"], - timeout: TimeInterval = 2.0, + timeout: TimeInterval = 6.0, onFinish: (([String]?) -> Void)? = nil) { self.lock.lock() @@ -993,4 +995,42 @@ public final class LoginShellPathCache: @unchecked Sendable { callbacks.forEach { $0(result) } } } + + public func currentOrCapture( + shell: String? = ProcessInfo.processInfo.environment["SHELL"], + timeout: TimeInterval = 6.0) -> [String]? + { + self.lock.lock() + if let captured { + self.lock.unlock() + return captured + } + + if self.isCapturing { + let semaphore = DispatchSemaphore(value: 0) + var callbackResult: [String]? + self.callbacks.append { result in + callbackResult = result + semaphore.signal() + } + self.lock.unlock() + let deadline = DispatchTime.now() + timeout + _ = semaphore.wait(timeout: deadline) + return callbackResult ?? self.current + } + + self.isCapturing = true + self.lock.unlock() + + let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout) + self.lock.lock() + self.captured = result + self.isCapturing = false + let callbacks = self.callbacks + self.callbacks.removeAll() + self.lock.unlock() + + callbacks.forEach { $0(result) } + return result + } } diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 7ac329e0c..3709d73c6 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -44,6 +44,30 @@ struct PathBuilderTests { #expect(async == sync) } + @Test + func `login shell cache retries after timed out nil capture`() throws { + let shell = try Self.makeLoginShellPathScript( + delay: 0.2, + path: "/login/bin:/usr/bin") + defer { try? FileManager.default.removeItem(at: shell) } + + let cache = LoginShellPathCache() + let semaphore = DispatchSemaphore(value: 0) + var firstResult: [String]? + cache.captureOnce(shell: shell.path, timeout: 0.01) { result in + firstResult = result + semaphore.signal() + } + + #expect(semaphore.wait(timeout: .now() + 2.0) == .success) + #expect(firstResult == nil) + #expect(cache.current == nil) + + let recovered = cache.currentOrCapture(shell: shell.path, timeout: 2.0) + #expect(recovered == ["/login/bin", "/usr/bin"]) + #expect(cache.current == ["/login/bin", "/usr/bin"]) + } + @Test func `shell runner drains noisy stdout and stderr`() throws { let script = """ @@ -626,6 +650,19 @@ struct PathBuilderTests { private static func shellSingleQuoted(_ value: String) -> String { "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" } + + private static func makeLoginShellPathScript(delay: TimeInterval, path: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-login-path-\(UUID().uuidString).sh") + let script = """ + #!/bin/sh + sleep \(delay) + printf '__CODEXBAR_PATH__%s__CODEXBAR_PATH__' \(self.shellSingleQuoted(path)) + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } } private final class MockFileManager: FileManager { From 90368202df707d4ae7bdaf45b43620d65daf3406 Mon Sep 17 00:00:00 2001 From: XWind Date: Mon, 1 Jun 2026 19:23:39 +0800 Subject: [PATCH 14/93] Fix MiniMax token plan usage display --- Sources/CodexBar/MenuCardView+Costs.swift | 9 + Sources/CodexBar/MenuCardView+MiniMax.swift | 53 ++- Sources/CodexBar/MenuCardView.swift | 51 ++- .../Providers/MiniMax/MiniMaxAPIRegion.swift | 5 + .../MiniMax/MiniMaxCookieHeader.swift | 17 +- .../MiniMax/MiniMaxSubscriptionMetadata.swift | 256 ++++++++++++ .../Providers/MiniMax/MiniMaxUsageError.swift | 21 + .../MiniMax/MiniMaxUsageFetcher.swift | 314 ++++++++++++--- .../MiniMaxUsageSnapshot+Metadata.swift | 45 +++ .../MiniMax/MiniMaxUsageSnapshot.swift | 37 +- Sources/CodexBarCore/UsageFetcher.swift | 12 + Tests/CodexBarTests/MenuCardModelTests.swift | 81 +++- .../MiniMaxMenuCardModelPlanTests.swift | 57 +++ .../CodexBarTests/MiniMaxProviderTests.swift | 30 ++ .../MiniMaxTokenPlanChangeTests.swift | 367 ++++++++++++++++++ .../SettingsStoreCoverageTests.swift | 25 ++ ...sageStoreSessionQuotaTransitionTests.swift | 62 +++ 17 files changed, 1356 insertions(+), 86 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift create mode 100644 Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 37973a99a..62a25cb49 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -166,6 +166,15 @@ extension UsageMenuCardView.Model { percentLine: nil) } + if provider == .minimax, cost.period == "MiniMax points balance" { + let balance = String(format: "%.0f", cost.used) + return ProviderCostSection( + title: L("Credits"), + percentUsed: nil, + spendLine: "\(L("Balance")): \(balance)", + percentLine: nil) + } + if provider == .openai || provider == .claude, cost.limit <= 0 { let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index dda62d32f..ea94db9ca 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -13,9 +13,6 @@ extension UsageMenuCardView.Model { format: L("minimax_usage_amount_format"), used.formatted(), service.limit.formatted()) - let usedLabel = String( - format: L("minimax_used_percent_format"), - String(format: "%.0f%%", displayPercent)) let localizedName = Self.localizedMiniMaxServiceName(service.displayName) let title = if localizedName == L("minimax_service_text_generation"), textGenerationCount > 1 { "\(L("minimax_service_text_generation")) · \(Self.displayWindowBadge(for: service.windowType))" @@ -29,12 +26,56 @@ extension UsageMenuCardView.Model { percent: displayPercent, percentStyle: percentStyle, resetText: Self.localizedMiniMaxResetDescription(service.resetDescription), - detailText: service.timeRange, + detailText: nil, detailLeftText: usageLabel, - detailRightText: usedLabel, + detailRightText: nil, pacePercent: nil, paceOnTop: true, - cardStyle: true) + warningMarkerPercents: Self.miniMaxWarningMarkerPercents(service: service, input: input), + cardStyle: false) + } + } + + private static func miniMaxWarningMarkerPercents(service: MiniMaxServiceUsage, input: Input) -> [Double] { + switch self.miniMaxQuotaWarningWindow(for: service) { + case .session: + warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.session], + showUsed: true) + case .weekly: + markerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: true, + workDays: input.workDaysPerWeek, + windowMinutes: self.miniMaxWindowMinutes(for: service.windowType), + includeWorkdayMarkers: true) + } + } + + private static func miniMaxQuotaWarningWindow(for service: MiniMaxServiceUsage) -> QuotaWarningWindow { + service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" ? .weekly : .session + } + + private static func miniMaxWindowMinutes(for windowType: String) -> Int? { + let normalized = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == "weekly" { + return 7 * 24 * 60 + } + if normalized == "today" || normalized == "daily" { + return 24 * 60 + } + if normalized == "5h" { + return 5 * 60 + } + let pieces = normalized.split(separator: " ") + guard pieces.count >= 2, let value = Int(pieces[0]) else { return nil } + switch pieces[1] { + case "hour", "hours", "hr", "hrs": + return value * 60 + case "minute", "minutes", "min", "mins": + return value + default: + return nil } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 7601233dc..2ca066446 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -845,8 +845,10 @@ extension UsageMenuCardView.Model { } private static func usageNotes(input: Input) -> [String] { + let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot) + if input.provider == .kiro { - return kiroUsageNotes(input: input) + return kiroUsageNotes(input: input) + subscriptionNotes } if input.provider == .kilo { @@ -860,24 +862,24 @@ extension UsageMenuCardView.Model { { notes.append(L("Using CLI fallback")) } - return notes + return notes + subscriptionNotes } if input.provider == .mimo, input.snapshot != nil { return [ L("Balance updates in near-real time (up to 5 min lag)"), L("Daily billing data finalizes at 07:00 UTC"), - ] + ] + subscriptionNotes } if let notes = apiProviderUsageNotes(input: input) { - return notes + return notes + subscriptionNotes } guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { - return [] + return subscriptionNotes } var notes = Self.openRouterSpendNotes(openRouter) @@ -889,7 +891,26 @@ extension UsageMenuCardView.Model { case .unavailable: notes.append(L("API key limit unavailable right now")) } - return notes + return notes + subscriptionNotes + } + + private static func subscriptionMetadataNotes(snapshot: UsageSnapshot?) -> [String] { + guard let snapshot else { return [] } + if let renewsAt = snapshot.subscriptionRenewsAt { + return [String(format: L("Renews: %@"), self.subscriptionDateString(renewsAt))] + } + if let expiresAt = snapshot.subscriptionExpiresAt { + return [String(format: L("Plan expires: %@"), self.subscriptionDateString(expiresAt))] + } + return [] + } + + private static func subscriptionDateString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = .current + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter.string(from: date) } private static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { @@ -952,6 +973,9 @@ extension UsageMenuCardView.Model { } private static func planDisplay(_ text: String, for provider: UsageProvider) -> String { + if provider == .minimax { + return self.miniMaxPlanDisplay(text) + } let cleaned = if provider == .codex { CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) } else { @@ -960,6 +984,21 @@ extension UsageMenuCardView.Model { return cleaned.isEmpty ? text : cleaned } + private static func miniMaxPlanDisplay(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.lowercased() + if normalized.contains("tokenplanplus") || normalized.contains("token plan plus") { + return "Plus" + } + if normalized.contains("tokenplanmax") || normalized.contains("token plan max") { + return "Max" + } + if normalized.contains("tokenplanultra") || normalized.contains("token plan ultra") { + return "Ultra" + } + return trimmed + } + private static func kiloLoginPass(snapshot: UsageSnapshot?) -> String? { self.kiloLoginParts(snapshot: snapshot).pass } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index bf93d57a7..56e77e2c7 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -7,6 +7,7 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let remainsPath = "v1/api/openplatform/coding_plan/remains" + private static let tokenPlanRemainsPath = "v1/token_plan/remains" private static let billingHistoryPath = "account/amount" public var displayName: String { @@ -57,6 +58,10 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.remainsPath) } + public var tokenPlanRemainsURL: URL { + URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.tokenPlanRemainsPath) + } + public var dashboardURL: URL { var components = URLComponents(string: self.baseURLString)! components.path = "/" + Self.codingPlanPath diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift index 3f16aef95..ab68319a0 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift @@ -24,7 +24,11 @@ public enum MiniMaxCookieHeader { #"(?i)(?:--cookie|-b)\s*([^\s]+)"#, ] private static let authorizationPattern = #"(?i)\bauthorization:\s*bearer\s+([A-Za-z0-9._\-+=/]+)"# - private static let groupIDPattern = #"(?i)\bgroup[_]?id=([0-9]{4,})"# + private static let groupIDPatterns = [ + #"(?i)\bx-group-id:\s*([0-9]{4,})"#, + #"(?i)\bminimax_group_id_v2=([0-9]{4,})"#, + #"(?i)\bgroup[_]?id=([0-9]{4,})"#, + ] public static func override(from raw: String?) -> MiniMaxCookieOverride? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -34,7 +38,7 @@ public enum MiniMaxCookieHeader { } guard let cookie = self.normalized(from: raw) else { return nil } let authorizationToken = self.extractFirst(pattern: self.authorizationPattern, text: raw) - let groupID = self.extractFirst(pattern: self.groupIDPattern, text: raw) + let groupID = self.extractFirst(patterns: self.groupIDPatterns, text: raw) return MiniMaxCookieOverride( cookieHeader: cookie, authorizationToken: authorizationToken, @@ -102,4 +106,13 @@ public enum MiniMaxCookieHeader { let value = text[captureRange].trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : String(value) } + + private static func extractFirst(patterns: [String], text: String) -> String? { + for pattern in patterns { + if let value = self.extractFirst(pattern: pattern, text: text) { + return value + } + } + return nil + } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift new file mode 100644 index 000000000..9374e8081 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -0,0 +1,256 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct MiniMaxSubscriptionMetadata: Sendable, Equatable { + let planName: String? + let subscriptionExpiresAt: Date? + let subscriptionRenewsAt: Date? +} + +enum MiniMaxSubscriptionMetadataFetcher { + private static let comboPath = "v1/api/openplatform/charge/combo/cycle_audio_resource_package" + + static func fetch( + cookieHeader: String, + groupID: String?, + region: MiniMaxAPIRegion, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> MiniMaxSubscriptionMetadata + { + let url = self.resolveComboURL(region: region, environment: environment) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + if let groupID = groupID?.trimmingCharacters(in: .whitespacesAndNewlines), !groupID.isEmpty { + request.setValue(groupID, forHTTPHeaderField: "x-group-id") + } + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept") + request.setValue("zh-CN,zh;q=0.9", forHTTPHeaderField: "accept-language") + request.setValue(self.platformOrigin(region: region).absoluteString, forHTTPHeaderField: "origin") + request.setValue(self.platformOrigin(region: region).absoluteString + "/", forHTTPHeaderField: "referer") + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") + } + return try self.parse(data: response.data) + } + + static func parse(data: Data) throws -> MiniMaxSubscriptionMetadata { + let object = try JSONSerialization.jsonObject(with: data, options: []) + try self.validateBaseResponse(in: object) + let planName = self.findPlanName(in: object) + let subscriptionExpiresAt = self.findDate( + in: object, + keys: ["current_subscribe_end_time_ts", "current_subscribe_end_time"]) + let subscriptionRenewsAt = self.findDate( + in: object, + keys: ["renewal_trigger_time_ts", "renewal_date"]) + guard planName != nil || subscriptionExpiresAt != nil || subscriptionRenewsAt != nil else { + throw MiniMaxUsageError.parseFailed("MiniMax combo metadata did not include subscription metadata.") + } + return MiniMaxSubscriptionMetadata( + planName: planName, + subscriptionExpiresAt: subscriptionExpiresAt, + subscriptionRenewsAt: subscriptionRenewsAt) + } + + static func resolveComboURL(region: MiniMaxAPIRegion, environment: [String: String]) -> URL { + let host = MiniMaxSettingsReader.hostOverride(environment: environment) ?? self.defaultWebHost(region: region) + var components = URLComponents(string: host.hasPrefix("http") ? host : "https://\(host)")! + components.path = "/" + Self.comboPath + components.queryItems = [ + URLQueryItem(name: "biz_line", value: "2"), + URLQueryItem(name: "cycle_type", value: "3"), + URLQueryItem(name: "resource_package_type", value: "7"), + ] + return components.url! + } + + private static func validateBaseResponse(in object: Any) throws { + guard let root = object as? [String: Any], + let baseResp = root["base_resp"] as? [String: Any] + else { return } + let status = self.intValue(baseResp["status_code"]) ?? 0 + guard status != 0 else { return } + let message = (baseResp["status_msg"] as? String) ?? "MiniMax combo metadata error \(status)" + if status == 1004 || message.lowercased().contains("cookie") { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError(message) + } + + private static func findPlanName(in object: Any) -> String? { + let currentSubscriptionStrings = self.collectCurrentSubscriptionStrings(in: object) + if let tokenPlan = self.bestPlanName(in: currentSubscriptionStrings) { + return tokenPlan + } + + let strings = self.collectStrings(in: object) + if let tokenPlan = self.bestPlanName(in: strings) { + return tokenPlan + } + + return nil + } + + private static func bestPlanName(in strings: [String]) -> String? { + let tokenPlans = strings.compactMap { value -> (rank: Int, value: String)? in + guard let rank = self.tokenPlanRank(value) else { return nil } + return (rank, value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + if let tokenPlan = tokenPlans.min(by: { lhs, rhs in + lhs.rank == rhs.rank ? lhs.value.count < rhs.value.count : lhs.rank < rhs.rank + }) { + return tokenPlan.value + } + return strings.first { value in + let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines) + return ["plus", "max", "ultra"].contains(cleaned.lowercased()) + } + } + + private static func collectCurrentSubscriptionStrings(in object: Any) -> [String] { + guard let dictionary = object as? [String: Any] else { + if let array = object as? [Any] { + return array.flatMap(self.collectCurrentSubscriptionStrings(in:)) + } + return [] + } + + return dictionary.flatMap { key, value in + let lowercasedKey = key.lowercased() + let stringsForCurrentField: [String] = if lowercasedKey == "current_subscribe" || + lowercasedKey == "current_subscription" || + lowercasedKey.contains("current_subscribe") || + lowercasedKey.contains("current_subscription") || + lowercasedKey.contains("current_plan") + { + self.collectStrings(in: value) + } else { + [] + } + return stringsForCurrentField + self.collectCurrentSubscriptionStrings(in: value) + } + } + + private static func tokenPlanRank(_ value: String) -> Int? { + let lower = value.lowercased() + if lower.contains("tokenplanplus") { return 0 } + if lower.contains("tokenplanmax") { return 1 } + if lower.contains("tokenplanultra") { return 2 } + if lower.contains("token plan"), lower.contains("plus") || lower.contains("max") || lower.contains("ultra") { + return 3 + } + return nil + } + + private static func collectStrings(in object: Any) -> [String] { + if let string = object as? String { return [string] } + if let array = object as? [Any] { return array.flatMap(self.collectStrings(in:)) } + if let dictionary = object as? [String: Any] { + return dictionary.sorted { $0.key < $1.key }.flatMap { self.collectStrings(in: $0.value) } + } + return [] + } + + private static func intValue(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let string = value as? String { return Int(string) } + return nil + } + + private static func findDate(in object: Any, keys: [String]) -> Date? { + keys.lazy.compactMap { key in + self.findValue(forKey: key, in: object).flatMap(self.dateValue(from:)) + }.first + } + + private static func findValue(forKey key: String, in object: Any) -> Any? { + if let dictionary = object as? [String: Any] { + if let value = dictionary[key] { return value } + for nested in dictionary.values { + if let value = self.findValue(forKey: key, in: nested) { + return value + } + } + } + if let array = object as? [Any] { + for nested in array { + if let value = self.findValue(forKey: key, in: nested) { + return value + } + } + } + return nil + } + + private static func dateValue(from value: Any) -> Date? { + if let int = value as? Int { + return self.dateValue(fromNumber: Double(int)) + } + if let double = value as? Double { + return self.dateValue(fromNumber: double) + } + guard let string = value as? String else { return nil } + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if let numeric = Double(trimmed) { + return self.dateValue(fromNumber: numeric) + } + return self.dateFromMonthDayYear(trimmed) + } + + private static func dateValue(fromNumber value: Double) -> Date? { + guard value.isFinite, value > 0 else { return nil } + let seconds = value > 10_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + private static func dateFromMonthDayYear(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + formatter.dateFormat = "MM/dd/yyyy" + return formatter.date(from: value) + } + + private static func defaultWebHost(region: MiniMaxAPIRegion) -> String { + switch region { + case .global: "https://www.minimax.io" + case .chinaMainland: "https://www.minimaxi.com" + } + } + + private static func platformOrigin(region: MiniMaxAPIRegion) -> URL { + switch region { + case .global: URL(string: "https://platform.minimax.io")! + case .chinaMainland: URL(string: "https://platform.minimaxi.com")! + } + } +} + +extension MiniMaxUsageFetcher { + static func attachingSubscriptionMetadataIfAvailable( + to snapshot: MiniMaxUsageSnapshot, + context: WebFetchContext, + groupID: String?) async -> MiniMaxUsageSnapshot + { + let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID + guard resolvedGroupID?.isEmpty == false else { return snapshot } + do { + let metadata = try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + return snapshot.withSubscriptionMetadata(metadata) + } catch { + Self.log.debug("MiniMax subscription metadata unavailable: \(error.localizedDescription)") + return snapshot + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift new file mode 100644 index 000000000..4cd856816 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift @@ -0,0 +1,21 @@ +import Foundation + +public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "MiniMax credentials are invalid or expired." + case let .networkError(message): + "MiniMax network error: \(message)" + case let .apiError(message): + "MiniMax API error: \(message)" + case let .parseFailed(message): + "Failed to parse MiniMax coding plan: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 7b250d6a2..50b910eab 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -8,6 +8,7 @@ public struct MiniMaxUsageFetcher: Sendable { private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let codingPlanRemainsPath = "v1/api/openplatform/coding_plan/remains" + private static let tokenPlanRemainsPath = "v1/token_plan/remains" private static let billingHistoryPath = "account/amount" private static let billingHistoryLimit = 100 private struct RemainsContext { @@ -15,7 +16,7 @@ public struct MiniMaxUsageFetcher: Sendable { let groupID: String? } - private struct WebFetchContext { + struct WebFetchContext { let cookie: String let authorizationToken: String? let region: MiniMaxAPIRegion @@ -44,7 +45,10 @@ public struct MiniMaxUsageFetcher: Sendable { environment: environment, transport: transport) do { - let snapshot = try await self.fetchCodingPlanHTML(context: context, now: now) + let snapshot = try await self.attachingSubscriptionMetadataIfAvailable( + to: self.fetchCodingPlanHTML(context: context, now: now), + context: context, + groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, @@ -53,12 +57,15 @@ public struct MiniMaxUsageFetcher: Sendable { } catch let error as MiniMaxUsageError { if case .parseFailed = error { Self.log.debug("MiniMax coding plan HTML parse failed, trying remains API") - let snapshot = try await self.fetchCodingPlanRemains( + let snapshot = try await self.attachingSubscriptionMetadataIfAvailable( + to: self.fetchCodingPlanRemains( + context: context, + remainsContext: RemainsContext( + authorizationToken: authorizationToken, + groupID: groupID), + now: now), context: context, - remainsContext: RemainsContext( - authorizationToken: authorizationToken, - groupID: groupID), - now: now) + groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, @@ -112,7 +119,7 @@ public struct MiniMaxUsageFetcher: Sendable { now: Date, transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot { - var request = URLRequest(url: region.apiRemainsURL) + var request = URLRequest(url: region.tokenPlanRemainsURL) request.httpMethod = "GET" request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "accept") @@ -189,7 +196,12 @@ public struct MiniMaxUsageFetcher: Sendable { if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } @@ -211,7 +223,30 @@ public struct MiniMaxUsageFetcher: Sendable { remainsContext: RemainsContext, now: Date) async throws -> MiniMaxUsageSnapshot { - let baseRemainsURL = self.resolveRemainsURL(region: context.region, environment: context.environment) + var lastError: Error? + for baseRemainsURL in self.resolveRemainsURLs(region: context.region, environment: context.environment) { + do { + return try await self.fetchCodingPlanRemainsOnce( + baseRemainsURL: baseRemainsURL, + context: context, + remainsContext: remainsContext, + now: now) + } catch let error as MiniMaxUsageError { + lastError = error + guard self.shouldTryNextRemainsURL(after: error) else { throw error } + Self.log.debug("MiniMax remains API failed for \(baseRemainsURL.host ?? "unknown host"), trying next") + } + } + if let lastError { throw lastError } + throw MiniMaxUsageError.parseFailed("Missing MiniMax remains URL.") + } + + private static func fetchCodingPlanRemainsOnce( + baseRemainsURL: URL, + context: WebFetchContext, + remainsContext: RemainsContext, + now: Date) async throws -> MiniMaxUsageSnapshot + { let remainsURL = self.appendGroupID(remainsContext.groupID, to: baseRemainsURL) var request = URLRequest(url: remainsURL) request.httpMethod = "GET" @@ -254,7 +289,12 @@ public struct MiniMaxUsageFetcher: Sendable { if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } @@ -268,6 +308,17 @@ public struct MiniMaxUsageFetcher: Sendable { return try MiniMaxUsageParser.parse(html: html, now: now) } + private static func shouldTryNextRemainsURL(after error: MiniMaxUsageError) -> Bool { + switch error { + case .invalidCredentials: + false + case let .apiError(message): + message.contains("HTTP 404") || message.contains("HTTP 405") + case .networkError, .parseFailed: + true + } + } + private static func attachingBillingIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, @@ -427,6 +478,50 @@ public struct MiniMaxUsageFetcher: Sendable { return region.remainsURL } + static func resolveRemainsURLs( + region: MiniMaxAPIRegion, + environment: [String: String]) -> [URL] + { + if let override = MiniMaxSettingsReader.remainsURL(environment: environment) { + return [override] + } + if let host = MiniMaxSettingsReader.hostOverride(environment: environment), + let hostURL = self.url(from: host, path: Self.codingPlanRemainsPath) + { + return [hostURL] + } + + let primary = region.remainsURL + let webCandidates = self.webRemainsFallbackURLs(region: region) + return self.deduplicated([primary] + webCandidates) + } + + static func resolveTokenPlanRemainsURL(region: MiniMaxAPIRegion) -> URL { + region.tokenPlanRemainsURL + } + + private static func webRemainsFallbackURLs(region: MiniMaxAPIRegion) -> [URL] { + let hosts = switch region { + case .global: + ["https://www.minimax.io"] + case .chinaMainland: + ["https://www.minimaxi.com"] + } + return hosts.compactMap { self.url(from: $0, path: Self.codingPlanRemainsPath) } + } + + private static func deduplicated(_ urls: [URL]) -> [URL] { + var seen: Set = [] + var result: [URL] = [] + for url in urls { + let key = url.absoluteString + guard !seen.contains(key) else { continue } + seen.insert(key) + result.append(url) + } + return result + } + static func resolveBillingHistoryURL( region: MiniMaxAPIRegion, environment: [String: String], @@ -542,6 +637,7 @@ struct MiniMaxCodingPlanData: Decodable { let comboTitle: String? let currentPlanTitle: String? let currentComboCard: MiniMaxComboCard? + let pointsBalance: Double? let modelRemains: [MiniMaxModelRemains] private enum CodingKeys: String, CodingKey { @@ -551,6 +647,11 @@ struct MiniMaxCodingPlanData: Decodable { case comboTitle = "combo_title" case currentPlanTitle = "current_plan_title" case currentComboCard = "current_combo_card" + case pointsBalance = "points_balance" + case pointBalance = "point_balance" + case creditsBalance = "credits_balance" + case creditBalance = "credit_balance" + case balance case modelRemains = "model_remains" } @@ -562,6 +663,13 @@ struct MiniMaxCodingPlanData: Decodable { self.comboTitle = try container.decodeIfPresent(String.self, forKey: .comboTitle) self.currentPlanTitle = try container.decodeIfPresent(String.self, forKey: .currentPlanTitle) self.currentComboCard = try container.decodeIfPresent(MiniMaxComboCard.self, forKey: .currentComboCard) + self.pointsBalance = MiniMaxDecoding.decodeDouble(container, forKeys: [ + .pointsBalance, + .pointBalance, + .creditsBalance, + .creditBalance, + .balance, + ]) self.modelRemains = try (container.decodeIfPresent([MiniMaxModelRemains].self, forKey: .modelRemains)) ?? [] } } @@ -577,11 +685,15 @@ struct MiniMaxModelRemains: Decodable { let startTime: Int? let endTime: Int? let remainsTime: Int? + let currentIntervalRemainingPercent: Double? + let currentIntervalStatus: Int? let currentWeeklyTotalCount: Int? let currentWeeklyUsageCount: Int? let weeklyStartTime: Int? let weeklyEndTime: Int? let weeklyRemainsTime: Int? + let currentWeeklyRemainingPercent: Double? + let currentWeeklyStatus: Int? private enum CodingKeys: String, CodingKey { case modelName = "model_name" @@ -590,11 +702,15 @@ struct MiniMaxModelRemains: Decodable { case startTime = "start_time" case endTime = "end_time" case remainsTime = "remains_time" + case currentIntervalRemainingPercent = "current_interval_remaining_percent" + case currentIntervalStatus = "current_interval_status" case currentWeeklyTotalCount = "current_weekly_total_count" case currentWeeklyUsageCount = "current_weekly_usage_count" case weeklyStartTime = "weekly_start_time" case weeklyEndTime = "weekly_end_time" case weeklyRemainsTime = "weekly_remains_time" + case currentWeeklyRemainingPercent = "current_weekly_remaining_percent" + case currentWeeklyStatus = "current_weekly_status" } init(from decoder: Decoder) throws { @@ -605,11 +721,19 @@ struct MiniMaxModelRemains: Decodable { self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.currentIntervalRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentIntervalRemainingPercent) + self.currentIntervalStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalStatus) self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) + self.currentWeeklyRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentWeeklyRemainingPercent) + self.currentWeeklyStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyStatus) } } @@ -710,6 +834,15 @@ enum MiniMaxDecoding { } return nil } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKeys keys: [K]) -> Double? { + for key in keys { + if let value = self.decodeDouble(container, forKey: key) { + return value + } + } + return nil + } } enum MiniMaxUsageParser { @@ -799,6 +932,8 @@ enum MiniMaxUsageParser { windowTypeOverride: nil, total: item.currentIntervalTotalCount, remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, + status: item.currentIntervalStatus, start: item.startTime, end: item.endTime, remainsTime: item.remainsTime), @@ -808,13 +943,15 @@ enum MiniMaxUsageParser { } // current_weekly_usage_count is also REMAINING quota; render only when weekly quota is real. - if self.isTextGenerationModelName(modelName), + if self.shouldRenderWeeklyWindow(for: modelName), let weeklyService = self.makeServiceUsage( ServiceUsageInput( serviceType: serviceTypeIdentifier, windowTypeOverride: "Weekly", total: item.currentWeeklyTotalCount, remaining: item.currentWeeklyUsageCount, + remainingPercent: item.currentWeeklyRemainingPercent, + status: item.currentWeeklyStatus, start: item.weeklyStartTime, end: item.weeklyEndTime, remainsTime: item.weeklyRemainsTime), @@ -826,9 +963,15 @@ enum MiniMaxUsageParser { // Use first service for backward compatibility fields let first = payload.data.modelRemains.first - let total = first?.currentIntervalTotalCount - let remaining = first?.currentIntervalUsageCount - let usedPercent = self.usedPercent(total: total, remaining: remaining) + let hasPercentQuota = first?.currentIntervalRemainingPercent != nil + let total = hasPercentQuota && first?.currentIntervalTotalCount == 0 ? nil : first?.currentIntervalTotalCount + let remaining = hasPercentQuota && first?.currentIntervalUsageCount == 0 + ? nil + : first?.currentIntervalUsageCount + let usedPercent = self.usedPercent( + total: total, + remaining: remaining, + remainingPercent: first?.currentIntervalRemainingPercent) let windowMinutes = self.windowMinutes( start: self.dateFromEpoch(first?.startTime), @@ -856,16 +999,24 @@ enum MiniMaxUsageParser { usedPercent: usedPercent, resetsAt: resetsAt, updatedAt: now, - services: services.isEmpty ? nil : services) + services: services.isEmpty ? nil : services, + pointsBalance: payload.data.pointsBalance) } - private static func usedPercent(total: Int?, remaining: Int?) -> Double? { + private static func usedPercent(total: Int?, remaining: Int?, remainingPercent: Double? = nil) -> Double? { + if let remainingPercent { + return self.usedPercent(remainingPercent: remainingPercent) + } guard let total, total > 0, let remaining else { return nil } let used = max(0, total - remaining) let percent = Double(used) / Double(total) * 100 return min(100, max(0, percent)) } + private static func usedPercent(remainingPercent: Double) -> Double { + min(100, max(0, 100 - remainingPercent)) + } + private static func dateFromEpoch(_ value: Int?) -> Date? { guard let raw = value else { return nil } if raw > 1_000_000_000_000 { @@ -893,40 +1044,47 @@ enum MiniMaxUsageParser { } private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { - let candidates = [ + [ data.currentSubscribeTitle, data.planName, data.comboTitle, data.currentPlanTitle, data.currentComboCard?.title, - ].compactMap(\.self) + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? self.inferredTokenPlanName(data: data) + } - for candidate in candidates { - let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } + private static func inferredTokenPlanName(data: MiniMaxCodingPlanData) -> String? { + let hasTextGeneration = data.modelRemains.contains { $0.modelName.map(self.isTextGenerationModelName) ?? false } + let hasUnavailableVideo = data.modelRemains.contains { item in + item.modelName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "video" && + self.isUnavailableQuotaPlaceholder( + total: item.currentIntervalTotalCount, + remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, + status: item.currentIntervalStatus) } - return nil + return hasTextGeneration && hasUnavailableVideo ? "Plus" : nil } private static func parsePlanName(html: String, text: String) -> String? { - let candidates = [ + [ self.extractFirst(pattern: #"(?i)"planName"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)"plan"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)"packageName"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)Coding\s*Plan\s*([A-Za-z0-9][A-Za-z0-9\s._-]{0,32})"#, text: text), - ].compactMap(\.self) - - for candidate in candidates { - let cleaned = UsageFormatter.cleanPlanName(candidate) - let trimmed = cleaned - .replacingOccurrences( - of: #"(?i)\s+available\s+usage.*$"#, - with: "", - options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - return nil + ] + .compactMap(\.self) + .map { + UsageFormatter.cleanPlanName($0) + .replacingOccurrences( + of: #"(?i)\s+available\s+usage.*$"#, + with: "", + options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + .first { !$0.isEmpty } } private static func parseNextData(html: String, now: Date) -> MiniMaxUsageSnapshot? { @@ -987,6 +1145,12 @@ enum MiniMaxUsageParser { if normalized["base_resp"] == nil, let value = normalized["baseResp"] { normalized["base_resp"] = value } + if normalized["points_balance"] == nil, let value = normalized["pointsBalance"] { + normalized["points_balance"] = value + } + if normalized["credits_balance"] == nil, let value = normalized["creditsBalance"] { + normalized["credits_balance"] = value + } if let data = normalized["data"] as? [String: Any] { normalized["data"] = self.normalizeCodingPlanPayload(data) @@ -1388,15 +1552,15 @@ enum MiniMaxUsageParser { let windowTypeOverride: String? let total: Int? let remaining: Int? + let remainingPercent: Double? + let status: Int? let start: Int? let end: Int? let remainsTime: Int? } private static func makeServiceUsage(_ input: ServiceUsageInput, now: Date) -> MiniMaxServiceUsage? { - guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } - let used = max(0, total - remaining) - if used == 0, total == 0 { return nil } + guard self.shouldRenderQuotaWindow(input) else { return nil } let startTime = self.dateFromEpoch(input.start) let endTime = self.dateFromEpoch(input.end) @@ -1415,18 +1579,52 @@ enum MiniMaxUsageParser { now: now, resetsAt: resetsAt) - let percent = Double(used) / Double(total) * 100.0 + let limit: Int + let usage: Int + let percent: Double + if let remainingPercent = input.remainingPercent { + percent = self.usedPercent(remainingPercent: remainingPercent) + limit = 100 + usage = Int(percent.rounded()) + } else { + guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } + let used = max(0, total - remaining) + percent = Double(used) / Double(total) * 100.0 + limit = total + usage = used + } + return MiniMaxServiceUsage( serviceType: input.serviceType, windowType: windowType, timeRange: timeRange, - usage: used, - limit: total, + usage: usage, + limit: limit, percent: min(100.0, max(0.0, percent)), resetsAt: resetsAt, resetDescription: resetDescription) } + private static func shouldRenderQuotaWindow(_ input: ServiceUsageInput) -> Bool { + // MiniMax Token Plan returns status 3 for quota lanes that exist in the schema but are not included in + // the current subscription, for example Plus accounts receiving a video lane with 100% remaining and 0 count. + !self.isUnavailableQuotaPlaceholder( + total: input.total, + remaining: input.remaining, + remainingPercent: input.remainingPercent, + status: input.status) + } + + private static func isUnavailableQuotaPlaceholder( + total: Int?, + remaining: Int?, + remainingPercent: Double?, + status: Int?) + -> Bool + { + status == 3 && (total ?? 0) == 0 && (remaining ?? 0) == 0 && (remainingPercent.map { $0 >= 100 } ?? false) + } + private static func mapModelNameToServiceType(modelName: String) -> String { // Text Generation (文本生成): M2.7, M2.7-highspeed, MiniMax-M*, etc. if self.isTextGenerationModelName(modelName) { @@ -1435,6 +1633,10 @@ enum MiniMaxUsageParser { let lower = modelName.lowercased() + if lower == "video" { + return "Text to Video" + } + // Text to Speech (语音合成): speech-hd, Speech 2.8, etc. if lower.contains("speech") { return "Text to Speech" @@ -1466,7 +1668,11 @@ enum MiniMaxUsageParser { private static func isTextGenerationModelName(_ modelName: String) -> Bool { let lower = modelName.lowercased() - return lower.contains("minimax-m") || lower.hasPrefix("m2.") + return lower == "general" || lower.contains("minimax-m") || lower.hasPrefix("m2.") + } + + private static func shouldRenderWeeklyWindow(for modelName: String) -> Bool { + self.isTextGenerationModelName(modelName) } private static func formatMiniMaxDateTimeRange(startTime: Date?, endTime: Date?) -> String? { @@ -1480,23 +1686,3 @@ enum MiniMaxUsageParser { return "\(start) - \(end)(UTC+8)" } } - -public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { - case invalidCredentials - case networkError(String) - case apiError(String) - case parseFailed(String) - - public var errorDescription: String? { - switch self { - case .invalidCredentials: - "MiniMax credentials are invalid or expired." - case let .networkError(message): - "MiniMax network error: \(message)" - case let .apiError(message): - "MiniMax API error: \(message)" - case let .parseFailed(message): - "Failed to parse MiniMax coding plan: \(message)" - } - } -} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift new file mode 100644 index 000000000..17532c9fa --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -0,0 +1,45 @@ +import Foundation + +extension MiniMaxUsageSnapshot { + func withPlanNameIfAvailable(_ planName: String?) -> MiniMaxUsageSnapshot { + let cleaned = planName?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let cleaned, !cleaned.isEmpty else { return self } + return MiniMaxUsageSnapshot( + planName: cleaned, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) + } + + func withSubscriptionMetadata(_ metadata: MiniMaxSubscriptionMetadata) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: metadata.planName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: metadata.subscriptionExpiresAt ?? self.subscriptionExpiresAt, + subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt) + } +} + +extension String { + fileprivate var nonEmpty: String? { + self.isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index f66de9d23..65aa13111 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -11,6 +11,9 @@ public struct MiniMaxUsageSnapshot: Sendable { public let updatedAt: Date public let services: [MiniMaxServiceUsage]? public let billingSummary: MiniMaxBillingSummary? + public let pointsBalance: Double? + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? public var primaryService: MiniMaxServiceUsage? { // Priority: "Text Generation" > first service @@ -55,7 +58,10 @@ public struct MiniMaxUsageSnapshot: Sendable { resetsAt: Date?, updatedAt: Date, services: [MiniMaxServiceUsage]? = nil, - billingSummary: MiniMaxBillingSummary? = nil) + billingSummary: MiniMaxBillingSummary? = nil, + pointsBalance: Double? = nil, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil) { self.planName = planName self.availablePrompts = availablePrompts @@ -67,6 +73,9 @@ public struct MiniMaxUsageSnapshot: Sendable { self.updatedAt = updatedAt self.services = services self.billingSummary = billingSummary + self.pointsBalance = pointsBalance + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt } public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { @@ -80,7 +89,10 @@ public struct MiniMaxUsageSnapshot: Sendable { resetsAt: self.resetsAt, updatedAt: self.updatedAt, services: self.services, - billingSummary: billingSummary) + billingSummary: billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) } } @@ -104,8 +116,10 @@ extension MiniMaxUsageSnapshot { primary: primaryWindow, secondary: secondaryWindow, tertiary: tertiaryWindow, - providerCost: nil, + providerCost: self.pointsBalanceSnapshot(), minimaxUsage: self, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -131,8 +145,10 @@ extension MiniMaxUsageSnapshot { primary: primary, secondary: nil, tertiary: nil, - providerCost: nil, + providerCost: self.pointsBalanceSnapshot(), minimaxUsage: self, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -178,6 +194,9 @@ extension MiniMaxUsageSnapshot { if windowType == "today" { return 24 * 60 } + if windowType == "weekly" { + return 7 * 24 * 60 + } // Handle time duration formats like "5 hours", "30 minutes", etc. let components = windowType.split(separator: " ") @@ -197,4 +216,14 @@ extension MiniMaxUsageSnapshot { return nil } } + + private func pointsBalanceSnapshot() -> ProviderCostSnapshot? { + guard let pointsBalance, pointsBalance >= 0 else { return nil } + return ProviderCostSnapshot( + used: pointsBalance, + limit: 0, + currencyCode: "Points", + period: "MiniMax points balance", + updatedAt: self.updatedAt) + } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 471f1147f..b9fa12814 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -95,6 +95,8 @@ public struct UsageSnapshot: Codable, Sendable { public let mistralUsage: MistralUsageSnapshot? public let deepgramUsage: DeepgramUsageSnapshot? public let cursorRequests: CursorRequestUsage? + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -110,6 +112,8 @@ public struct UsageSnapshot: Codable, Sendable { case claudeAdminAPIUsage case mistralUsage case deepgramUsage + case subscriptionExpiresAt + case subscriptionRenewsAt case updatedAt case identity case accountEmail @@ -133,6 +137,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: MistralUsageSnapshot? = nil, deepgramUsage: DeepgramUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) { @@ -151,6 +157,8 @@ public struct UsageSnapshot: Codable, Sendable { self.mistralUsage = mistralUsage self.deepgramUsage = deepgramUsage self.cursorRequests = cursorRequests + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt self.updatedAt = updatedAt self.identity = identity } @@ -174,6 +182,8 @@ public struct UsageSnapshot: Codable, Sendable { self.mistralUsage = try container.decodeIfPresent(MistralUsageSnapshot.self, forKey: .mistralUsage) self.deepgramUsage = try container.decodeIfPresent(DeepgramUsageSnapshot.self, forKey: .deepgramUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time + self.subscriptionExpiresAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionExpiresAt) + self.subscriptionRenewsAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionRenewsAt) self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { self.identity = identity @@ -207,6 +217,8 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage) try container.encodeIfPresent(self.deepgramUsage, forKey: .deepgramUsage) + try container.encodeIfPresent(self.subscriptionExpiresAt, forKey: .subscriptionExpiresAt) + try container.encodeIfPresent(self.subscriptionRenewsAt, forKey: .subscriptionRenewsAt) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 6e64710af..0fcd782fb 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -531,7 +531,7 @@ struct FactoryMenuCardModelTests { struct MiniMaxMenuCardModelTests { @Test - func `minimax service metrics use quota card copy`() throws { + func `minimax service metrics use codex aligned quota copy`() throws { let now = Date() let minimax = MiniMaxUsageSnapshot( planName: "Max", @@ -587,10 +587,10 @@ struct MiniMaxMenuCardModelTests { #expect(used.metrics.first?.title == "Text Generation") #expect(used.metrics.first?.detailLeftText == "Usage: 2 / 10") - #expect(used.metrics.first?.detailRightText == "Used 20%") - #expect(used.metrics.first?.detailText == "10:00-15:00(UTC+8)") + #expect(used.metrics.first?.detailRightText == nil) + #expect(used.metrics.first?.detailText == nil) #expect(used.metrics.first?.percent == 20) - #expect(used.metrics.first?.cardStyle == true) + #expect(used.metrics.first?.cardStyle == false) } @Test @@ -661,6 +661,79 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[0].title == "Text Generation · Today") #expect(model.metrics[1].title == "Text Generation · Weekly") } + + @Test + func `minimax token plan model shows weekly quota and points balance`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan · TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(4 * 3600), + resetDescription: "Resets in 4 hours"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ], + pointsBalance: 14000, + subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.planText == "Plus") + #expect(model.metrics[0].title == "Text Generation · 5h") + #expect(model.metrics[1].title == "Text Generation · Weekly") + #expect(model.metrics[0].detailLeftText == "Usage: 4 / 100") + #expect(model.metrics[1].detailLeftText == "Usage: 1 / 100") + #expect(model.metrics[0].detailRightText == nil) + #expect(model.metrics[1].detailRightText == nil) + #expect(model.metrics[0].detailText == nil) + #expect(model.metrics[1].detailText == nil) + #expect(model.metrics[0].cardStyle == false) + #expect(model.metrics[1].cardStyle == false) + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "Balance: 14000") + #expect(model.usageNotes.count == 1 && model.usageNotes[0].hasPrefix("Renews: ")) + } } struct ClaudeMenuCardCostTests { diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 968962590..5c96b374a 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -98,4 +98,61 @@ struct MiniMaxMenuCardModelPlanTests { #expect(model.planText == nil) } + + @Test + func `minimax quota rows include configured warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 31, + limit: 100, + percent: 31, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]]) + } } diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 1313c24e9..be79cc1dd 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -124,6 +124,18 @@ struct MiniMaxCookieHeaderTests { #expect(override?.authorizationToken == "token-abc") #expect(override?.groupID == "98765") } + + @Test + func `extracts group ID from combo curl header and cookie`() { + let raw = """ + curl 'https://www.minimaxi.com/v1/api/openplatform/charge/combo/cycle_audio_resource_package' \ + -b 'foo=bar; minimax_group_id_v2=2013894056999916075' \ + -H 'x-group-id: 2013894056999916075' + """ + let override = MiniMaxCookieHeader.override(from: raw) + #expect(override?.cookieHeader == "foo=bar; minimax_group_id_v2=2013894056999916075") + #expect(override?.groupID == "2013894056999916075") + } } struct MiniMaxUsageParserTests { @@ -1009,6 +1021,24 @@ struct MiniMaxAPIRegionTests { #expect(codingPlan.query == "cycle_type=3") } + @Test + func `resolves web remains fallback hosts`() { + let global = MiniMaxUsageFetcher.resolveRemainsURLs(region: .global, environment: [:]) + let china = MiniMaxUsageFetcher.resolveRemainsURLs(region: .chinaMainland, environment: [:]) + + #expect(global.map(\.host).contains("platform.minimax.io")) + #expect(global.map(\.host).contains("www.minimax.io")) + #expect(china.map(\.host).contains("platform.minimaxi.com")) + #expect(china.map(\.host).contains("www.minimaxi.com")) + } + + @Test + func `resolves official token plan remains URL`() { + let url = MiniMaxUsageFetcher.resolveTokenPlanRemainsURL(region: .chinaMainland) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + } + @Test func `host override wins for remains and coding plan`() { let env = [MiniMaxSettingsReader.hostKey: "api.minimaxi.com"] diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift new file mode 100644 index 000000000..00c5a14c4 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -0,0 +1,367 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MiniMaxTokenPlanChangeTests { + @Test + func `parses percent based general token plan remains`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(Self.percentBasedRemainsJSON.utf8), + now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.availablePrompts == nil) + #expect(snapshot.currentPrompts == nil) + #expect(snapshot.remainingPrompts == nil) + #expect(snapshot.usedPercent == 4) + #expect(services.count == 2) + #expect(services[0].displayName == "Text Generation") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 4) + #expect(services[0].limit == 100) + #expect(services[0].percent == 4) + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 1) + #expect(services[1].limit == 100) + #expect(services[1].percent == 1) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + } + + @Test + func `zero count fields do not suppress percent based quota windows`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "data": { + "current_subscribe_title": "Token Plan · TokenPlanPlus-年度会员", + "points_balance": "14000", + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": "96", + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": "99", + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Token Plan · TokenPlanPlus-年度会员") + #expect(snapshot.pointsBalance == 14000) + #expect(snapshot.services?.count == 2) + #expect(snapshot.toUsageSnapshot().providerCost?.used == 14000) + } + + @Test + func `plus token plan omits unavailable video quota lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + }, + { + "start_time": 1780243200000, + "end_time": 1780329600000, + "remains_time": 49059830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.planName == "Plus") + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "Plus") + #expect(services.map(\.displayName) == ["Text Generation", "Text Generation"]) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().tertiary == nil) + } + + @Test + func `web usage fetch falls back to www remains host after platform parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: "not json", contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { + $0.url?.host == "platform.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + #expect(requests.contains { + $0.url?.host == "www.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + } + + @Test + func `api token fetch uses official token plan remains endpoint`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-cp-test") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + now: now, + session: transport) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + @Test + func `api token fetch rejects official endpoint auth failure for fallback`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path == "/v1/token_plan/remains") + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + session: transport) + } + } + + @Test + func `combo metadata parser extracts token plan subscription label`() throws { + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(Self.comboMetadataJSON.utf8)) + #expect(metadata.planName == "TokenPlanMax-年度会员") + #expect(metadata.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(metadata.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + } + + @Test + func `combo metadata parser prefers current subscription over package catalog`() throws { + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanUltra-年度会员" + }, + "packages": [ + { "resource_package_name": "TokenPlanPlus" }, + { "resource_package_name": "TokenPlanMax" }, + { "resource_package_name": "TokenPlanUltra" } + ] + } + } + """ + + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(json.utf8)) + + #expect(metadata.planName == "TokenPlanUltra-年度会员") + } + + @Test + func `web usage fetch merges combo subscription metadata`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/charge/combo/cycle_audio_resource_package") + #expect(url.query?.contains("biz_line=2") == true) + #expect(request.value(forHTTPHeaderField: "x-group-id") == "2013894056999916075") + #expect(request.value(forHTTPHeaderField: "origin") == "https://platform.minimaxi.com") + return Self.httpResponse(url: url, body: Self.comboMetadataJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc; minimax_group_id_v2=2013894056999916075", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.planName == "TokenPlanMax-年度会员") + #expect(snapshot.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.toUsageSnapshot().subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { $0.url?.path.contains("cycle_audio_resource_package") == true }) + } + + @Test + func `combo metadata failure does not block quota rendering`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + #expect(snapshot.planName == nil) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + private static let comboMetadataJSON = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanMax-年度会员", + "current_subscribe_end_time": "05/19/2027", + "renewal_date": "05/18/2027", + "current_subscribe_end_time_ts": 1810656000000, + "renewal_trigger_time_ts": 1810569600000 + }, + "packages": [ + { + "resource_package_name": "TokenPlanMax", + "display_name": "Token Plan · TokenPlanMax-年度会员" + } + ] + } + } + """ + + private static let percentBasedRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 44a3e3e40..fc68cba5b 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -74,6 +74,31 @@ struct SettingsStoreCoverageTests { #expect(settings.resetTimeDisplayStyle == .absolute) } + @Test + func `minimax settings snapshot uses selected token account as manual cookie`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-token-account") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + settings.addTokenAccount(provider: .minimax, label: "account", token: "HERTZ-SESSION=selected") + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=selected") + } + + @Test + func `minimax settings snapshot falls back to global cookie without token accounts`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-global-cookie") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .auto) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=global") + } + @Test func `multi account menu layout persists and bridges legacy show all token accounts`() throws { let suite = "SettingsStoreCoverageTests-multi-account-layout" diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 1798b8eb4..6199b162c 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -510,6 +510,35 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.map(\.event.window) == [.weekly]) } + @Test + func `minimax quota warning posts for session and weekly windows`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-minimax") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 40, weeklyUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 55, weeklyUsed: 55)) + + #expect(notifier.quotaWarningPosts.map(\.provider) == [.minimax, .minimax]) + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + @Test func `disabling quota warning window clears fired state`() { let settings = self @@ -550,4 +579,37 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.count == 1) #expect(store.quotaWarningState[UsageStore.QuotaWarningStateKey(provider: .codex, window: .session)] == nil) } + + private func minimaxSnapshot(sessionUsed: Double, weeklyUsed: Double) -> UsageSnapshot { + let now = Date() + return MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: Int(sessionUsed), + limit: 100, + percent: sessionUsed, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: Int(weeklyUsed), + limit: 100, + percent: weeklyUsed, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]).toUsageSnapshot() + } } From 3298e47f74aa57c402f683b0c3da9067bdff5ef0 Mon Sep 17 00:00:00 2001 From: XWind Date: Tue, 2 Jun 2026 08:39:27 +0800 Subject: [PATCH 15/93] Harden MiniMax metadata fetch --- .../MiniMax/MiniMaxSubscriptionMetadata.swift | 13 +++-- .../MiniMaxTokenPlanChangeTests.swift | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift index 9374e8081..7cf3546a5 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -19,7 +19,7 @@ enum MiniMaxSubscriptionMetadataFetcher { environment: [String: String], transport: any ProviderHTTPTransport) async throws -> MiniMaxSubscriptionMetadata { - let url = self.resolveComboURL(region: region, environment: environment) + let url = try self.resolveComboURL(region: region, environment: environment) var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") @@ -58,9 +58,12 @@ enum MiniMaxSubscriptionMetadataFetcher { subscriptionRenewsAt: subscriptionRenewsAt) } - static func resolveComboURL(region: MiniMaxAPIRegion, environment: [String: String]) -> URL { + static func resolveComboURL(region: MiniMaxAPIRegion, environment: [String: String]) throws -> URL { let host = MiniMaxSettingsReader.hostOverride(environment: environment) ?? self.defaultWebHost(region: region) var components = URLComponents(string: host.hasPrefix("http") ? host : "https://\(host)")! + guard components.scheme?.lowercased() == "https" else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host must use HTTPS.") + } components.path = "/" + Self.comboPath components.queryItems = [ URLQueryItem(name: "biz_line", value: "2"), @@ -236,7 +239,7 @@ extension MiniMaxUsageFetcher { static func attachingSubscriptionMetadataIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, - groupID: String?) async -> MiniMaxUsageSnapshot + groupID: String?) async throws -> MiniMaxUsageSnapshot { let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID guard resolvedGroupID?.isEmpty == false else { return snapshot } @@ -248,6 +251,10 @@ extension MiniMaxUsageFetcher { environment: context.environment, transport: context.transport) return snapshot.withSubscriptionMetadata(metadata) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() } catch { Self.log.debug("MiniMax subscription metadata unavailable: \(error.localizedDescription)") return snapshot diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index 00c5a14c4..c5738bb4e 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -272,6 +272,58 @@ struct MiniMaxTokenPlanChangeTests { #expect(requests.contains { $0.url?.path.contains("cycle_audio_resource_package") == true }) } + @Test + func `combo metadata rejects non https host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.self) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "http://metadata.test"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `web usage fetch preserves combo metadata cancellation`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + } + } + @Test func `combo metadata failure does not block quota rendering`() async throws { let now = Date(timeIntervalSince1970: 1_780_282_340) From 65a41cbee9b325ba25f01c9b518bf9401897e2ef Mon Sep 17 00:00:00 2001 From: XWind Date: Tue, 2 Jun 2026 09:48:20 +0800 Subject: [PATCH 16/93] Handle MiniMax token plan quotas --- Sources/CodexBar/MenuCardView+MiniMax.swift | 23 ++- .../MiniMax/MiniMaxModelRemains.swift | 64 +++++++ .../MiniMax/MiniMaxServiceUsage.swift | 9 + .../MiniMax/MiniMaxUsageFetcher.swift | 160 +++++++----------- .../MiniMaxMenuCardModelPlanTests.swift | 66 +++++++- .../MiniMaxTokenPlanChangeTests.swift | 75 +++++++- 6 files changed, 290 insertions(+), 107 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index ea94db9ca..d41077061 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -4,18 +4,22 @@ import Foundation extension UsageMenuCardView.Model { static func minimaxMetrics(services: [MiniMaxServiceUsage], input: Input) -> [Metric] { let percentStyle: PercentStyle = .used - let textGenerationCount = services.count { $0.displayName == "Text Generation" } + let displayNameCounts = Dictionary(grouping: services.map(\.displayName), by: { $0 }).mapValues(\.count) return services.enumerated().map { index, service in let used = service.usage let displayPercent = min(100, max(0, service.percent)) - let usageLabel = String( - format: L("minimax_usage_amount_format"), - used.formatted(), - service.limit.formatted()) + let usageLabel = if service.isUnlimited { + nil as String? + } else { + String( + format: L("minimax_usage_amount_format"), + used.formatted(), + service.limit.formatted()) + } let localizedName = Self.localizedMiniMaxServiceName(service.displayName) - let title = if localizedName == L("minimax_service_text_generation"), textGenerationCount > 1 { - "\(L("minimax_service_text_generation")) · \(Self.displayWindowBadge(for: service.windowType))" + let title = if (displayNameCounts[service.displayName] ?? 0) > 1 { + "\(localizedName) · \(Self.displayWindowBadge(for: service.windowType))" } else { localizedName } @@ -25,13 +29,16 @@ extension UsageMenuCardView.Model { title: title, percent: displayPercent, percentStyle: percentStyle, + statusText: service.isUnlimited ? "∞ Unlimited" : nil, resetText: Self.localizedMiniMaxResetDescription(service.resetDescription), detailText: nil, detailLeftText: usageLabel, detailRightText: nil, pacePercent: nil, paceOnTop: true, - warningMarkerPercents: Self.miniMaxWarningMarkerPercents(service: service, input: input), + warningMarkerPercents: service.isUnlimited + ? [] + : Self.miniMaxWarningMarkerPercents(service: service, input: input), cardStyle: false) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift new file mode 100644 index 000000000..6dc8838aa --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift @@ -0,0 +1,64 @@ +struct MiniMaxModelRemains: Decodable { + let modelName: String? + let currentIntervalTotalCount: Int? + let currentIntervalUsageCount: Int? + let startTime: Int? + let endTime: Int? + let remainsTime: Int? + let intervalBoostPermille: Int? + let currentIntervalRemainingPercent: Double? + let currentIntervalStatus: Int? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? + let weeklyStartTime: Int? + let weeklyEndTime: Int? + let weeklyRemainsTime: Int? + let weeklyBoostPermille: Int? + let currentWeeklyRemainingPercent: Double? + let currentWeeklyStatus: Int? + + private enum CodingKeys: String, CodingKey { + case modelName = "model_name" + case currentIntervalTotalCount = "current_interval_total_count" + case currentIntervalUsageCount = "current_interval_usage_count" + case startTime = "start_time" + case endTime = "end_time" + case remainsTime = "remains_time" + case intervalBoostPermille = "interval_boost_permill" + case currentIntervalRemainingPercent = "current_interval_remaining_percent" + case currentIntervalStatus = "current_interval_status" + case currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" + case weeklyStartTime = "weekly_start_time" + case weeklyEndTime = "weekly_end_time" + case weeklyRemainsTime = "weekly_remains_time" + case weeklyBoostPermille = "weekly_boost_permill" + case currentWeeklyRemainingPercent = "current_weekly_remaining_percent" + case currentWeeklyStatus = "current_weekly_status" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) + self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) + self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) + self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) + self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.intervalBoostPermille = MiniMaxDecoding.decodeInt(container, forKey: .intervalBoostPermille) + self.currentIntervalRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentIntervalRemainingPercent) + self.currentIntervalStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalStatus) + self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) + self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) + self.weeklyBoostPermille = MiniMaxDecoding.decodeInt(container, forKey: .weeklyBoostPermille) + self.currentWeeklyRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentWeeklyRemainingPercent) + self.currentWeeklyStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyStatus) + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift index 488b0b0f5..a7359c483 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift @@ -34,6 +34,9 @@ public struct MiniMaxServiceUsage: Sendable { /// The percentage of quota used (0-100) public let percent: Double + /// Whether this quota window is explicitly unlimited. + public let isUnlimited: Bool + /// The timestamp when the quota will reset, if available public let resetsAt: Date? @@ -49,6 +52,10 @@ public struct MiniMaxServiceUsage: Sendable { public var displayName: String { let normalized = self.serviceType.lowercased() return switch normalized { + case "general": + "General" + case "video": + "Video" case "text-generation": "Text Generation" case "text-to-speech": @@ -98,6 +105,7 @@ public struct MiniMaxServiceUsage: Sendable { usage: Int, limit: Int, percent: Double, + isUnlimited: Bool = false, resetsAt: Date?, resetDescription: String) { @@ -107,6 +115,7 @@ public struct MiniMaxServiceUsage: Sendable { self.usage = usage self.limit = limit self.percent = percent + self.isUnlimited = isUnlimited self.resetsAt = resetsAt self.resetDescription = resetDescription } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 50b910eab..b479d48e5 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -678,65 +678,6 @@ struct MiniMaxComboCard: Decodable { let title: String? } -struct MiniMaxModelRemains: Decodable { - let modelName: String? - let currentIntervalTotalCount: Int? - let currentIntervalUsageCount: Int? - let startTime: Int? - let endTime: Int? - let remainsTime: Int? - let currentIntervalRemainingPercent: Double? - let currentIntervalStatus: Int? - let currentWeeklyTotalCount: Int? - let currentWeeklyUsageCount: Int? - let weeklyStartTime: Int? - let weeklyEndTime: Int? - let weeklyRemainsTime: Int? - let currentWeeklyRemainingPercent: Double? - let currentWeeklyStatus: Int? - - private enum CodingKeys: String, CodingKey { - case modelName = "model_name" - case currentIntervalTotalCount = "current_interval_total_count" - case currentIntervalUsageCount = "current_interval_usage_count" - case startTime = "start_time" - case endTime = "end_time" - case remainsTime = "remains_time" - case currentIntervalRemainingPercent = "current_interval_remaining_percent" - case currentIntervalStatus = "current_interval_status" - case currentWeeklyTotalCount = "current_weekly_total_count" - case currentWeeklyUsageCount = "current_weekly_usage_count" - case weeklyStartTime = "weekly_start_time" - case weeklyEndTime = "weekly_end_time" - case weeklyRemainsTime = "weekly_remains_time" - case currentWeeklyRemainingPercent = "current_weekly_remaining_percent" - case currentWeeklyStatus = "current_weekly_status" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) - self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) - self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) - self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) - self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) - self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) - self.currentIntervalRemainingPercent = MiniMaxDecoding.decodeDouble( - container, - forKey: .currentIntervalRemainingPercent) - self.currentIntervalStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalStatus) - self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) - self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) - self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) - self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) - self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) - self.currentWeeklyRemainingPercent = MiniMaxDecoding.decodeDouble( - container, - forKey: .currentWeeklyRemainingPercent) - self.currentWeeklyStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyStatus) - } -} - struct MiniMaxBaseResponse: Decodable { let statusCode: Int? let statusMessage: String? @@ -936,7 +877,8 @@ enum MiniMaxUsageParser { status: item.currentIntervalStatus, start: item.startTime, end: item.endTime, - remainsTime: item.remainsTime), + remainsTime: item.remainsTime, + boostPermille: item.intervalBoostPermille), now: now) { services.append(intervalService) @@ -954,7 +896,8 @@ enum MiniMaxUsageParser { status: item.currentWeeklyStatus, start: item.weeklyStartTime, end: item.weeklyEndTime, - remainsTime: item.weeklyRemainsTime), + remainsTime: item.weeklyRemainsTime, + boostPermille: item.weeklyBoostPermille), now: now) { services.append(weeklyService) @@ -1059,11 +1002,17 @@ enum MiniMaxUsageParser { let hasTextGeneration = data.modelRemains.contains { $0.modelName.map(self.isTextGenerationModelName) ?? false } let hasUnavailableVideo = data.modelRemains.contains { item in item.modelName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "video" && - self.isUnavailableQuotaPlaceholder( + self.isUnavailableQuotaPlaceholder(ServiceUsageInput( + serviceType: "Text to Video", + windowTypeOverride: nil, total: item.currentIntervalTotalCount, remaining: item.currentIntervalUsageCount, remainingPercent: item.currentIntervalRemainingPercent, - status: item.currentIntervalStatus) + status: item.currentIntervalStatus, + start: nil, + end: nil, + remainsTime: nil, + boostPermille: nil)) } return hasTextGeneration && hasUnavailableVideo ? "Plus" : nil } @@ -1557,6 +1506,7 @@ enum MiniMaxUsageParser { let start: Int? let end: Int? let remainsTime: Int? + let boostPermille: Int? } private static func makeServiceUsage(_ input: ServiceUsageInput, now: Date) -> MiniMaxServiceUsage? { @@ -1572,19 +1522,28 @@ enum MiniMaxUsageParser { timeRange = weeklyRange } - let resetsAt = self.resetsAt(end: endTime, remains: input.remainsTime, now: now) - let resetDescription = self.resetDescription( - for: windowType, - timeRange: timeRange, - now: now, - resetsAt: resetsAt) - + let isUnlimited = self.isUnlimitedQuotaWindow(input, windowType: windowType) + let resetsAt = isUnlimited ? nil : self.resetsAt(end: endTime, remains: input.remainsTime, now: now) + let resetDescription = if isUnlimited { + "Unlimited" + } else { + self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + } let limit: Int let usage: Int let percent: Double - if let remainingPercent = input.remainingPercent { - percent = self.usedPercent(remainingPercent: remainingPercent) - limit = 100 + if isUnlimited { + percent = 0 + limit = 0 + usage = 0 + } else if let remainingPercent = input.remainingPercent { + let quotaLimit = self.percentQuotaLimit(boostPermille: input.boostPermille) + percent = self.usedPercent(remainingPercent: remainingPercent) * (Double(quotaLimit) / 100.0) + limit = quotaLimit usage = Int(percent.rounded()) } else { guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } @@ -1601,40 +1560,51 @@ enum MiniMaxUsageParser { usage: usage, limit: limit, percent: min(100.0, max(0.0, percent)), + isUnlimited: isUnlimited, resetsAt: resetsAt, resetDescription: resetDescription) } + private static func percentQuotaLimit(boostPermille: Int?) -> Int { + guard let boostPermille, boostPermille > 0 else { return 100 } + return max(1, Int((Double(boostPermille) / 10.0).rounded())) + } + private static func shouldRenderQuotaWindow(_ input: ServiceUsageInput) -> Bool { // MiniMax Token Plan returns status 3 for quota lanes that exist in the schema but are not included in // the current subscription, for example Plus accounts receiving a video lane with 100% remaining and 0 count. - !self.isUnavailableQuotaPlaceholder( - total: input.total, - remaining: input.remaining, - remainingPercent: input.remainingPercent, - status: input.status) - } - - private static func isUnavailableQuotaPlaceholder( - total: Int?, - remaining: Int?, - remainingPercent: Double?, - status: Int?) - -> Bool - { - status == 3 && (total ?? 0) == 0 && (remaining ?? 0) == 0 && (remainingPercent.map { $0 >= 100 } ?? false) + !self.isUnavailableQuotaPlaceholder(input) } - private static func mapModelNameToServiceType(modelName: String) -> String { - // Text Generation (文本生成): M2.7, M2.7-highspeed, MiniMax-M*, etc. - if self.isTextGenerationModelName(modelName) { - return "Text Generation" + private static func isUnavailableQuotaPlaceholder(_ input: ServiceUsageInput) -> Bool { + if let windowType = input.windowTypeOverride, self.isUnlimitedQuotaWindow(input, windowType: windowType) { + return false } + return input.status == 3 && + (input.total ?? 0) == 0 && + (input.remaining ?? 0) == 0 && + (input.remainingPercent.map { $0 >= 100 } ?? false) + } - let lower = modelName.lowercased() + private static func isUnlimitedQuotaWindow(_ input: ServiceUsageInput, windowType: String) -> Bool { + let normalizedService = input.serviceType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedWindow = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let unlimitedServices = ["text generation", "general"] + return input.status == 3 && + unlimitedServices.contains(normalizedService) && + normalizedWindow == "weekly" && + (input.remainingPercent.map { $0 >= 100 } ?? false) + } - if lower == "video" { - return "Text to Video" + private static func mapModelNameToServiceType(modelName: String) -> String { + let lower = modelName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if lower == "general" || lower == "video" { + return lower + } + + // Legacy text model names are separate from Token Plan's `general` bucket. + if self.isTextGenerationModelName(modelName) { + return "Text Generation" } // Text to Speech (语音合成): speech-hd, Speech 2.8, etc. diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 5c96b374a..587a7cf07 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -113,7 +113,7 @@ struct MiniMaxMenuCardModelPlanTests { updatedAt: now, services: [ MiniMaxServiceUsage( - serviceType: "text-generation", + serviceType: "general", windowType: "5 hours", timeRange: "15:00-20:00(UTC+8)", usage: 31, @@ -122,7 +122,7 @@ struct MiniMaxMenuCardModelPlanTests { resetsAt: now.addingTimeInterval(3600), resetDescription: "Resets in 1 hour"), MiniMaxServiceUsage( - serviceType: "text-generation", + serviceType: "general", windowType: "Weekly", timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", usage: 4, @@ -155,4 +155,66 @@ struct MiniMaxMenuCardModelPlanTests { #expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]]) } + + @Test + func `minimax unlimited quota rows omit usage copy and warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 2, + limit: 200, + percent: 2, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 0, + limit: 0, + percent: 0, + isUnlimited: true, + resetsAt: nil, + resetDescription: "Unlimited"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics[1].title == "General · Weekly") + #expect(model.metrics[1].statusText == "∞ Unlimited") + #expect(model.metrics[1].detailLeftText == nil) + #expect(model.metrics[1].warningMarkerPercents == []) + } } diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index c5738bb4e..52b29aa57 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -18,7 +18,8 @@ struct MiniMaxTokenPlanChangeTests { #expect(snapshot.remainingPrompts == nil) #expect(snapshot.usedPercent == 4) #expect(services.count == 2) - #expect(services[0].displayName == "Text Generation") + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") #expect(services[0].windowType == "5 hours") #expect(services[0].usage == 4) #expect(services[0].limit == 100) @@ -121,12 +122,82 @@ struct MiniMaxTokenPlanChangeTests { #expect(snapshot.planName == "Plus") #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "Plus") - #expect(services.map(\.displayName) == ["Text Generation", "Text Generation"]) + #expect(services.map(\.serviceType) == ["general", "general"]) + #expect(services.map(\.displayName) == ["General", "General"]) #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) #expect(snapshot.toUsageSnapshot().tertiary == nil) } + @Test + func `plus token plan renders boosted interval and unlimited weekly lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_347_620) + let json = """ + { + "model_remains": [ + { + "start_time": 1780347600000, + "end_time": 1780365600000, + "remains_time": 4650822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 1, + "current_interval_remaining_percent": 99, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100, + "interval_boost_permill": 2000, + "weekly_boost_permill": 2000 + }, + { + "start_time": 1780329600000, + "end_time": 1780416000000, + "remains_time": 55050822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 2) + #expect(services[0].limit == 200) + #expect(services[0].percent == 2) + #expect(services[0].isUnlimited == false) + #expect(services[1].serviceType == "general") + #expect(services[1].displayName == "General") + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 0) + #expect(services[1].limit == 0) + #expect(services[1].percent == 0) + #expect(services[1].isUnlimited) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 2) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "Unlimited") + } + @Test func `web usage fetch falls back to www remains host after platform parse failure`() async throws { let now = Date(timeIntervalSince1970: 1_780_282_340) From bfff3dfe494a0c77452ad37e44d674f29cecf617 Mon Sep 17 00:00:00 2001 From: XWind Date: Wed, 3 Jun 2026 14:31:11 +0800 Subject: [PATCH 17/93] Preserve MiniMax API token fallback Try the token-plan remains endpoint first, then fall back to the legacy coding-plan remains endpoint for compatible API-token failures. Cover the new fallback path and the updated global/China retry request order. --- .../MiniMax/MiniMaxUsageFetcher.swift | 41 +++++++++++++++++- .../MiniMaxAPITokenFetchTests.swift | 26 +++++++++--- .../MiniMaxTokenPlanChangeTests.swift | 42 ++++++++++++++++++- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index b479d48e5..3628ae636 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -119,7 +119,35 @@ public struct MiniMaxUsageFetcher: Sendable { now: Date, transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot { - var request = URLRequest(url: region.tokenPlanRemainsURL) + var lastError: Error? + for remainsURL in [region.tokenPlanRemainsURL, region.apiRemainsURL] { + do { + return try await self.fetchAPIUsageOnce( + apiToken: apiToken, + remainsURL: remainsURL, + now: now, + transport: transport) + } catch let error as MiniMaxUsageError { + lastError = error + guard remainsURL == region.tokenPlanRemainsURL, + self.shouldTryLegacyAPIEndpoint(after: error) + else { + throw error + } + Self.log.debug("MiniMax token-plan API failed, trying legacy coding-plan endpoint") + } + } + if let lastError { throw lastError } + throw MiniMaxUsageError.parseFailed("Missing MiniMax API remains URL.") + } + + private static func fetchAPIUsageOnce( + apiToken: String, + remainsURL: URL, + now: Date, + transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot + { + var request = URLRequest(url: remainsURL) request.httpMethod = "GET" request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "accept") @@ -151,6 +179,17 @@ public struct MiniMaxUsageFetcher: Sendable { return snapshot } + private static func shouldTryLegacyAPIEndpoint(after error: MiniMaxUsageError) -> Bool { + switch error { + case .invalidCredentials: + true + case let .apiError(message): + message.contains("HTTP 404") || message.contains("HTTP 405") + case .networkError, .parseFailed: + true + } + } + private static func fetchCodingPlanHTML( context: WebFetchContext, now: Date) async throws -> MiniMaxUsageSnapshot diff --git a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift index f1a69d37b..ac5f3ccb6 100644 --- a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift +++ b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift @@ -49,9 +49,16 @@ struct MiniMaxAPITokenFetchTests { session: Self.makeSession()) #expect(snapshot.planName == "Max") - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + ]) } @Test @@ -83,9 +90,16 @@ struct MiniMaxAPITokenFetchTests { session: Self.makeSession()) } - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + ]) } @Test diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index 52b29aa57..b42e7d878 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -256,10 +256,42 @@ struct MiniMaxTokenPlanChangeTests { } @Test - func `api token fetch rejects official endpoint auth failure for fallback`() async throws { + func `api token fetch falls back to legacy coding plan endpoint after official auth failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) let transport = ProviderHTTPTransportStub { request in let url = try #require(request.url) - #expect(url.path == "/v1/token_plan/remains") + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch rejects after official and legacy endpoint auth failures`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect([ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ].contains(url.path)) return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") } @@ -269,6 +301,12 @@ struct MiniMaxTokenPlanChangeTests { region: .chinaMainland, session: transport) } + let requests = await transport.requests() + + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) } @Test From df74854492f5f700c5294ec19dae73bb69df8a24 Mon Sep 17 00:00:00 2001 From: XWind Date: Thu, 4 Jun 2026 09:04:00 +0800 Subject: [PATCH 18/93] Surface constrained MiniMax menu metric Use the most constrained MiniMax quota window for automatic menu-bar metrics so a near-exhausted weekly lane is not hidden behind a low 5h lane. --- .../CodexBar/MenuBarMetricWindowResolver.swift | 2 +- .../MenuBarMetricWindowResolverTests.swift | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 520878d4d..bf603407d 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -104,7 +104,7 @@ enum MenuBarMetricWindowResolver { { return primary.usedPercent >= secondary.usedPercent ? primary : secondary } - if provider == .cursor { + if provider == .cursor || provider == .minimax { return Self.mostConstrainedWindow( primary: snapshot.primary, secondary: snapshot.secondary, diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 267f3098b..4047bbaef 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -21,6 +21,23 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.usedPercent == 92) } + @Test + func `automatic metric uses minimax weekly token lane when it is most constrained`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 97, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .minimax, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 97) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + @Test func `extra usage metric maps provider cost into a menu bar window`() { let snapshot = UsageSnapshot( From e4ea2bfd1bb98587f6fae2848bfc268f3bca902b Mon Sep 17 00:00:00 2001 From: XWind Date: Thu, 4 Jun 2026 14:17:05 +0800 Subject: [PATCH 19/93] Stabilize rapid switcher rebuild test --- .../StatusMenuOpenRefreshTests.swift | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index cf31c6025..9695f90e9 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -741,7 +741,7 @@ extension StatusMenuTests { let menuKey = ObjectIdentifier(menu) controller.openMenus[menuKey] = menu controller.menuRefreshEnabledOverrideForTesting = true - controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 50_000_000 + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 0 defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } var rebuildCount = 0 @@ -749,18 +749,50 @@ extension StatusMenuTests { rebuildCount += 1 } defer { controller._test_openMenuRebuildObserver = nil } + var refreshGateEntries = 0 + var pendingRefreshGates: [CheckedContinuation] = [] + func resumePendingRefreshGates() { + let gates = pendingRefreshGates + pendingRefreshGates.removeAll(keepingCapacity: true) + for gate in gates { + gate.resume() + } + } + controller._test_openMenuRefreshYieldOverride = { + refreshGateEntries += 1 + await withCheckedContinuation { continuation in + pendingRefreshGates.append(continuation) + } + } + defer { + resumePendingRefreshGates() + controller._test_openMenuRefreshYieldOverride = nil + } controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) - try? await Task.sleep(nanoseconds: 10_000_000) + for _ in 0..<20 where refreshGateEntries == 0 { + await Task.yield() + } + #expect(refreshGateEntries == 1) + #expect(rebuildCount == 0) + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + resumePendingRefreshGates() + for _ in 0..<20 where refreshGateEntries < 2 { + await Task.yield() + } + #expect(refreshGateEntries == 2) + #expect(rebuildCount == 0) + resumePendingRefreshGates() - for _ in 0..<40 where rebuildCount == 0 { + for _ in 0..<20 where rebuildCount == 0 { await Task.yield() - try? await Task.sleep(nanoseconds: 5_000_000) } #expect(rebuildCount == 1) - try? await Task.sleep(nanoseconds: 75_000_000) + for _ in 0..<20 { + await Task.yield() + } #expect(rebuildCount == 1) } From 20d2ebe991c1a4bcfa1cb3446038bfb43e449615 Mon Sep 17 00:00:00 2001 From: XWind Date: Thu, 4 Jun 2026 14:54:46 +0800 Subject: [PATCH 20/93] Fix MiniMax boosted quota percent --- .../CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift | 4 ++-- Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 3628ae636..3b0cee63e 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -1581,9 +1581,9 @@ enum MiniMaxUsageParser { usage = 0 } else if let remainingPercent = input.remainingPercent { let quotaLimit = self.percentQuotaLimit(boostPermille: input.boostPermille) - percent = self.usedPercent(remainingPercent: remainingPercent) * (Double(quotaLimit) / 100.0) + percent = self.usedPercent(remainingPercent: remainingPercent) limit = quotaLimit - usage = Int(percent.rounded()) + usage = Int((percent * Double(quotaLimit) / 100.0).rounded()) } else { guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } let used = max(0, total - remaining) diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index b42e7d878..aba7461b8 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -185,7 +185,7 @@ struct MiniMaxTokenPlanChangeTests { #expect(services[0].windowType == "5 hours") #expect(services[0].usage == 2) #expect(services[0].limit == 200) - #expect(services[0].percent == 2) + #expect(services[0].percent == 1) #expect(services[0].isUnlimited == false) #expect(services[1].serviceType == "general") #expect(services[1].displayName == "General") @@ -194,7 +194,7 @@ struct MiniMaxTokenPlanChangeTests { #expect(services[1].limit == 0) #expect(services[1].percent == 0) #expect(services[1].isUnlimited) - #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 2) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 1) #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "Unlimited") } From bd0bbc052245aa676f15968e84801ce2e8c1f57b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 12:40:23 -0700 Subject: [PATCH 21/93] fix: Preserve subscription metadata in snapshot copies --- Sources/CodexBarCore/UsageFetcher.swift | 5 +++++ Tests/CodexBarTests/ResetTimeBackfillTests.swift | 4 ++++ .../TokenAccountEnvironmentPrecedenceTests.swift | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index b9fa12814..d3b335338 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -322,6 +322,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: self.mistralUsage, deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -347,6 +349,7 @@ public struct UsageSnapshot: Codable, Sendable { secondary: secondary, tertiary: tertiary, extraRateWindows: self.extraRateWindows, + kiroUsage: self.kiroUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, @@ -357,6 +360,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: self.mistralUsage, deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: self.identity) } diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift index 7cdbf152f..4f269a093 100644 --- a/Tests/CodexBarTests/ResetTimeBackfillTests.swift +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -76,6 +76,8 @@ final class ResetTimeBackfillTests: XCTestCase { secondary: nil, extraRateWindows: [extra], cursorRequests: CursorRequestUsage(used: 10, limit: 50), + subscriptionExpiresAt: reset.addingTimeInterval(86_400), + subscriptionRenewsAt: reset.addingTimeInterval(43_200), updatedAt: now, identity: identity) @@ -87,6 +89,8 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertEqual(result.extraRateWindows?.first?.id, "overflow") XCTAssertEqual(result.extraRateWindows?.first?.window.nextRegenPercent, 2) XCTAssertEqual(result.cursorRequests?.used, 10) + XCTAssertEqual(result.subscriptionExpiresAt, reset.addingTimeInterval(86_400)) + XCTAssertEqual(result.subscriptionRenewsAt, reset.addingTimeInterval(43_200)) XCTAssertEqual(result.identity?.accountEmail, "peter@example.com") } diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 14dfe6392..46786321f 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -974,6 +974,8 @@ extension TokenAccountEnvironmentPrecedenceTests { rateLimit: nil, updatedAt: now), cursorRequests: CursorRequestUsage(used: 7, limit: 70), + subscriptionExpiresAt: reset.addingTimeInterval(86_400), + subscriptionRenewsAt: reset.addingTimeInterval(43_200), updatedAt: now, identity: identity) } @@ -993,6 +995,8 @@ extension TokenAccountEnvironmentPrecedenceTests { #expect(after.openRouterUsage?.rateLimit?.requests == before.openRouterUsage?.rateLimit?.requests) #expect(after.cursorRequests?.used == before.cursorRequests?.used) #expect(after.cursorRequests?.limit == before.cursorRequests?.limit) + #expect(after.subscriptionExpiresAt == before.subscriptionExpiresAt) + #expect(after.subscriptionRenewsAt == before.subscriptionRenewsAt) #expect(after.updatedAt == before.updatedAt) } } From 7b2487b0debb8a5a5b57de9445e86c5918183db6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 12:45:02 -0700 Subject: [PATCH 22/93] fix: Preserve MiniMax API fallback on parse errors --- .../MiniMax/MiniMaxUsageFetcher.swift | 9 +++++- .../MiniMaxTokenPlanChangeTests.swift | 28 +++++++++++++++++++ .../ResetTimeBackfillTests.swift | 8 +++--- ...kenAccountEnvironmentPrecedenceTests.swift | 4 +-- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 3b0cee63e..8fef84265 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -172,7 +172,14 @@ public struct MiniMaxUsageFetcher: Sendable { throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index aba7461b8..78b35bc65 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -283,6 +283,34 @@ struct MiniMaxTokenPlanChangeTests { ]) } + @Test + func `api token fetch falls back to legacy coding plan endpoint after official parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + @Test func `api token fetch rejects after official and legacy endpoint auth failures`() async throws { let transport = ProviderHTTPTransportStub { request in diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift index 4f269a093..559bb1928 100644 --- a/Tests/CodexBarTests/ResetTimeBackfillTests.swift +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -76,8 +76,8 @@ final class ResetTimeBackfillTests: XCTestCase { secondary: nil, extraRateWindows: [extra], cursorRequests: CursorRequestUsage(used: 10, limit: 50), - subscriptionExpiresAt: reset.addingTimeInterval(86_400), - subscriptionRenewsAt: reset.addingTimeInterval(43_200), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) @@ -89,8 +89,8 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertEqual(result.extraRateWindows?.first?.id, "overflow") XCTAssertEqual(result.extraRateWindows?.first?.window.nextRegenPercent, 2) XCTAssertEqual(result.cursorRequests?.used, 10) - XCTAssertEqual(result.subscriptionExpiresAt, reset.addingTimeInterval(86_400)) - XCTAssertEqual(result.subscriptionRenewsAt, reset.addingTimeInterval(43_200)) + XCTAssertEqual(result.subscriptionExpiresAt, reset.addingTimeInterval(86400)) + XCTAssertEqual(result.subscriptionRenewsAt, reset.addingTimeInterval(43200)) XCTAssertEqual(result.identity?.accountEmail, "peter@example.com") } diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 46786321f..148ef2424 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -974,8 +974,8 @@ extension TokenAccountEnvironmentPrecedenceTests { rateLimit: nil, updatedAt: now), cursorRequests: CursorRequestUsage(used: 7, limit: 70), - subscriptionExpiresAt: reset.addingTimeInterval(86_400), - subscriptionRenewsAt: reset.addingTimeInterval(43_200), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) } From fb5dcf85b2eb03b913d989b43007b02a1b3c6e1c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 12:49:17 -0700 Subject: [PATCH 23/93] fix: Preserve MiniMax web auth errors --- .../Providers/MiniMax/MiniMaxDecoding.swift | 46 ++++++++++++++++ .../MiniMax/MiniMaxUsageFetcher.swift | 49 ++--------------- .../MiniMaxTokenPlanChangeTests.swift | 55 +++++++++++++++++++ 3 files changed, 105 insertions(+), 45 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift new file mode 100644 index 000000000..6ad41bb9e --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift @@ -0,0 +1,46 @@ +import Foundation + +enum MiniMaxDecoding { + static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return Int(value) + } + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(trimmed) + } + return nil + } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: K) -> Double? { + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) + } + return nil + } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKeys keys: [K]) -> Double? { + for key in keys { + if let value = self.decodeDouble(container, forKey: key) { + return value + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 8fef84265..bf86fe082 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -245,6 +245,8 @@ public struct MiniMaxUsageFetcher: Sendable { let snapshot: MiniMaxUsageSnapshot do { snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error } catch { throw MiniMaxUsageError.parseFailed(error.localizedDescription) } @@ -338,6 +340,8 @@ public struct MiniMaxUsageFetcher: Sendable { let snapshot: MiniMaxUsageSnapshot do { snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error } catch { throw MiniMaxUsageError.parseFailed(error.localizedDescription) } @@ -787,51 +791,6 @@ struct MiniMaxServiceItem: Decodable { } } -enum MiniMaxDecoding { - static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { - if let value = try? container.decodeIfPresent(Int.self, forKey: key) { - return value - } - if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { - return Int(value) - } - if let value = try? container.decodeIfPresent(Double.self, forKey: key) { - return Int(value) - } - if let value = try? container.decodeIfPresent(String.self, forKey: key) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Int(trimmed) - } - return nil - } - - static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: K) -> Double? { - if let value = try? container.decodeIfPresent(Double.self, forKey: key) { - return value - } - if let value = try? container.decodeIfPresent(Int.self, forKey: key) { - return Double(value) - } - if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { - return Double(value) - } - if let value = try? container.decodeIfPresent(String.self, forKey: key) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Double(trimmed) - } - return nil - } - - static func decodeDouble(_ container: KeyedDecodingContainer, forKeys keys: [K]) -> Double? { - for key in keys { - if let value = self.decodeDouble(container, forKey: key) { - return value - } - } - return nil - } -} - enum MiniMaxUsageParser { static func decodePayload(data: Data) throws -> MiniMaxCodingPlanPayload { let decoder = JSONDecoder() diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index 78b35bc65..005d018db 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -235,6 +235,61 @@ struct MiniMaxTokenPlanChangeTests { }) } + @Test + func `web usage fetch preserves coding plan json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path.contains("coding-plan")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.count == 1) + } + + @Test + func `web usage fetch preserves remains json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + #expect(url.path.contains("coding_plan/remains")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + @Test func `api token fetch uses official token plan remains endpoint`() async throws { let now = Date(timeIntervalSince1970: 1_780_282_340) From dfb99128a8c152bdb25e2da98d317d1e5e4a894b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 12:52:22 -0700 Subject: [PATCH 24/93] fix: Format MiniMax subscription dates in provider timezone --- Sources/CodexBar/MenuCardView.swift | 21 ++++++++++++++------ Tests/CodexBarTests/MenuCardModelTests.swift | 2 +- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 2ca066446..3b4106b8e 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -845,7 +845,7 @@ extension UsageMenuCardView.Model { } private static func usageNotes(input: Input) -> [String] { - let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot) + let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot, provider: input.provider) if input.provider == .kiro { return kiroUsageNotes(input: input) + subscriptionNotes @@ -894,25 +894,34 @@ extension UsageMenuCardView.Model { return notes + subscriptionNotes } - private static func subscriptionMetadataNotes(snapshot: UsageSnapshot?) -> [String] { + private static func subscriptionMetadataNotes(snapshot: UsageSnapshot?, provider: UsageProvider) -> [String] { guard let snapshot else { return [] } if let renewsAt = snapshot.subscriptionRenewsAt { - return [String(format: L("Renews: %@"), self.subscriptionDateString(renewsAt))] + return [String(format: L("Renews: %@"), self.subscriptionDateString(renewsAt, provider: provider))] } if let expiresAt = snapshot.subscriptionExpiresAt { - return [String(format: L("Plan expires: %@"), self.subscriptionDateString(expiresAt))] + return [String(format: L("Plan expires: %@"), self.subscriptionDateString(expiresAt, provider: provider))] } return [] } - private static func subscriptionDateString(_ date: Date) -> String { + private static func subscriptionDateString(_ date: Date, provider: UsageProvider) -> String { let formatter = DateFormatter() formatter.locale = Locale.current - formatter.timeZone = .current + formatter.timeZone = self.subscriptionDateTimeZone(provider: provider) formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") return formatter.string(from: date) } + private static func subscriptionDateTimeZone(provider: UsageProvider) -> TimeZone { + switch provider { + case .minimax: + TimeZone(identifier: "Asia/Shanghai") ?? .current + default: + .current + } + } + private static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { var parts: [String] = [] if let daily = usage.keyUsageDaily { diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 0fcd782fb..584266351 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -732,7 +732,7 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[1].cardStyle == false) #expect(model.providerCost?.title == "Credits") #expect(model.providerCost?.spendLine == "Balance: 14000") - #expect(model.usageNotes.count == 1 && model.usageNotes[0].hasPrefix("Renews: ")) + #expect(model.usageNotes == ["Renews: May 18, 2027"]) } } From d6e225af364a554c08153a392c832d5b8e43758b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 12:58:37 -0700 Subject: [PATCH 25/93] fix: Preserve MiniMax token-plan fallbacks --- .../MiniMax/MiniMaxServiceUsage.swift | 5 + .../MiniMax/MiniMaxUsageFetcher.swift | 28 +++-- .../MiniMax/MiniMaxUsageSnapshot.swift | 60 ++++++---- .../MiniMaxTokenPlanChangeTests.swift | 107 ++++++++++++++++++ 4 files changed, 169 insertions(+), 31 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift index a7359c483..71101e8ca 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift @@ -87,6 +87,11 @@ public struct MiniMaxServiceUsage: Sendable { } } + public var isPrimaryTextQuotaLane: Bool { + let normalized = self.serviceType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "general" || self.displayName == "Text Generation" + } + /// Creates a new MiniMaxServiceUsage instance. /// /// - Parameters: diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index bf86fe082..3f67e9a5b 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -157,10 +157,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -224,10 +222,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await context.transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -319,10 +315,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await context.transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -369,6 +363,22 @@ public struct MiniMaxUsageFetcher: Sendable { } } + private static func normalizedTransportError(_ error: Error) -> Error { + if error is MiniMaxUsageError || error is CancellationError { + return error + } + if let urlError = error as? URLError { + if urlError.code == .cancelled { + return error + } + if urlError.code == .badServerResponse { + return MiniMaxUsageError.networkError("Invalid response") + } + return MiniMaxUsageError.networkError(urlError.localizedDescription) + } + return error + } + private static func attachingBillingIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 65aa13111..794114533 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -16,38 +16,54 @@ public struct MiniMaxUsageSnapshot: Sendable { public let subscriptionRenewsAt: Date? public var primaryService: MiniMaxServiceUsage? { - // Priority: "Text Generation" > first service - if let services = self.services, !services.isEmpty { - if let textGenService = services.first(where: { $0.displayName == "Text Generation" }) { - return textGenService - } - return services.first - } - return nil + self.orderedQuotaServices.first } public var secondaryService: MiniMaxServiceUsage? { - // Return second service for RateWindow.secondary if exists - guard let services = self.services, services.count >= 2 else { return nil } - // If we have Text Generation as primary, get the next non-Text Generation service - if let textGenIndex = services.firstIndex(where: { $0.displayName == "Text Generation" }) { - // If Text Generation is first, secondary is second - if textGenIndex == 0 { - return services[1] - } - // If Text Generation is not first, secondary could be first or second depending on count - return services[0] - } - // No Text Generation found, just return second service + let services = self.orderedQuotaServices + guard services.count >= 2 else { return nil } return services[1] } public var tertiaryService: MiniMaxServiceUsage? { - // Return third service for RateWindow.tertiary if exists - guard let services = self.services, services.count >= 3 else { return nil } + let services = self.orderedQuotaServices + guard services.count >= 3 else { return nil } return services[2] } + private var orderedQuotaServices: [MiniMaxServiceUsage] { + guard let services, !services.isEmpty else { return [] } + return services.enumerated().sorted { lhs, rhs in + let lhsRank = self.quotaServiceRank(lhs.element, originalIndex: lhs.offset) + let rhsRank = self.quotaServiceRank(rhs.element, originalIndex: rhs.offset) + if lhsRank.primary != rhsRank.primary { + return lhsRank.primary < rhsRank.primary + } + if lhsRank.window != rhsRank.window { + return lhsRank.window < rhsRank.window + } + return lhsRank.originalIndex < rhsRank.originalIndex + }.map(\.element) + } + + private func quotaServiceRank( + _ service: MiniMaxServiceUsage, + originalIndex: Int) -> (primary: Int, window: Int, originalIndex: Int) + { + ( + primary: service.isPrimaryTextQuotaLane ? 0 : 1, + window: self.quotaWindowRank(service), + originalIndex: originalIndex) + } + + private func quotaWindowRank(_ service: MiniMaxServiceUsage) -> Int { + let window = service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if window == "weekly" { + return 1 + } + return 0 + } + public init( planName: String?, availablePrompts: Int?, diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index 005d018db..c8b3229c9 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -72,6 +72,49 @@ struct MiniMaxTokenPlanChangeTests { #expect(snapshot.toUsageSnapshot().providerCost?.used == 14000) } + @Test + func `video first token plan still uses general quota as primary and weekly secondary`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "model_remains": [ + { + "model_name": "video", + "current_interval_total_count": 100, + "current_interval_usage_count": 70, + "current_interval_remaining_percent": 30, + "start_time": 1780243200000, + "end_time": 1780329600000 + }, + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 96, + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": 99, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.services?.map(\.serviceType) == ["video", "general", "general"]) + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 70) + } + @Test func `plus token plan omits unavailable video quota lane`() throws { let now = Date(timeIntervalSince1970: 1_780_282_340) @@ -235,6 +278,42 @@ struct MiniMaxTokenPlanChangeTests { }) } + @Test + func `web usage fetch falls back to www remains host after platform transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + throw URLError(.timedOut) + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "platform.minimaxi.com", + "platform.minimaxi.com", + "www.minimaxi.com", + ]) + } + @Test func `web usage fetch preserves coding plan json auth failure`() async throws { let transport = ProviderHTTPTransportStub { request in @@ -366,6 +445,34 @@ struct MiniMaxTokenPlanChangeTests { ]) } + @Test + func `api token fetch falls back to legacy coding plan endpoint after official transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + throw URLError(.timedOut) + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + @Test func `api token fetch rejects after official and legacy endpoint auth failures`() async throws { let transport = ProviderHTTPTransportStub { request in From 26a53f20d87e85acc4c77478d504108e53af55e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 13:02:10 -0700 Subject: [PATCH 26/93] fix: Guard MiniMax metadata host overrides --- .../MiniMax/MiniMaxSubscriptionMetadata.swift | 11 +++++++-- .../MiniMaxTokenPlanChangeTests.swift | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift index 7cf3546a5..56a2d45c0 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -60,7 +60,11 @@ enum MiniMaxSubscriptionMetadataFetcher { static func resolveComboURL(region: MiniMaxAPIRegion, environment: [String: String]) throws -> URL { let host = MiniMaxSettingsReader.hostOverride(environment: environment) ?? self.defaultWebHost(region: region) - var components = URLComponents(string: host.hasPrefix("http") ? host : "https://\(host)")! + guard var components = URLComponents(string: host.hasPrefix("http") ? host : "https://\(host)"), + components.host?.isEmpty == false + else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host is invalid.") + } guard components.scheme?.lowercased() == "https" else { throw MiniMaxUsageError.apiError("MiniMax combo metadata host must use HTTPS.") } @@ -70,7 +74,10 @@ enum MiniMaxSubscriptionMetadataFetcher { URLQueryItem(name: "cycle_type", value: "3"), URLQueryItem(name: "resource_package_type", value: "7"), ] - return components.url! + guard let url = components.url else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host is invalid.") + } + return url } private static func validateBaseResponse(in object: Any) throws { diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index c8b3229c9..899d60c0b 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -594,6 +594,29 @@ struct MiniMaxTokenPlanChangeTests { #expect(requests.isEmpty) } + @Test + func `combo metadata rejects malformed host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.self) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "bad host"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + @Test func `web usage fetch preserves combo metadata cancellation`() async throws { let now = Date(timeIntervalSince1970: 1_780_282_340) From 09afa2ac73b07feace5b5c4d7fe2ead48f91504f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 13:07:41 -0700 Subject: [PATCH 27/93] fix: Order MiniMax menu quota rows --- Sources/CodexBar/MenuCardView.swift | 3 +- .../MiniMax/MiniMaxUsageSnapshot.swift | 2 +- .../MiniMaxMenuCardModelPlanTests.swift | 66 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3b4106b8e..4d345bf6e 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1105,7 +1105,8 @@ extension UsageMenuCardView.Model { } if input.provider == .minimax { if let minimaxUsage = snapshot.minimaxUsage { - if let services = minimaxUsage.services, !services.isEmpty { + let services = minimaxUsage.orderedQuotaServices + if !services.isEmpty { return Self.minimaxMetrics(services: services, input: input) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 794114533..fe73270d0 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -31,7 +31,7 @@ public struct MiniMaxUsageSnapshot: Sendable { return services[2] } - private var orderedQuotaServices: [MiniMaxServiceUsage] { + public var orderedQuotaServices: [MiniMaxServiceUsage] { guard let services, !services.isEmpty else { return [] } return services.enumerated().sorted { lhs, rhs in let lhsRank = self.quotaServiceRank(lhs.element, originalIndex: lhs.offset) diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 587a7cf07..23740cb50 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -156,6 +156,72 @@ struct MiniMaxMenuCardModelPlanTests { #expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]]) } + @Test + func `minimax quota rows use canonical general first order`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanMax-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "video", + windowType: "Today", + timeRange: "06/01 00:00 - 06/02 00:00(UTC+8)", + usage: 70, + limit: 100, + percent: 70, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["General · 5h", "General · Weekly", "Video"]) + #expect(model.metrics.map(\.percent) == [4, 1, 70]) + } + @Test func `minimax unlimited quota rows omit usage copy and warning markers`() throws { let now = Date() From b7d36fbb58ada03d93bd1157e2303edaf026fee7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Jun 2026 15:02:25 -0700 Subject: [PATCH 28/93] test: update MiniMax china fallback expectation --- Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift index ac5f3ccb6..4027ca11d 100644 --- a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift +++ b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift @@ -94,11 +94,13 @@ struct MiniMaxAPITokenFetchTests { "api.minimax.io", "api.minimax.io", "api.minimaxi.com", + "api.minimaxi.com", ]) #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ "/v1/token_plan/remains", "/v1/api/openplatform/coding_plan/remains", "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", ]) } From d3151b719e3228f9ec72421a1e4086d7cf7fd22e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:50:00 +0800 Subject: [PATCH 29/93] Add native French localization support Adds French localization resources, registers French in the language picker, and includes the maintainer changelog entry. Thanks @Yuxin-Qiao! --- CHANGELOG.md | 3 + Sources/CodexBar/PreferencesGeneralPane.swift | 2 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1063 +++++++++++++++++ .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + 10 files changed, 1075 insertions(+) create mode 100644 Sources/CodexBar/Resources/fr.lproj/Localizable.strings diff --git a/CHANGELOG.md b/CHANGELOG.md index edd747a84..2b021e923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.5 — Unreleased +### Added +- Localization: add French as a selectable app language (#1241). Thanks @Yuxin-Qiao! + ### Fixed - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! - Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 181b49633..4c943a571 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -11,6 +11,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case chineseTraditional = "zh-Hant" case portugueseBrazilian = "pt-BR" case swedish = "sv" + case french = "fr" var id: String { self.rawValue @@ -26,6 +27,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .chineseTraditional: L("language_chinese_traditional") case .portugueseBrazilian: L("language_portuguese_brazilian") case .swedish: L("language_swedish") + case .french: L("language_french") } } } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 6c7a760a6..319e8846b 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_french" = "Francès"; "start_at_login_title" = "Obrir en iniciar la sessió"; "start_at_login_subtitle" = "Obre el CodexBar automàticament en iniciar el Mac."; "show_cost_summary" = "Mostra el resum de cost"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 6030d94f2..6a7e1bf99 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -400,6 +400,7 @@ "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "language_swedish" = "Svenska"; +"language_french" = "French"; "start_at_login_title" = "Start at Login"; "start_at_login_subtitle" = "Automatically opens CodexBar when you start your Mac."; "show_cost_summary" = "Show cost summary"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 293183e74..19728fb23 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_french" = "Francés"; "start_at_login_title" = "Abrir al iniciar sesión"; "start_at_login_subtitle" = "Abre CodexBar automáticamente al iniciar tu Mac."; "show_cost_summary" = "Mostrar resumen de coste"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings new file mode 100644 index 000000000..5371c450b --- /dev/null +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,1063 @@ +/* English localization for CodexBar (base/fallback) */ + +" providers" = " fournisseurs"; +"(System)" = "(System)"; +"30d" = "30d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez qu'il soit terminé avant d'ajouter"; +"API key" = "Clé API"; +"API region" = "Région API"; +"API token" = "Jeton API"; +"API tokens" = "Jetons API"; +"About" = "À propos"; +"Account" = "Compte"; +"Accounts" = "Comptes"; +"Accounts subtitle" = "Sous-titre des comptes"; +"Active" = "Actif"; +"Add" = "Ajouter"; +"Add Workspace" = "Ajouter un espace de travail"; +"Advanced" = "Avancé"; +"All" = "Tout"; +"Always allow prompts" = "Toujours autoriser les invites"; +"Animation pattern" = "Modèle d'animation"; +"Antigravity login is managed in the app" = "La connexion antigravité est gérée dans l’app"; +"Applies only to the Security.framework OAuth keychain reader." = "S’applique uniquement au lecteur de Trousseau OAuth Security.framework."; +"Auto falls back to the next source if the preferred one fails." = "Revient automatiquement à la source suivante si la source préférée échoue."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto utilise d'abord l'API, puis revient à la CLI en cas d'échec d'authentification."; +"Auto-detect" = "Auto-detect"; +"Auto-refresh is off; use the menu's Refresh command." = "L'actualisation automatique est désactivée ; utilisez la commande Actualiser du menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Actualisation automatique : toutes les heures · Délai d'expiration : 10 min"; +"Automatic" = "Automatique"; +"Automatic imports browser cookies and WorkOS tokens." = "Importe automatiquement les cookies du navigateur et les jetons WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Importe automatiquement les cookies du navigateur et les jetons de stockage local."; +"Automatic imports browser cookies for dashboard extras." = "Importe automatiquement les cookies du navigateur pour les extras du tableau de bord."; +"Automatic imports browser cookies for the web API." = "Importe automatiquement les cookies du navigateur pour l'API Web."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Importe automatiquement les cookies du navigateur depuis Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Importe automatiquement les cookies du navigateur depuis admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Importe automatiquement les cookies du navigateur depuis opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Importe automatiquement les cookies du navigateur ou les sessions stockées."; +"Automatic imports browser cookies." = "Importe automatiquement les cookies du navigateur."; +"Automatically imports browser session cookie." = "Importe automatiquement le cookie de session du navigateur."; +"Automatically opens CodexBar when you start your Mac." = "Ouvre automatiquement CodexBar lorsque vous démarrez votre Mac."; +"Automation" = "Automatisation"; +"Average (\\(label1) + \\(label2))" = "Moyenne (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Moyenne (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Éviter les invites du Trousseau"; +"Balance" = "Balance"; +"Battery Saver" = "Économiseur de batterie"; +"Bordered" = "Bordé"; +"Build" = "Version"; +"Built \\(buildTimestamp)" = "Construit \\(buildTimestamp)"; +"Buy Credits..." = "Acheter des crédits..."; +"Buy Credits…" = "Acheter des crédits…"; +"CLI paths" = "Chemins CLI"; +"CLI sessions" = "Sessions CLI"; +"Caches" = "Caches"; +"Cancel" = "Annuler"; +"Check for Updates…" = "Rechercher les mises à jour…"; +"Check for updates automatically" = "Rechercher automatiquement les mises à jour"; +"Check if you like your agents having some fun up there." = "Vérifiez si vous aimez que vos agents s'amusent là-haut."; +"Check provider status" = "Vérifier le statut du fournisseur"; +"Choose Codex workspace" = "Choisissez l'espace de travail Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Choisissez l'hôte MiniMax (global .io ou Chine continentale .com)."; +"Choose up to " = "Choisissez jusqu'à "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Choisissez jusqu'à \\(Self.maxOverviewProviders) fournisseurs"; +"Choose up to \\(count) providers" = "Choisissez jusqu'à \\(count) fournisseurs"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Choisissez ce que vous voulez afficher dans la barre de menu (Pace affiche l'utilisation par rapport à celle attendue)."; +"Choose which Codex account CodexBar should follow." = "Choisissez quel compte Codex CodexBar doit suivre."; +"Choose which window drives the menu bar percent." = "Choisissez quelle fenêtre gère le pourcentage de la barre de menus."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI introuvable"; +"Claude binary" = "Binaire Claude"; +"Claude cookies" = "cookies Claude"; +"Claude login failed" = "La connexion de Claude a échoué"; +"Claude login timed out" = "La connexion de Claude a expiré"; +"Close" = "Fermer"; +"Code review" = "Revue de code"; +"Codex CLI not found" = "Codex CLI introuvable"; +"Codex account login already running" = "La connexion au compte Codex est déjà en cours"; +"Codex binary" = "Binaire Codex"; +"Codex login failed" = "Échec de la connexion au Codex"; +"Codex login timed out" = "La connexion au Codex a expiré"; +"CodexBar Lifecycle Keepalive" = "Cycle de vie de CodexBar Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar ne peut pas afficher l'icône de sa barre de menus"; +"CodexBar could not read managed account storage. " = "CodexBar n'a pas pu lire le stockage du compte géré."; +"Configure…" = "Configurer…"; +"Connected" = "Connecté"; +"Controls how much detail is logged." = "Contrôle la quantité de détails enregistrés."; +"Cookie header" = "En-tête du cookie"; +"Cookie source" = "Source des cookies"; +"Cookie: ..." = "Cookie : …"; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie : \\u{2026}\\\n\\\nou collez une capture cURL à partir du tableau de bord Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie : \\u{2026}\\\n\\\nou collez la valeur __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie : \\u{2026}\\\n\\\nou collez la valeur du jeton kimi-auth"; +"Cookie: …" = "Cookie : …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Coût"; +"Could not add Codex account" = "Impossible d'ajouter un compte Codex"; +"Could not open Terminal for Gemini" = "Impossible d'ouvrir le terminal pour Gemini"; +"Could not start claude /login" = "Impossible de démarrer Claude /connexion"; +"Could not start codex login" = "Impossible de démarrer la connexion à Codex"; +"Could not switch system account" = "Impossible de changer de compte système"; +"Credits" = "Crédits"; +"Credits history" = "Historique des crédits"; +"Cursor login failed" = "La connexion au curseur a échoué"; +"Custom" = "Personnalisé"; +"Custom Path" = "Chemin personnalisé"; +"Daily Routines" = "Routines quotidiennes"; +"Debug" = "Débogage"; +"Default" = "Par défaut"; +"Disable Keychain access" = "Désactiver l'accès au Trousseau"; +"Disabled" = "Désactivé"; +"Dismiss" = "Ignorer"; +"Disconnected" = "Déconnecté"; +"Display" = "Affichage"; +"Display mode" = "Mode d'affichage"; +"Display reset times as absolute clock values instead of countdowns." = "Affichez les temps de réinitialisation sous forme de valeurs d'horloge absolues au lieu de comptes à rebours."; +"Done" = "Terminé"; +"Effective PATH" = "CHEMIN efficace"; +"Email" = "E-mail"; +"Enable Merge Icons to configure Overview tab providers." = "Activez Fusionner les icônes pour configurer les fournisseurs d'onglets Présentation."; +"Enable file logging" = "Activer la journalisation des fichiers"; +"Enabled" = "Activé"; +"Error" = "Erreur"; +"Error simulation" = "Simulation d'erreur"; +"Expose troubleshooting tools in the Debug tab." = "Exposez les outils de dépannage dans l’onglet Débogage."; +"Failed" = "Échec"; +"False" = "False"; +"Fetch strategy attempts" = "Récupérer les tentatives de stratégie"; +"Fetching" = "Récupération"; +"Field" = "Champ"; +"Field subtitle" = "Sous-titre du champ"; +"Finish the current managed account change before switching the system account." = "Terminez la modification du compte géré actuel avant de changer de compte système."; +"Force animation on next refresh" = "Forcer l'animation au prochain rafraîchissement"; +"Gateway region" = "Région passerelle"; +"Gemini CLI not found" = "Gemini CLI introuvable"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, signale les incidents dans l'icône et le menu."; +"General" = "Général"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Connexion à GitHub Copilot"; +"GitHub Login" = "Connexion à GitHub"; +"Hide details" = "Masquer les détails"; +"Hide personal information" = "Masquer les informations personnelles"; +"Historical tracking" = "Suivi historique"; +"How often CodexBar polls providers in the background." = "À quelle fréquence CodexBar interroge les fournisseurs en arrière-plan."; +"Inactive" = "Inactif"; +"Install CLI" = "Installer la CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installez la CLI Claude (npm i -g @anthropic-ai/claude-code) et réessayez."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installez la CLI Codex (npm i -g @openai/codex) et réessayez."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installez la CLI Gemini (npm i -g @google/gemini-cli) et réessayez."; +"JetBrains AI is ready" = "L'IA JetBrains est prête"; +"JetBrains IDE" = "EDI JetBrains"; +"Keep CLI sessions alive" = "Maintenir les sessions CLI en vie"; +"Keyboard shortcut" = "Raccourci clavier"; +"Keychain access" = "Accès au Trousseau"; +"Keychain prompt policy" = "Politique d'invite du Trousseau"; +"Last \\(name) fetch failed:" = "La dernière récupération de \\(name) a échoué :"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "La dernière récupération de \\(self.store.metadata(for: self.provider).displayName) a échoué :"; +"Last attempt" = "Dernière tentative"; +"Link" = "Lien"; +"Loading animations" = "Chargement des animations"; +"Loading…" = "Chargement…"; +"Local" = "Local"; +"Logging" = "Journalisation"; +"Login failed" = "La connexion a échoué"; +"Login shell PATH (startup capture)" = "CHEMIN du shell de connexion (capture de démarrage)"; +"Login timed out" = "La connexion a expiré"; +"MCP details" = "Détails du MCP"; +"Managed Codex accounts unavailable" = "Comptes Codex gérés indisponibles"; +"Managed account storage is unreadable. Live account access is still available, " = "Le stockage du compte géré est illisible. L'accès au compte en direct est toujours disponible,"; +"Manual" = "Manuel"; +"May your tokens never run out—keep agent limits in view." = "Que vos jetons ne soient jamais épuisés : gardez un œil sur les limites des agents."; +"Menu bar" = "Barre de menus"; +"Menu bar auto-shows the provider closest to its rate limit." = "La barre de menu affiche automatiquement le fournisseur le plus proche de sa limite de débit."; +"Menu bar metric" = "Métrique de la barre de menus"; +"Menu bar shows percent" = "La barre de menu affiche le pourcentage"; +"Menu content" = "Contenu des menus"; +"Merge Icons" = "Fusionner les icônes"; +"Never prompt" = "Ne jamais demander"; +"No" = "Non"; +"No Codex accounts detected yet." = "Aucun compte Codex détecté pour l'instant."; +"No JetBrains IDE detected" = "Aucun IDE JetBrains détecté"; +"No cost history data." = "Aucune donnée historique des coûts."; +"No data available" = "Aucune donnée disponible"; +"No data yet" = "Aucune donnée pour l'instant"; +"No enabled providers available for Overview." = "Aucun fournisseur activé disponible pour la présentation."; +"No providers selected" = "Aucun fournisseur sélectionné"; +"No token accounts yet." = "Aucun compte symbolique pour l'instant."; +"No usage breakdown data." = "Aucune donnée de répartition d'utilisation."; +"None" = "Aucun"; +"Notifications" = "Notifications"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avertit lorsque le quota de session de 5 heures atteint 0 % et lorsqu'il devient"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Adresses e-mail obscures dans la barre de menus et l'interface utilisateur du menu."; +"Off" = "Désactivé"; +"Offline" = "Hors ligne"; +"On" = "Activé"; +"Online" = "En ligne"; +"Only on user action" = "Uniquement sur l'action de l'utilisateur"; +"Open" = "Ouvrir"; +"Open API Keys" = "Clés API ouvertes"; +"Open Amp Settings" = "Ouvrir les paramètres de l'ampli"; +"Open Antigravity to sign in, then refresh CodexBar." = "Ouvrez Antigravity pour vous connecter, puis actualisez CodexBar."; +"Open Browser" = "Ouvrir le navigateur"; +"Open Coding Plan" = "Plan de codage ouvert"; +"Open Console" = "Ouvrir la console"; +"Open Dashboard" = "Ouvrir le tableau de bord"; +"Open Mistral Admin" = "Ouvrir l'administrateur Mistral"; +"Open Menu Bar Settings" = "Ouvrir les paramètres de la barre de menu"; +"Open Ollama Settings" = "Ouvrir les paramètres Ollama"; +"Open Terminal" = "Terminal ouvert"; +"Open Usage Page" = "Ouvrir la page d'utilisation"; +"Open Warp API Key Guide" = "Guide des clés de l'API Open Warp"; +"Open menu" = "Ouvrir le menu"; +"Open token file" = "Ouvrir le fichier de jeton"; +"OpenAI cookies" = "Cookies OpenAI"; +"OpenAI web extras" = "Extras Web OpenAI"; +"Option A" = "Option A"; +"Option B" = "Option B"; +"Optional override if workspace lookup fails." = "Remplacement facultatif si la recherche d’espace de travail échoue."; +"Options" = "Options"; +"Override auto-detection with a custom IDE base path" = "Remplacer la détection automatique par un chemin de base IDE personnalisé"; +"Overview" = "Aperçu"; +"Overview rows always follow provider order." = "Les lignes de présentation suivent toujours l’ordre des fournisseurs."; +"Overview tab providers" = "Fournisseurs d'onglets de présentation"; +"Paste API key…" = "Coller la clé API…"; +"Paste API token…" = "Coller le jeton API…"; +"Paste key…" = "Coller la clé…"; +"Paste sessionKey or OAuth token…" = "Collez sessionKey ou le jeton OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Collez l'en-tête Cookie d'une requête vers admin.mistral.ai."; +"Paste token…" = "Coller le jeton…"; +"Personal" = "Personnel"; +"Picker" = "Sélecteur"; +"Picker subtitle" = "Sous-titre du sélecteur"; +"Placeholder" = "Espace réservé"; +"Plan" = "Forfait"; +"Play full-screen confetti when weekly usage resets." = "Jouez des confettis en plein écran lorsque l'utilisation hebdomadaire est réinitialisée."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Sonde les pages d'état OpenAI/Claude et Google Workspace pour"; +"Prevents any Keychain access while enabled." = "Empêche tout accès au Trousseau lorsqu'il est activé."; +"Primary (API key limit)" = "Primaire (limite de clé API)"; +"Primary (\\(label))" = "Primaire (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primaire (\\(metadata.sessionLabel))"; +"Probe logs" = "Journaux de sonde"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Les barres de progression se remplissent à mesure que vous consommez le quota (au lieu d'afficher le reste)."; +"Provider" = "Fournisseur"; +"Providers" = "Fournisseurs"; +"Quit CodexBar" = "Quitter CodexBar"; +"Random (default)" = "Aléatoire (par défaut)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Lit les journaux d'utilisation locaux. Affiche aujourd'hui + la fenêtre d'historique sélectionnée dans le menu."; +"Refresh" = "Actualiser"; +"Refresh cadence" = "Cadence de rafraîchissement"; +"Remote" = "Distant"; +"Remove" = "Supprimer"; +"Remove Codex account?" = "Supprimer le compte Codex ?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Supprimer \\(account.email) de CodexBar ? Sa maison Codex gérée sera supprimée."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Supprimer \\(email) de CodexBar ? Sa maison Codex gérée sera supprimée."; +"Remove selected account" = "Supprimer le compte sélectionné"; +"Replace critter bars with provider branding icons and a percentage." = "Remplacez les barres de créatures par des icônes de marque du fournisseur et un pourcentage."; +"Replay selected animation" = "Rejouer l'animation sélectionnée"; +"Requires authentication via GitHub Device Flow." = "Nécessite une authentification via GitHub Device Flow."; +"Resets: \\(reset)" = "Réinitialisation : \\(reset)"; +"Rolling five-hour limit" = "Limite mobile de cinq heures"; +"Search hourly" = "Recherche horaire"; +"Secondary (\\(label))" = "Secondaire (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secondaire (\\(metadata.weeklyLabel))"; +"Select a provider" = "Sélectionnez un fournisseur"; +"Select the IDE to monitor" = "Sélectionnez l'IDE à surveiller"; +"Session quota notifications" = "Notifications de quota de session"; +"Session tokens" = "Jetons de session"; +"Settings" = "Réglages"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Afficher les sections d'utilisation des crédits Codex et de Claude Extra dans le menu."; +"Show Debug Settings" = "Afficher les paramètres de débogage"; +"Show all token accounts" = "Afficher tous les comptes de jetons"; +"Show cost summary" = "Afficher le récapitulatif des coûts"; +"Show credits + extra usage" = "Afficher les crédits + utilisation supplémentaire"; +"Show details" = "Afficher les détails"; +"Show most-used provider" = "Afficher le fournisseur le plus utilisé"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Afficher les icônes des fournisseurs dans le sélecteur (sinon, afficher une ligne de progression hebdomadaire)."; +"Show reset time as clock" = "Afficher l'heure de réinitialisation sous forme d'horloge"; +"Show usage as used" = "Afficher l'utilisation telle qu'utilisée"; +"Sign in via button below" = "Connectez-vous via le bouton ci-dessous"; +"Skip teardown between probes (debug-only)." = "Ignorer le démontage entre les sondes (débogage uniquement)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Empilez les comptes de jetons dans le menu (sinon, affichez une barre de changement de compte)."; +"Start at Login" = "Commencez par la connexion"; +"Status" = "Statut"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Stockez les cookies sessionKey de Claude ou les jetons d'accès OAuth."; +"Store multiple Abacus AI Cookie headers." = "Stockez plusieurs en-têtes Abacus AI Cookie."; +"Store multiple Augment Cookie headers." = "Stockez plusieurs en-têtes de cookies d’augmentation."; +"Store multiple Cursor Cookie headers." = "Stockez plusieurs en-têtes de cookies de curseur."; +"Store multiple Factory Cookie headers." = "Stockez plusieurs en-têtes Factory Cookie."; +"Store multiple MiniMax Cookie headers." = "Stockez plusieurs en-têtes MiniMax Cookie."; +"Store multiple Mistral Cookie headers." = "Stockez plusieurs en-têtes Mistral Cookie."; +"Store multiple Ollama Cookie headers." = "Stockez plusieurs en-têtes Ollama Cookie."; +"Store multiple OpenCode Cookie headers." = "Stockez plusieurs en-têtes de cookies OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Stockez plusieurs en-têtes OpenCode Go Cookie."; +"Stored in the CodexBar config file." = "Stocké dans le fichier de configuration CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Stocké dans ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Stocké dans ~/.codexbar/config.json. Générez-en un sur kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Stocké dans ~/.codexbar/config.json. Collez la clé du tableau de bord synthétique."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Stocké dans ~/.codexbar/config.json. Collez la clé API de votre plan de codage depuis Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Stocké dans ~/.codexbar/config.json. Collez votre clé API MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Stocké dans ~/.codexbar/config.json. Vous pouvez également fournir KILO_API_KEY ou"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stocke l’historique d’utilisation local du Codex (8 semaines) pour personnaliser les prédictions Pace."; +"Subscription Utilization" = "Utilisation de l'abonnement"; +"Surprise me" = "Surprenez-moi"; +"Switcher shows icons" = "Le commutateur affiche des icônes"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Lien symbolique CodexBarCLI vers /usr/local/bin et /opt/homebrew/bin en tant que codexbar."; +"System" = "Système"; +"Temporarily shows the loading animation after the next refresh." = "Affiche temporairement l'animation de chargement après la prochaine actualisation."; +"Tertiary (\\(label))" = "Tertiaire (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Tertiaire (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Le compte Codex par défaut sur ce Mac."; +"Toggle" = "Basculer"; +"Toggle subtitle" = "Basculer le sous-titre"; +"Token" = "Jeton"; +"Trigger the menu bar menu from anywhere." = "Déclenchez le menu de la barre de menus depuis n'importe où."; +"True" = "Vrai"; +"Twitter" = "Twitter"; +"Unsupported" = "Non pris en charge"; +"Update Channel" = "Mettre à jour la chaîne"; +"Updated" = "Mis à jour"; +"Updates unavailable in this build." = "Mises à jour non disponibles dans cette version."; +"Usage" = "Utilisation"; +"Usage breakdown" = "Répartition de l'utilisation"; +"Usage history (30 days)" = "Historique d'utilisation"; +"Usage source" = "Source d'utilisation"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Utilisez BigModel pour les points de terminaison de la Chine continentale (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Utilisez une seule icône de barre de menu avec un sélecteur de fournisseur."; +"Use international or China mainland console gateways for quota fetches." = "Utilisez les passerelles de console internationales ou chinoises pour les récupérations de quotas."; +"Version" = "Version"; +"Version \\(self.versionString)" = "Version \\(self.versionString)"; +"Version \\(version)" = "Version \\(version)"; +"Version \\(versionString)" = "Version \\(versionString)"; +"Vertex AI Login" = "Connexion à Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Attendez la fin de la connexion Codex gérée actuelle avant d'ajouter un autre compte."; +"Waiting for Authentication..." = "En attente d'authentification..."; +"Website" = "Site web"; +"Weekly limit confetti" = "Confettis de limite hebdomadaire"; +"Weekly token limit" = "Limite hebdomadaire de jetons"; +"Weekly usage" = "Utilisation hebdomadaire"; +"Weekly usage unavailable for this account." = "Utilisation hebdomadaire indisponible pour ce compte."; +"Window: \\(window)" = "Fenêtre : \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Écrivez les journaux dans \\(self.fileLogPath) pour le débogage."; +"Yes" = "Oui"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode) : \\(usage)"; +"\\(name): \\(truncated)" = "\\(name) : \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name) : \\(updated) · 30j \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name) : récupération de…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name) : dernière tentative \\(when)"; +"\\(name): no data yet" = "\\(name) : aucune donnée pour l'instant"; +"\\(name): unsupported" = "\\(name) : non pris en charge"; +"all browsers" = "tous les navigateurs"; +"available again." = "à nouveau disponible."; +"built_format" = "Construit %@"; +"copilot_complete_in_browser" = "Connectez-vous complètement dans votre navigateur."; +"copilot_device_code" = "Code de l'appareil copié dans le presse-papier : %1$@\n\nVérifiez à : %2$@"; +"copilot_device_code_copied" = "Code de l'appareil copié."; +"copilot_verify_at" = "Vérifiez à %@"; +"copilot_waiting_text" = "Terminez la connexion dans votre navigateur.\nCette fenêtre se ferme automatiquement une fois la connexion terminée."; +"copilot_window_closes_auto" = "Cette fenêtre se ferme automatiquement une fois la connexion terminée."; +"cost_status_error" = "%1$@ : %2$@"; +"cost_status_fetching" = "%1$@ : récupération de … %2$@"; +"cost_status_last_attempt" = "%1$@ : dernière tentative %2$@"; +"cost_status_no_data" = "%@ : aucune donnée pour l'instant"; +"cost_status_snapshot" = "%1$@ : %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@ : non pris en charge"; +"credits_remaining" = "Crédits : %@"; +"cursor_on_demand" = "À la demande : %@"; +"cursor_on_demand_with_limit" = "À la demande : %1$@ / %2$@"; +"extra_usage_format" = "Utilisation supplémentaire : %1$@ / %2$@"; +"jetbrains_detected_generate" = "Détecté : %@. Utilisez l'assistant IA une fois pour générer des données de quota, puis actualisez CodexBar."; +"jetbrains_detected_select" = "Détecté : %@. Sélectionnez votre IDE préféré dans Paramètres, puis actualisez CodexBar."; +"last_fetch_failed_with_provider" = "La dernière récupération de %@ a échoué :"; +"last_spend" = "Dernière dépense : %@"; +"mcp_model_usage" = "%1$@ : %2$@"; +"mcp_resets" = "Réinitialisation : %@"; +"mcp_window" = "Fenêtre : %@"; +"metric_average" = "Moyenne (%1$@ + %2$@)"; +"metric_primary" = "Primaire (%@)"; +"metric_secondary" = "Secondaire (%@)"; +"metric_tertiary" = "Tertiaire (%@)"; +"multiple_workspaces_found" = "CodexBar a trouvé plusieurs espaces de travail pour %@. Veuillez choisir l'espace de travail à ajouter."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Choisissez jusqu'à %@ fournisseurs"; +"remove_account_message" = "Supprimer %@ de CodexBar ? Sa maison Codex gérée sera supprimée."; +"version_format" = "Version %@"; +"vertex_ai_login_instructions" = "Pour suivre l'utilisation de Vertex AI, authentifiez-vous auprès de Google Cloud.\n\n1. Ouvrez le terminal\n2. Exécutez : gcloud auth application-default login\n3. Suivez les invites du navigateur pour vous connecter\n4. Définissez votre projet : gcloud config set project PROJECT_ID\n\nOuvrir le terminal maintenant ?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID est défini mais seuls opencode, opencodego et deepgram prennent en charge workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licence MIT."; + +/* General Pane */ +"section_system" = "Système"; +"section_usage" = "Utilisation"; +"section_automation" = "Automatisation"; +"language_title" = "Langue"; +"language_subtitle" = "Change la langue d'affichage. Nécessite de redémarrer l'app pour une prise en compte complète."; +"language_system" = "Système"; +"language_english" = "Anglais"; +"language_spanish" = "Espagnol"; +"language_catalan" = "Catalan"; +"language_chinese_simplified" = "Chinois simplifié"; +"language_chinese_traditional" = "Chinois traditionnel"; +"language_portuguese_brazilian" = "Portugais (Brésil)"; +"language_swedish" = "Suédois"; +"language_french" = "Français"; +"start_at_login_title" = "Lancer à l'ouverture de session"; +"start_at_login_subtitle" = "Ouvre automatiquement CodexBar au démarrage de votre Mac."; +"show_cost_summary" = "Afficher le récapitulatif des coûts"; +"show_cost_summary_subtitle" = "Lit les journaux d'utilisation locaux. Affiche le coût d'aujourd'hui et de la période sélectionnée dans le menu."; +"cost_history_days_title" = "Fenêtre d'historique : %d jours"; +"cost_auto_refresh_info" = "Actualisation automatique : toutes les heures · Délai d'expiration : 10 min"; +"refresh_cadence_title" = "Fréquence d'actualisation"; +"refresh_cadence_subtitle" = "Définit la fréquence à laquelle CodexBar interroge les fournisseurs en arrière-plan."; +"manual_refresh_hint" = "L'actualisation automatique est désactivée ; utilisez la commande Actualiser du menu."; +"check_provider_status_title" = "Vérifier l'état des fournisseurs"; +"check_provider_status_subtitle" = "Interroge les pages d'état OpenAI/Claude et Google Workspace pour Gemini/Antigravity, et affiche les incidents dans l'icône et le menu."; +"session_quota_notifications_title" = "Notifications de quota de session"; +"session_quota_notifications_subtitle" = "Vous avertit lorsque le quota de session sur 5 heures atteint 0 % puis lorsqu'il redevient disponible."; +"quota_warning_notifications_title" = "Alertes de quota"; +"quota_warning_notifications_subtitle" = "Vous avertit lorsque le quota restant (session ou hebdomadaire) franchit les seuils configurés."; +"quota_warnings_title" = "Alertes de quota"; +"quota_warning_session" = "session"; +"quota_warning_session_capitalized" = "Session"; +"quota_warning_weekly" = "hebdomadaire"; +"quota_warning_weekly_capitalized" = "Hebdomadaire"; +"quota_warning_notification_title" = "%1$@ %2$@ : quota faible"; +"quota_warning_notification_body" = "Il reste %1$@. Seuil d'alerte %2$d %% (%3$@) atteint."; +"quota_warning_notification_body_with_account" = "Compte %1$@. Il reste %2$@. Seuil d'alerte %3$d %% (%4$@) atteint."; +"session_depleted_notification_title" = "Session %@ épuisée"; +"session_depleted_notification_body" = "0 % restant. Vous serez notifié quand elle redeviendra disponible."; +"session_restored_notification_title" = "Session %@ rétablie"; +"session_restored_notification_body" = "Le quota de session est à nouveau disponible."; +"quota_warning_warn_at" = "Avertir à"; +"quota_warning_global_threshold_subtitle" = "Pourcentages restants pour les fenêtres de session et hebdomadaires, sauf si un fournisseur les remplace."; +"quota_warning_sound" = "Lire un son de notification"; +"quota_warning_provider_inherits" = "Utilise les réglages globaux d'alerte de quota, sauf personnalisation de cette fenêtre."; +"quota_warning_customize_thresholds" = "Personnaliser les seuils %@"; +"quota_warning_enable_warnings" = "Activer les alertes %@"; +"quota_warning_window_warn_at" = "Alerte %@"; +"quota_warning_off" = "Désactivé"; +"quota_warning_inherited" = "Hérité : %@"; +"quota_warning_depleted_only" = "uniquement à l'épuisement"; +"quota_warning_upper" = "Seuil haut"; +"quota_warning_lower" = "Seuil bas"; +"apply" = "Appliquer"; +"quit_app" = "Quitter CodexBar"; + +/* Tab titles */ +"tab_general" = "Général"; +"tab_providers" = "Fournisseurs"; +"tab_display" = "Affichage"; +"tab_advanced" = "Avancé"; +"tab_about" = "À propos"; +"tab_debug" = "Débogage"; + +/* Providers Pane */ +"select_a_provider" = "Sélectionner un fournisseur"; +"cancel" = "Annuler"; +"last_fetch_failed" = "dernière récupération échouée"; +"usage_not_fetched_yet" = "utilisation pas encore récupérée"; +"managed_account_storage_unreadable" = "Le stockage du compte géré est illisible. L'accès au compte réel est toujours disponible, mais les actions gérées d'ajout, de réauthentification et de suppression sont désactivées jusqu'à ce que le magasin soit récupérable."; +"remove_codex_account_title" = "Supprimer le compte Codex ?"; +"remove" = "Supprimer"; +"managed_login_already_running" = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez la fin avant d'ajouter ou de ré-authentifier un autre compte."; +"managed_login_failed" = "La connexion au Codex géré n'a pas abouti. Vérifiez que `codex --version` fonctionne dans Terminal. Si macOS a bloqué ou déplacé « codex » vers la corbeille, supprimez les installations en double obsolètes, exécutez « npm install -g --include=optional @openai/codex@latest », puis réessayez."; +"codex_login_output" = "Résultat de connexion à Codex :"; +"managed_login_missing_email" = "Connexion au Codex terminée, mais aucune adresse e-mail du compte n'était disponible. Réessayez après avoir confirmé que le compte est entièrement connecté."; +"login_success_notification_title" = "%@ connexion réussie"; +"login_success_notification_body" = "Vous pouvez revenir à l’app ; authentification terminée."; +"workspace_selection_cancelled" = "CodexBar a trouvé plusieurs espaces de travail, mais aucun espace de travail n'a été sélectionné."; +"unsafe_managed_home" = "CodexBar a refusé de modifier un chemin d'accès à la maison géré inattendu : %@"; +"menu_bar_metric_title" = "Métrique de la barre de menus"; +"menu_bar_metric_subtitle" = "Choisissez quelle fenêtre gère le pourcentage de la barre de menus."; +"menu_bar_metric_subtitle_deepseek" = "Affiche le solde DeepSeek dans la barre de menu."; +"menu_bar_metric_subtitle_moonshot" = "Affiche le solde de l'API Moonshot / Kimi dans la barre de menu."; +"menu_bar_metric_subtitle_mistral" = "Affiche les dépenses de l'API Mistral du mois en cours dans la barre de menu."; +"menu_bar_metric_subtitle_kimik2" = "Affiche les crédits de la clé API Kimi K2 dans la barre de menu."; +"automatic" = "Automatique"; +"primary_api_key_limit" = "Primaire (limite de clé API)"; + +/* Display Pane */ +"section_menu_bar" = "Barre de menus"; +"merge_icons_title" = "Fusionner les icônes"; +"merge_icons_subtitle" = "Utilisez une seule icône de barre de menu avec un sélecteur de fournisseur."; +"switcher_shows_icons_title" = "Le commutateur affiche des icônes"; +"switcher_shows_icons_subtitle" = "Afficher les icônes des fournisseurs dans le sélecteur (sinon, afficher une ligne de progression hebdomadaire)."; +"show_most_used_provider_title" = "Afficher le fournisseur le plus utilisé"; +"show_most_used_provider_subtitle" = "La barre de menu affiche automatiquement le fournisseur le plus proche de sa limite de débit."; +"menu_bar_shows_percent_title" = "La barre de menu affiche le pourcentage"; +"menu_bar_shows_percent_subtitle" = "Remplacez les barres de créatures par des icônes de marque du fournisseur et un pourcentage."; +"display_mode_title" = "Mode d'affichage"; +"display_mode_subtitle" = "Choisissez ce que vous voulez afficher dans la barre de menu (Pace affiche l'utilisation par rapport à celle attendue)."; +"section_menu_content" = "Contenu des menus"; +"show_usage_as_used_title" = "Afficher l'utilisation telle qu'utilisée"; +"show_usage_as_used_subtitle" = "Les barres de progression se remplissent à mesure que vous consommez le quota (au lieu d'afficher le reste)."; +"show_quota_warning_markers_title" = "Afficher les marqueurs d'avertissement de quota"; +"show_quota_warning_markers_subtitle" = "Dessinez des coches de seuil sur les barres d’utilisation lorsque des avertissements de quota sont configurés."; +"weekly_progress_work_days_title" = "Jours de travail hebdomadaires"; +"weekly_progress_work_days_subtitle" = "Dessinez des graduations journalières sur les barres d’utilisation hebdomadaire."; +"show_reset_time_as_clock_title" = "Afficher l'heure de réinitialisation sous forme d'horloge"; +"show_reset_time_as_clock_subtitle" = "Affichez les temps de réinitialisation sous forme de valeurs d'horloge absolues au lieu de comptes à rebours."; +"show_provider_changelog_links_title" = "Afficher les liens du journal des modifications du fournisseur"; +"show_provider_changelog_links_subtitle" = "Ajoute au menu des liens de notes de version pour les fournisseurs pris en charge par CLI."; +"show_credits_extra_usage_title" = "Afficher les crédits + utilisation supplémentaire"; +"show_credits_extra_usage_subtitle" = "Afficher les sections d'utilisation des crédits Codex et de Claude Extra dans le menu."; +"show_all_token_accounts_title" = "Afficher tous les comptes de jetons"; +"show_all_token_accounts_subtitle" = "Empilez les comptes de jetons dans le menu (sinon, affichez une barre de changement de compte)."; +"multi_account_layout_title" = "Disposition multi-comptes"; +"multi_account_layout_subtitle" = "Choisissez un changement de compte segmenté ou des cartes de compte empilées."; +"multi_account_layout_segmented" = "Segmenté"; +"multi_account_layout_stacked" = "Empilé"; +"overview_tab_providers_title" = "Fournisseurs d'onglets de présentation"; +"configure" = "Configurer…"; +"overview_enable_merge_icons_hint" = "Activez Fusionner les icônes pour configurer les fournisseurs d'onglets Présentation."; +"overview_no_providers_hint" = "Aucun fournisseur activé disponible pour la présentation."; +"overview_rows_follow_order" = "Les lignes de présentation suivent toujours l’ordre des fournisseurs."; +"overview_no_providers_selected" = "Aucun fournisseur sélectionné"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Raccourci clavier"; +"open_menu_shortcut_title" = "Ouvrir le menu"; +"open_menu_shortcut_subtitle" = "Déclenchez le menu de la barre de menus depuis n'importe où."; +"install_cli" = "Installer la CLI"; +"install_cli_subtitle" = "Lien symbolique CodexBarCLI vers /usr/local/bin et /opt/homebrew/bin en tant que codexbar."; +"cli_not_found" = "CodexBarCLI introuvable dans l'ensemble d'applications."; +"no_writable_bin_dirs" = "Aucun répertoire bin inscriptible trouvé."; +"show_debug_settings_title" = "Afficher les paramètres de débogage"; +"show_debug_settings_subtitle" = "Exposez les outils de dépannage dans l’onglet Débogage."; +"surprise_me_title" = "Surprenez-moi"; +"surprise_me_subtitle" = "Vérifiez si vous aimez que vos agents s'amusent là-haut."; +"weekly_limit_confetti_title" = "Confettis de limite hebdomadaire"; +"weekly_limit_confetti_subtitle" = "Jouez des confettis en plein écran lorsque l'utilisation hebdomadaire est réinitialisée."; +"hide_personal_info_title" = "Masquer les informations personnelles"; +"hide_personal_info_subtitle" = "Adresses e-mail obscures dans la barre de menus et l'interface utilisateur du menu."; +"show_provider_storage_usage_title" = "Afficher l'utilisation du stockage du fournisseur"; +"show_provider_storage_usage_subtitle" = "Afficher l'utilisation du disque local dans les menus. Analyse les chemins connus appartenant au fournisseur en arrière-plan."; +"section_keychain_access" = "Accès au Trousseau"; +"keychain_access_caption" = "Désactivez toutes les lectures et écritures du Trousseau. Utilisez-le si macOS continue de demander « Chrome/Brave/Edge Safe Storage » même après avoir cliqué sur Toujours autoriser. L'importation des cookies du navigateur n'est pas disponible lorsqu'elle est activée ; collez manuellement les en-têtes de cookies dans les fournisseurs. Claude/Codex OAuth via la CLI fonctionne toujours."; +"disable_keychain_access_title" = "Désactiver l'accès au Trousseau"; +"disable_keychain_access_subtitle" = "Empêche tout accès au Trousseau lorsqu'il est activé."; + +/* About Pane */ +"about_tagline" = "Que vos jetons ne soient jamais épuisés : gardez un œil sur les limites des agents."; +"link_github" = "GitHub"; +"link_website" = "Site web"; +"link_twitter" = "X/Twitter"; +"link_email" = "E-mail"; +"check_updates_auto" = "Rechercher automatiquement les mises à jour"; +"update_channel" = "Mettre à jour la chaîne"; +"check_for_updates" = "Rechercher les mises à jour…"; +"updates_unavailable" = "Mises à jour non disponibles dans cette version."; +"copyright" = "© 2026 Peter Steinberger. Licence MIT."; + +/* Debug Pane */ +"section_logging" = "Journalisation"; +"enable_file_logging" = "Activer la journalisation des fichiers"; +"enable_file_logging_subtitle" = "Écrivez les journaux dans %@ pour le débogage."; +"verbosity_title" = "Niveau de verbosité"; +"verbosity_subtitle" = "Contrôle la quantité de détails enregistrés."; +"open_log_file" = "Ouvrir le fichier journal"; +"force_animation_next_refresh" = "Forcer l'animation au prochain rafraîchissement"; +"force_animation_next_refresh_subtitle" = "Affiche temporairement l'animation de chargement après la prochaine actualisation."; +"section_loading_animations" = "Chargement des animations"; +"loading_animations_caption" = "Choisissez un motif et rejouez-le dans la barre de menu. \"Aléatoire\" conserve le comportement existant."; +"animation_random_default" = "Aléatoire (par défaut)"; +"replay_selected_animation" = "Rejouer l'animation sélectionnée"; +"blink_now" = "Cligne des yeux maintenant"; +"section_probe_logs" = "Journaux de sonde"; +"probe_logs_caption" = "Récupère la dernière sortie de la sonde pour le débogage ; La copie conserve le texte intégral."; +"fetch_log" = "Récupérer le journal"; +"copy" = "Copier"; +"save_to_file" = "Enregistrer dans un fichier"; +"load_parse_dump" = "Charger le vidage d'analyse"; +"rerun_provider_autodetect" = "Réexécuter la détection automatique du fournisseur"; +"loading" = "Chargement…"; +"no_log_yet_fetch" = "Pas de journal pour l'instant. Récupérer pour charger."; +"section_fetch_strategy" = "Récupérer les tentatives de stratégie"; +"fetch_strategy_caption" = "Dernières décisions et erreurs du pipeline de récupération pour un fournisseur."; +"section_openai_cookies" = "Cookies OpenAI"; +"openai_cookies_caption" = "Importation de cookies + journaux de récupération WebKit de la dernière tentative de cookies OpenAI."; +"no_log_yet" = "Pas de journal pour l'instant. Mettez à jour les cookies OpenAI dans Fournisseurs → Codex pour exécuter une importation."; +"section_caches" = "Caches"; +"caches_caption" = "Effacez les résultats de l’analyse des coûts mis en cache ou les caches des cookies du navigateur."; +"clear_cookie_cache" = "Vider le cache des cookies"; +"clear_cost_cache" = "Vider le cache des coûts"; +"section_notifications" = "Notifications"; +"notifications_caption" = "Déclenchez des notifications de test pour la fenêtre de session de 5 heures (épuisée/restaurée)."; +"post_depleted" = "Post épuisé"; +"post_restored" = "Message restauré"; +"section_cli_sessions" = "Sessions CLI"; +"cli_sessions_caption" = "Gardez les sessions Codex/Claude CLI actives après une sonde. La valeur par défaut se ferme une fois les données capturées."; +"keep_cli_sessions_alive" = "Maintenir les sessions CLI en vie"; +"keep_cli_sessions_alive_subtitle" = "Ignorer le démontage entre les sondes (débogage uniquement)."; +"reset_cli_sessions" = "Réinitialiser les sessions CLI"; +"section_error_simulation" = "Simulation d'erreur"; +"error_simulation_caption" = "Injectez un faux message d'erreur dans la carte de menu pour tester la mise en page."; +"set_menu_error" = "Erreur de menu de définition"; +"clear_menu_error" = "Effacer l'erreur de menu"; +"set_cost_error" = "Erreur de définition du coût"; +"clear_cost_error" = "Effacer l'erreur de coût"; +"section_cli_paths" = "Chemins CLI"; +"cli_paths_caption" = "Couches binaires et PATH du Codex résolues ; Capture du chemin de connexion au démarrage (délai d'attente court)."; +"codex_binary" = "Binaire Codex"; +"claude_binary" = "Binaire Claude"; +"effective_path" = "CHEMIN efficace"; +"unavailable" = "Indisponible"; +"login_shell_path" = "CHEMIN du shell de connexion (capture de démarrage)"; +"cleared" = "Effacé"; +"no_fetch_attempts" = "Aucune tentative de récupération pour l'instant."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe peut bloquer les applications de la barre de menus dans Paramètres système → Barre de menus → Autoriser dans la barre de menus. CodexBar est en cours d'exécution, mais macOS cache peut-être son icône. Ouvrez les paramètres de la barre de menu et activez CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatique"; +"metric_pref_primary" = "Principal"; +"metric_pref_secondary" = "Secondaire"; +"metric_pref_tertiary" = "Tertiaire"; +"metric_pref_extra_usage" = "Utilisation supplémentaire"; +"metric_pref_average" = "Moyenne"; + +/* Display modes */ +"display_mode_percent" = "Pourcentage"; +"display_mode_pace" = "Rythme"; +"display_mode_both" = "Les deux"; +"display_mode_percent_desc" = "Afficher le pourcentage restant/utilisé (par exemple 45 %)"; +"display_mode_pace_desc" = "Afficher l'indicateur d'allure (par exemple +5 %)"; +"display_mode_both_desc" = "Afficher à la fois le pourcentage et le rythme (par exemple 45 % · +5 %)"; + +/* Provider status */ +"status_operational" = "Opérationnel"; +"status_partial_outage" = "Dégradation partielle"; +"status_major_outage" = "Panne majeure"; +"status_critical_issue" = "Problème critique"; +"status_maintenance" = "Maintenance"; +"status_unknown" = "Statut inconnu"; + +/* Refresh frequency */ +"refresh_manual" = "Manuel"; +"refresh_1min" = "1 minute"; +"refresh_2min" = "2 minutes"; +"refresh_5min" = "5 minutes"; +"refresh_15min" = "15 minutes"; +"refresh_30min" = "30 minutes"; + +/* Additional keys */ +"not_found" = "Pas trouvé"; + +/* Cost estimation */ +"cost_header_estimated" = "Coût (estimé)"; +"cost_estimate_hint" = "Estimé à partir des journaux locaux · peut différer de votre facture"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Aucun IDE JetBrains avec AI Assistant détecté. Installez un IDE JetBrains et activez AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Jeton API OpenRouter non configuré. Définissez la variable d'environnement OPENROUTER_API_KEY ou configurez-la dans Paramètres."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Jeton API z.ai introuvable. Définissez apiKey dans ~/.codexbar/config.json ou Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Clé API DeepSeek manquante."; +"%@ is unavailable in the current environment." = "%@ n'est pas disponible dans l'environnement actuel."; +"All Systems Operational" = "Tous les systèmes opérationnels"; +"Last 30 days" = "30 derniers jours"; +"Last 30 days:" = "30 derniers jours :"; +"This month" = "Ce mois-ci"; +"Store multiple OpenAI API keys." = "Stockez plusieurs clés API OpenAI."; +"Admin API key" = "Clé API d'administration"; +"Open billing" = "Facturation ouverte"; +"Google accounts" = "Comptes Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Stockez plusieurs comptes Google OAuth Antigravity pour une commutation rapide."; +"Add Google Account" = "Ajouter un compte Google"; +"Open Token Plan" = "Plan de jetons ouverts"; +"Text Generation" = "Génération de texte"; +"Text to Speech" = "Synthèse vocale"; +"Music Generation" = "Génération de musique"; +"Image Generation" = "Génération d'images"; +"No local data found" = "Aucune donnée locale trouvée"; +"Credits unavailable; keep Codex running to refresh." = "Crédits indisponibles ; laissez le Codex fonctionner pour l'actualiser."; +"No available fetch strategy for minimax." = "Aucune stratégie de récupération disponible pour minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Aucune session de Cursor trouvée. Veuillez vous connecter à cursor.com dans Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX ou Edge Canary. Si vous utilisez Safari, accordez l'accès complet au disque à CodexBar dans Paramètres système ▸ Confidentialité et sécurité. Vous pouvez également vous connecter à Cursor à partir du menu CodexBar (Ajouter/changer de compte)."; +"No OpenCode session cookies found in browsers." = "Aucun cookie de session OpenCode trouvé dans les navigateurs."; +"No available fetch strategy for %@." = "Aucune stratégie de récupération disponible pour %@."; +"Today" = "Aujourd’hui"; +"Today tokens" = "Jetons d'aujourd'hui"; +"30d cost" = "coût 30 jours"; +"30d tokens" = "jetons 30d"; +"Latest tokens" = "Derniers jetons"; +"Top model" = "Top modèle"; +"Storage" = "Stockage"; +"Add Account..." = "Ajouter un compte..."; +"Usage Dashboard" = "Tableau de bord d'utilisation"; +"Status Page" = "Page d'état"; +"Settings..." = "Réglages…"; +"About CodexBar" = "À propos de CodexBar"; +"Quit" = "Quitter"; +"Last %d day" = "Dernier %d jour"; +"Last %d days" = "%d derniers jours"; +"%@ tokens" = "Jetons %@"; +"Latest billing day" = "Dernier jour de facturation"; +"Latest billing day (%@)" = "Dernier jour de facturation (%@)"; +"%@ left" = "%@ restant"; +"Resets %@" = "Réinitialise %@"; +"Resets in %@" = "Réinitialisé dans %@"; +"Resets now" = "Réinitialise maintenant"; +"Lasts until reset" = "Dure jusqu'à la réinitialisation"; +"Updated %@" = "%@ mis à jour"; +"Updated %@h ago" = "Mis à jour il y a %@h"; +"Updated %@m ago" = "Mis à jour il y a %@m"; +"Updated just now" = "Mis à jour tout à l'heure"; +"Projected empty in %@" = "Projeté vide dans %@"; +"Runs out in %@" = "S'épuise dans %@"; +"Pace: %@" = "Rythme : %@"; +"Pace: %@ · %@" = "Rythme : %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% risque d'épuisement"; +"%d%% in deficit" = "%d%% en déficit"; +"%d%% in reserve" = "%d%% en réserve"; +"usage_percent_suffix_left" = "restant"; +"usage_percent_suffix_used" = "utilisé"; +"Store multiple DeepSeek API keys." = "Stockez plusieurs clés API DeepSeek."; +"This week" = "Cette semaine"; +"Week" = "Semaine"; +"Month" = "Mois"; +"Models" = "Modèles"; +"24h tokens" = "jetons 24h"; +"Latest hour" = "Dernière heure"; +"Peak hour" = "Heure de pointe"; +"Top method" = "Méthode supérieure"; +"30d cash" = "30 jours en espèces"; +"30d billing history from MiniMax web session" = "Historique de facturation 30 jours à partir de la session Web MiniMax"; +"AWS Cost Explorer billing can lag." = "La facturation d'AWS Cost Explorer peut prendre du retard."; +"Rate limit: %d / %@" = "Limite de débit : %d / %@"; +"Key remaining" = "Clé restante"; +"No limit set for the API key" = "Aucune limite définie pour la clé API"; +"API key limit unavailable right now" = "Limite de clé API indisponible pour le moment"; +"This month: %@ tokens" = "Ce mois-ci : %@ jetons"; +"No utilization data yet." = "Aucune donnée d'utilisation pour l'instant."; +"No %@ utilization data yet." = "Aucune donnée d'utilisation de %@ pour l'instant."; +"%@: %@%% used" = "%@ : %@%% utilisé"; +"%dd" = "%dd"; +"today" = "aujourd'hui"; +"just now" = "tout à l' heure"; +"On pace" = "Au rythme"; +"Runs out now" = "S'épuise maintenant"; +"Projected empty now" = "Projeté vide maintenant"; +"Switch Account..." = "Changer de compte..."; +"Update ready, restart now?" = "La mise à jour est prête, redémarrer maintenant ?"; +"Daily" = "Quotidien"; +"Hourly Tokens" = "Jetons horaires"; +"No data" = "Aucune donnée"; +"No usage breakdown data available." = "Aucune donnée de répartition d'utilisation disponible."; + +"Today: %@ · %@ tokens" = "Aujourd'hui : %@ · %@ jetons"; +"Today: %@" = "Aujourd'hui : %@"; +"Today: %@ tokens" = "Aujourd'hui : %@ jetons"; +"Last 30 days: %@ · %@ tokens" = "30 derniers jours : %@ · %@ jetons"; +"Last 30 days: %@" = "30 derniers jours : %@"; +"Est. total (30d): %@" = "HNE. total (30j) : %@"; +"Est. total (%@): %@" = "HNE. total (%@) : %@"; +"Hover a bar for details" = "Passez la souris sur une barre pour plus de détails"; +"%@: %@ · %@ tokens" = "%@ : %@ · %@ jetons"; +"No providers selected for Overview." = "Aucun fournisseur sélectionné pour la vue d'ensemble."; +"No overview data available." = "Aucune donnée globale disponible."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto utilise d'abord l'API IDE locale, puis Google OAuth lorsque l'EDI est fermé."; +"Login with Google" = "Connectez-vous avec Google"; + +/* Popup panels */ +"No usage configured." = "Aucune utilisation configurée."; +"Quota" = "Quota"; +"tokens" = "jetons"; +"requests" = "requêtes"; +"Latest" = "Dernier"; +"Monthly" = "Mensuel"; +"Sonnet" = "Sonnet"; +"Overages" = "Dépassements"; +"Activity" = "Activité"; +"Copied" = "Copié"; +"Copy error" = "Erreur de copie"; +"Copy path" = "Copier le chemin"; +"Extra usage spent" = "Utilisation supplémentaire dépensée"; +"Credits remaining" = "Crédits restants"; +"Using CLI fallback" = "Utilisation de la solution de secours CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "Mises à jour du solde en temps quasi réel (jusqu'à 5 minutes de décalage)"; +"Daily billing data finalizes at 07:00 UTC" = "Les données de facturation quotidiennes se terminent à 07h00 UTC"; +"%@ of %@ credits left" = "%@ sur %@ crédits restants"; +"%@ of %@ bonus credits left" = "%@ de %@ crédits bonus restants"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restant)"; +"%@/%@ left" = "%@/%@ gauche"; +"Gemini Flash" = "Flash Gémeaux"; +"Regenerates %@" = "Régénère %@"; +"used after next regen" = "utilisé après la prochaine régénération"; +"after next regen" = "après la prochaine régénération"; +"Near full" = "Presque plein"; +"Full in ~1 regen" = "Complet en ~1 régénération"; +"Full in ~%.0f regens" = "Plein en ~%.0f régénérations"; +"Overage usage" = "Utilisation excédentaire"; +"Overage cost" = "Coût excédentaire"; +"credits" = "crédits"; +"Zen balance" = "L'équilibre zen"; +"API spend" = "Dépenses API"; +"Extra usage" = "Utilisation supplémentaire"; +"Quota usage" = "Utilisation des quotas"; +"%.0f%% used" = "%.0f%% utilisé"; +"Usage history (today)" = "Historique d'utilisation (aujourd'hui)"; +"Usage history (%d days)" = "Historique d'utilisation (%d jours)"; +"%d percent remaining" = "%d pour cent restant"; +"Unknown" = "Inconnu"; +"stale data" = "données obsolètes"; +"No credits history data." = "Aucune donnée d'historique de crédits."; +"No credits history data available." = "Aucune donnée d'historique de crédits disponible."; +"Credits history chart" = "Graphique de l'historique des crédits"; +"%d days of credits data" = "%d jours de données de crédits"; +"Usage breakdown chart" = "Tableau de répartition de l'utilisation"; +"%d days of usage data across %d services" = "%d jours de données d'utilisation sur %d services"; +"Cost history chart" = "Graphique de l'historique des coûts"; +"%d days of cost data" = "%d jours de données sur les coûts"; +"Plan utilization chart" = "Tableau d'utilisation du plan"; +"%d utilization samples" = "Exemples d'utilisation de %d"; +"Hourly Usage" = "Utilisation horaire"; +"Usage remaining" = "Utilisation restante"; +"Usage used" = "Utilisation utilisée"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "Clé API vérifiée. Ollama n'expose pas les limites de quota Cloud via l'API."; +"Last 30 days: %@ tokens" = "30 derniers jours : jetons %@"; +"7d spend" = "7j dépensés"; +"30d spend" = "30 jours de dépenses"; +"Cache read" = "Lecture du cache"; +"Claude Admin API 30 day spend trend" = "Tendance des dépenses de l'API Claude Admin sur 30 jours"; +"OpenRouter API key spend trend" = "Tendance des dépenses liées aux clés API OpenRouter"; +"z.ai hourly token trend" = "tendance des jetons horaires z.ai"; +"MiniMax 30 day token usage trend" = "Tendance d'utilisation des jetons MiniMax sur 30 jours"; +"Today cash" = "Aujourd'hui en espèces"; +"DeepSeek 30 day token usage trend" = "Tendance d'utilisation des jetons DeepSeek sur 30 jours"; +"cache-hit input" = "entrée d'accès au cache"; +"cache-miss input" = "entrée manquante dans le cache"; +"output" = "sortie"; +"Requests" = "Requêtes"; +"Reported by OpenAI Admin API organization usage." = "Signalé par l’utilisation de l’organisation de l’API OpenAI Admin."; +"Reported by Mistral billing usage." = "Rapporté par l'utilisation de la facturation Mistral."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Ajoutez des comptes via GitHub OAuth Device Flow sur l'hôte sélectionné."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Stocke chaque compte Google connecté pour un changement rapide d'antigravité. Utilise Antigravity.app OAuth lorsqu'il est disponible, ou ANTIGRAVITY_OAUTH_CLIENT_ID et ANTIGRAVITY_OAUTH_CLIENT_SECRET comme remplacement."; +"Manual cleanup: past sessions" = "Nettoyage manuel : sessions précédentes"; +"Clearing removes past resume, continue, and rewind history." = "L'effacement supprime l'historique de reprise, de continuation et de rembobinage passé."; +"Manual cleanup: file checkpoints" = "Nettoyage manuel : points de contrôle des fichiers"; +"Clearing removes checkpoint restore data for previous edits." = "La suppression supprime les données de restauration du point de contrôle pour les modifications précédentes."; +"Manual cleanup: saved plans" = "Nettoyage manuel : plans enregistrés"; +"Clearing removes old plan-mode files." = "La suppression supprime les anciens fichiers en mode plan."; +"Manual cleanup: debug logs" = "Nettoyage manuel : journaux de débogage"; +"Clearing removes past debug logs." = "La suppression supprime les anciens journaux de débogage."; +"Manual cleanup: attachment cache" = "Nettoyage manuel : cache des pièces jointes"; +"Clearing removes cached large pastes or attached images." = "La suppression supprime les gros collages mis en cache ou les images jointes."; +"Manual cleanup: session metadata" = "Nettoyage manuel : métadonnées de session"; +"Clearing removes per-session environment metadata." = "La suppression supprime les métadonnées de l'environnement par session."; +"Manual cleanup: shell snapshots" = "Nettoyage manuel : instantanés du shell"; +"Clearing removes leftover runtime shell snapshot files." = "La suppression supprime les fichiers instantanés du shell d'exécution restants."; +"Manual cleanup: legacy todos" = "Nettoyage manuel : tâches héritées"; +"Clearing removes legacy per-session task lists." = "La suppression supprime les anciennes listes de tâches par session."; +"Manual cleanup: sessions" = "Nettoyage manuel : sessions"; +"Clearing removes past Codex session history." = "La suppression supprime l'historique des sessions Codex passées."; +"Manual cleanup: archived sessions" = "Nettoyage manuel : sessions archivées"; +"Clearing removes archived Codex session history." = "La suppression supprime l'historique des sessions Codex archivé."; +"Manual cleanup: cache" = "Nettoyage manuel : cache"; +"Clearing removes provider-owned cached data." = "La suppression supprime les données mises en cache appartenant au fournisseur."; +"Manual cleanup: logs" = "Nettoyage manuel : journaux"; +"Clearing removes local diagnostic logs." = "La suppression supprime les journaux de diagnostic locaux."; +"Manual cleanup: file history" = "Nettoyage manuel : historique des fichiers"; +"Clearing removes local edit checkpoint history." = "La suppression supprime l’historique des points de contrôle des modifications locales."; +"Manual cleanup: temporary data" = "Nettoyage manuel : données temporaires"; +"Clearing removes local temporary provider data." = "La suppression supprime les données du fournisseur temporaire local."; +"Total: %@" = "Total : %@"; +"%d more items" = "%d plus d'articles"; +"Cleanup ideas" = "Idées de nettoyage"; +"%d unreadable item(s) skipped" = "%d élément(s) illisible(s) ignoré(s)"; + +"API key limit" = "Limite de clé API"; +"Auth" = "Authentification"; +"Auto" = "Auto"; +"Disabled — no recent data" = "Désactivé – aucune donnée récente"; +"Limits not available" = "Limites non disponibles"; +"No usage yet" = "Pas encore d'utilisation"; +"Not fetched yet" = "Pas encore récupéré"; +"Refreshing" = "Actualisation"; +"Session" = "Session"; +"Source" = "Source"; +"State" = "État"; +"Unavailable" = "Indisponible"; +"Weekly" = "Hebdomadaire"; +"not detected" = "non détecté"; +"Estimated from local Codex logs for the selected account." = "Estimé à partir des journaux Codex locaux pour le compte sélectionné."; +"minimax_usage_amount_format" = "Utilisation : %@ / %@"; +"minimax_used_percent_format" = "Utilisé %@"; +"minimax_service_text_generation" = "Génération de texte"; +"minimax_service_text_to_speech" = "Synthèse vocale"; +"minimax_service_music_generation" = "Génération de musique"; +"minimax_service_image_generation" = "Génération d'images"; +"minimax_service_lyrics_generation" = "Génération de paroles"; +"minimax_service_coding_plan_vlm" = "Plan de codage VLM"; +"minimax_service_coding_plan_search" = "Recherche de plan de codage"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ attend l'autorisation"; +"%@ requests" = "%@ requêtes"; +"%@: %@ credits" = "%@ : %@ crédits"; +"30d requests" = "demandes 30j"; +"4 days" = "4 jours"; +"5 days" = "5 jours"; +"7 days" = "7 jours"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "La clé API vérifie l'accès à Ollama Cloud ; les cookies exposent toujours des limites de quota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID de clé d'accès AWS. Peut également être défini avec AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Région AWS. Peut également être défini avec AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Clé d'accès secrète AWS. Peut également être défini avec AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID de la clé d'accès"; +"Add Account" = "Ajouter un compte"; +"Adding Account…" = "Ajout d'un compte…"; +"Antigravity login failed" = "La connexion antigravité a échoué"; +"Antigravity login timed out" = "La connexion antigravité a expiré"; +"Auth source" = "Source d'authentification"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importe automatiquement les cookies du navigateur Chrome de Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importe automatiquement les données de session Windsurf à partir du navigateur Chromium localStorage."; +"Automatic imports browser cookies from Bailian." = "Importe automatiquement les cookies du navigateur depuis Bailian."; +"Automatically imports browser cookies." = "Importe automatiquement les cookies du navigateur."; +"Automatically imports browser session cookies." = "Importe automatiquement les cookies de session du navigateur."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nom du déploiement Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME est également pris en charge."; +"Azure OpenAI key" = "Clé Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Point de terminaison de ressource Azure OpenAI. AZURE_OPENAI_ENDPOINT est également pris en charge."; +"Base URL" = "URL de base"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL de base pour l'instance LLM-API-Key-Proxy."; +"Browser cookies" = "Cookies du navigateur"; +"Cap end" = "Fin du capuchon"; +"Cap start" = "Début du plafond"; +"Capacity End" = "Fin de capacité"; +"Capacity Start" = "Capacité Début"; +"Changelog" = "Journal des modifications"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Choisissez l'hôte API Moonshot/Kimi pour les comptes internationaux ou en Chine continentale."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar ne peut pas remplacer un compte système connecté par une configuration de clé API uniquement."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar n'a pas pu trouver l'authentification enregistrée pour ce compte. Ré-authentifiez-le et réessayez."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar n'a pas pu lire le stockage du compte géré. Récupérez la boutique avant d'ajouter un autre compte."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar n'a pas pu lire l'authentification enregistrée pour ce compte. Ré-authentifiez-le et réessayez."; +"CodexBar could not read the current system account on this Mac." = "CodexBar n'a pas pu lire le compte système actuel sur ce Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar n'a pas pu remplacer l'authentification Codex en direct sur ce Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar n'a pas pu conserver en toute sécurité le compte système actuel avant le changement."; +"CodexBar could not save the current system account before switching." = "CodexBar n'a pas pu enregistrer le compte système actuel avant de changer."; +"CodexBar could not update managed account storage." = "CodexBar n'a pas pu mettre à jour le stockage du compte géré."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar a trouvé un autre compte géré qui utilise déjà le compte système actuel. Résolvez le compte en double avant de changer."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar demandera au Trousseau macOS « %@ » afin de pouvoir décrypter les cookies du navigateur et authentifier votre compte. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS le jeton Claude Code OAuth afin de pouvoir récupérer votre utilisation de Claude. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS l'en-tête de votre cookie Amp afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie Augment afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie Claude afin de pouvoir récupérer l'utilisation du Web de Claude. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS l'en-tête de votre cookie Cursor afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS l'en-tête de votre cookie d'usine afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre jeton GitHub Copilot afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre clé API Kimi K2 afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre jeton d'authentification Kimi afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre jeton API MiniMax afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie MiniMax afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie OpenAI afin de pouvoir récupérer les extras du tableau de bord Codex. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie OpenCode afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre clé API synthétique afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre jeton API z.ai afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"Could not open Cursor login in your browser." = "Impossible d'ouvrir la connexion par curseur dans votre navigateur."; +"Could not open browser for Antigravity" = "Impossible d'ouvrir le navigateur pour Antigravity"; +"Credits used" = "Crédits utilisés"; +"Day" = "Jour"; +"Deployment" = "Déploiement"; +"Drag to reorder" = "Faites glisser pour réorganiser"; +"Endpoint" = "Point de terminaison"; +"Enterprise host" = "Hôte d'entreprise"; +"Extra usage balance: %@" = "Solde d'utilisation supplémentaire : %@"; +"Keychain Access Required" = "Accès au Trousseau requis"; +"Kiro menu bar value" = "Valeur de la barre de menu Kiro"; +"Label" = "Libellé"; +"No organizations loaded. Click Refresh after setting your API key." = "Aucune organisation chargée. Cliquez sur Actualiser après avoir défini votre clé API."; +"No output captured." = "Aucune sortie capturée."; +"No system account" = "Aucun compte système"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Ouvrir l'augmentation (déconnexion et reconnexion)"; +"Open Codebuff Dashboard" = "Ouvrir le tableau de bord Codebuff"; +"Open Command Code Settings" = "Ouvrir les paramètres du code de commande"; +"Open Crof dashboard" = "Ouvrir le tableau de bord Crof"; +"Open Manus" = "Ouvrir Manus"; +"Open MiMo Balance" = "Ouvrir la balance MiMo"; +"Open Moonshot Console" = "Ouvrir la console Moonshot"; +"Open Ollama API Keys" = "Ouvrir les clés API Ollama"; +"Open StepFun Platform" = "Ouvrir la plateforme StepFun"; +"Open T3 Chat Settings" = "Ouvrir les paramètres de discussion T3"; +"Open Volcengine Ark Console" = "Ouvrir la console Volcengine Ark"; +"Open legacy provider docs" = "Ouvrir les documents du fournisseur existant"; +"Open projects" = "Projets ouverts"; +"Open this URL manually to continue login:\n\n%@" = "Ouvrez cette URL manuellement pour continuer la connexion :\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID d'organisation facultatif pour les comptes liés à plusieurs organisations Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Facultatif. S'applique à la clé API Admin configurée ; Les comptes de jetons sélectionnés n'héritent pas d'OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Facultatif. Entrez votre hôte GitHub Enterprise, par exemple octocorp.ghe.com. Laissez vide pour github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Facultatif. Laissez vide pour découvrir et regrouper les projets visibles par la clé API."; +"Org ID (optional)" = "ID de l'organisation (facultatif)"; +"Organizations" = "Organisations"; +"Password" = "Mot de passe"; +"%@ authentication is disabled." = "L'authentification %@ est désactivée."; +"%@ cookies are disabled." = "Les cookies %@ sont désactivés."; +"%@ web API access is disabled." = "L'accès à l'API Web %@ est désactivé."; +"Disable %@ dashboard cookie usage." = "Désactivez l'utilisation des cookies du tableau de bord %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "L'accès au Trousseau est désactivé dans Advanced, donc l'importation des cookies du navigateur n'est pas disponible."; +"Manually paste an %@ from a browser session." = "Collez manuellement un %@ à partir d'une session de navigateur."; +"Paste a Cookie header captured from %@." = "Collez un en-tête de cookie capturé à partir de %@."; +"Paste a Cookie header from %@." = "Collez un en-tête de cookie à partir de %@."; +"Paste a Cookie header or cURL capture from %@." = "Collez un en-tête de cookie ou une capture cURL à partir de %@."; +"Paste a Cookie header or full cURL capture from %@." = "Collez un en-tête de cookie ou une capture cURL complète à partir de %@."; +"Paste a Cookie or Authorization header from %@." = "Collez un en-tête de cookie ou d'autorisation à partir de %@."; +"Paste a full cookie header or the %@ value." = "Collez un en-tête de cookie complet ou la valeur %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Collez un en-tête de cookie ou une capture cURL complète à partir des paramètres de T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Collez l'en-tête Cookie d'une requête vers admin.mistral.ai. Doit contenir un cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Collez le jeton Oasis à partir d'une session de navigateur connectée sur platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Collez le bundle JSON %@ de %@."; +"Paste the %@ value or a full Cookie header." = "Collez la valeur %@ ou un en-tête de cookie complet."; +"Personal account" = "Compte personnel"; +"Project ID" = "ID du projet"; +"Re-auth" = "Se reconnecter"; +"Re-authenticating…" = "Réauthentification…"; +"Refresh Session" = "Session de rafraîchissement"; +"Refresh organizations" = "Actualiser les organisations"; +"Region" = "Région"; +"Reload" = "Recharger"; +"Reorder" = "Réorganiser"; +"Secret access key" = "Clé d'accès secrète"; +"Series" = "Séries"; +"Service" = "Service"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Affichez ou masquez les crédits Kiro, le pourcentage ou les deux à côté de l'icône de la barre de menu."; +"Show usage for organizations you belong to. Personal account is always shown." = "Afficher l'utilisation des organisations auxquelles vous appartenez. Le compte personnel est toujours affiché."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Connectez-vous à cursor.com dans votre navigateur, puis actualisez Cursor dans CodexBar."; +"Simulated error text" = "Texte d'erreur simulé"; +"StepFun platform account (phone number or email)." = "Compte de la plateforme StepFun (numéro de téléphone ou email)."; +"Stored in ~/.codexbar/config.json." = "Stocké dans ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Stocké dans ~/.codexbar/config.json. AZURE_OPENAI_API_KEY est également pris en charge."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Stocké dans ~/.codexbar/config.json. Pour l'API Kimi officielle, utilisez l'API Moonshot / Kimi."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé API depuis la console Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé dans les paramètres d'Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé sur console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé sur Elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé sur openrouter.ai/settings/keys et définissez-y une limite de dépenses pour activer le suivi des quotas de clés API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Stocké dans ~/.codexbar/config.json. Dans Warp, ouvrez Paramètres > Plateforme > Clés API, puis créez-en une."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Stocké dans ~/.codexbar/config.json. Les métriques nécessitent un accès à Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Stocké dans ~/.codexbar/config.json. OPENAI_ADMIN_KEY est préféré ; OPENAI_API_KEY fonctionne toujours."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Stocké dans ~/.codexbar/config.json. Nécessite une clé API Anthropic Admin."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Stocké dans ~/.codexbar/config.json. Utilisé pour /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Stocké dans ~/.codexbar/config.json. Vous pouvez également fournir CODEBUFF_API_KEY ou laisser CodexBar lire ~/.config/manicode/credentials.json (créé par `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Stocké dans ~/.codexbar/config.json. Vous pouvez également fournir CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Stocké dans ~/.codexbar/config.json. Vous pouvez également fournir KILO_API_KEY ou ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie de discussion T3"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Ce compte n'est plus disponible dans CodexBar. Actualisez la liste des comptes et réessayez."; +"The browser login did not complete in time. Try Antigravity login again." = "La connexion au navigateur ne s'est pas terminée à temps. Essayez à nouveau de vous connecter à Antigravity."; +"Timed out waiting for Cursor login. %@" = "Le délai d'attente pour la connexion au curseur a expiré. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Le délai d'attente pour la connexion au curseur a expiré. %@ Dernière erreur : %@"; +"Today requests" = "Demandes d'aujourd'hui"; +"Total (30d): %@ credits" = "Total (30j) : %@ crédits"; +"Username" = "Nom d’utilisateur"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Utilise le nom d'utilisateur + le mot de passe pour se connecter et obtenir automatiquement un jeton Oasis."; +"Uses username + password to login and obtain an %@ automatically." = "Utilise le nom d'utilisateur + le mot de passe pour se connecter et obtenir automatiquement un %@."; +"Utilization End" = "Fin d'utilisation"; +"Utilization Start" = "Début de l'utilisation"; +"Verbosity" = "Niveau de verbosité"; +"Windsurf session JSON bundle" = "Pack JSON de session de planche à voile"; +"Workspace ID" = "ID de l'espace de travail"; +"Your StepFun platform password. Used to login and obtain a session token." = "Votre mot de passe de la plateforme StepFun. Utilisé pour se connecter et obtenir un jeton de session."; +"claude /login exited with status %d." = "claude /login est sorti avec le statut %d."; +"codex login exited with status %d." = "La connexion à Codex s'est terminée avec le statut %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie : …\n\nou collez une capture cURL à partir du tableau de bord Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie : …\n\nou collez la valeur __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie : …\n\nou collez la valeur du jeton kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou collez uniquement la valeur session_id"; +"Clear" = "Effacer"; +"No matching providers" = "Aucun fournisseur correspondant"; +"Search providers" = "Fournisseurs de recherche"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index bf8433340..e949ac7eb 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "Chinês simplificado"; "language_chinese_traditional" = "Chinês tradicional"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_french" = "Francês"; "start_at_login_title" = "Iniciar ao fazer login"; "start_at_login_subtitle" = "Abre o CodexBar automaticamente ao iniciar o Mac."; "show_cost_summary" = "Mostrar resumo de custos"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 429d98e06..a4ad657cd 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -401,6 +401,7 @@ "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "language_swedish" = "Svenska"; +"language_french" = "Franska"; "start_at_login_title" = "Starta vid inloggning"; "start_at_login_subtitle" = "Öppnar CodexBar automatiskt när du startar din Mac."; "show_cost_summary" = "Visa kostnadssammanfattning"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index dfed63ad3..9aa345dba 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -406,6 +406,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_french" = "法语"; "start_at_login_title" = "开机启动"; "start_at_login_subtitle" = "启动 Mac 时自动打开 CodexBar。"; "show_cost_summary" = "显示费用摘要"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 46034fcf5..929497565 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -406,6 +406,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_french" = "法語"; "start_at_login_title" = "登入時啟動"; "start_at_login_subtitle" = "登入 Mac 時自動開啟 CodexBar。"; "show_cost_summary" = "顯示費用摘要"; From ae63897f3ee5375607d0a4e057dafa01edd6e330 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:35:45 +0800 Subject: [PATCH 30/93] Add native Ukrainian localization support Adds Ukrainian localization resources, registers Ukrainian in the language picker, preserves Swift interpolation placeholders, and includes the maintainer changelog entry. Thanks @Yuxin-Qiao! --- CHANGELOG.md | 1 + Sources/CodexBar/PreferencesGeneralPane.swift | 2 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1064 +++++++++++++++++ .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../LocalizationLanguageCatalogTests.swift | 33 + 12 files changed, 1108 insertions(+) create mode 100644 Sources/CodexBar/Resources/uk.lproj/Localizable.strings create mode 100644 Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b021e923..d1dbcbdb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Localization: add French as a selectable app language (#1241). Thanks @Yuxin-Qiao! +- Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! ### Fixed - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 4c943a571..15fc731b6 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -12,6 +12,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case portugueseBrazilian = "pt-BR" case swedish = "sv" case french = "fr" + case ukrainian = "uk" var id: String { self.rawValue @@ -28,6 +29,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .portugueseBrazilian: L("language_portuguese_brazilian") case .swedish: L("language_swedish") case .french: L("language_french") + case .ukrainian: L("language_ukrainian") } } } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 319e8846b..04c387a90 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -400,6 +400,7 @@ "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "language_french" = "Francès"; +"language_ukrainian" = "Ucraïnès"; "start_at_login_title" = "Obrir en iniciar la sessió"; "start_at_login_subtitle" = "Obre el CodexBar automàticament en iniciar el Mac."; "show_cost_summary" = "Mostra el resum de cost"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 6a7e1bf99..7d4e81fd6 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -401,6 +401,7 @@ "language_portuguese_brazilian" = "Português (Brasil)"; "language_swedish" = "Svenska"; "language_french" = "French"; +"language_ukrainian" = "Українська"; "start_at_login_title" = "Start at Login"; "start_at_login_subtitle" = "Automatically opens CodexBar when you start your Mac."; "show_cost_summary" = "Show cost summary"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 19728fb23..77d78e43b 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -400,6 +400,7 @@ "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "language_french" = "Francés"; +"language_ukrainian" = "Ucraniano"; "start_at_login_title" = "Abrir al iniciar sesión"; "start_at_login_subtitle" = "Abre CodexBar automáticamente al iniciar tu Mac."; "show_cost_summary" = "Mostrar resumen de coste"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 5371c450b..8b487362f 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -401,6 +401,7 @@ "language_portuguese_brazilian" = "Portugais (Brésil)"; "language_swedish" = "Suédois"; "language_french" = "Français"; +"language_ukrainian" = "Ukrainien"; "start_at_login_title" = "Lancer à l'ouverture de session"; "start_at_login_subtitle" = "Ouvre automatiquement CodexBar au démarrage de votre Mac."; "show_cost_summary" = "Afficher le récapitulatif des coûts"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index e949ac7eb..96786d8c5 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -400,6 +400,7 @@ "language_chinese_traditional" = "Chinês tradicional"; "language_portuguese_brazilian" = "Português (Brasil)"; "language_french" = "Francês"; +"language_ukrainian" = "Ucraniano"; "start_at_login_title" = "Iniciar ao fazer login"; "start_at_login_subtitle" = "Abre o CodexBar automaticamente ao iniciar o Mac."; "show_cost_summary" = "Mostrar resumo de custos"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index a4ad657cd..90eeb5540 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -402,6 +402,7 @@ "language_portuguese_brazilian" = "Português (Brasil)"; "language_swedish" = "Svenska"; "language_french" = "Franska"; +"language_ukrainian" = "Ukrainska"; "start_at_login_title" = "Starta vid inloggning"; "start_at_login_subtitle" = "Öppnar CodexBar automatiskt när du startar din Mac."; "show_cost_summary" = "Visa kostnadssammanfattning"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings new file mode 100644 index 000000000..21ec193d3 --- /dev/null +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -0,0 +1,1064 @@ +/* Ukrainian localization for CodexBar */ + +" providers" = "провайдерів"; +"(System)" = "(Система)"; +"30d" = "30д"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; +"API key" = "Ключ API"; +"API region" = "регіон API"; +"API token" = "Маркер API"; +"API tokens" = "маркери API"; +"About" = "Про програму"; +"Account" = "Обліковий запис"; +"Accounts" = "Облікові записи"; +"Accounts subtitle" = "Підзаголовок облікових записів"; +"Active" = "Активний"; +"Add" = "Додати"; +"Add Workspace" = "Додати робочу область"; +"Advanced" = "Розширені"; +"All" = "Усі"; +"Always allow prompts" = "Завжди дозволяти підказки"; +"Animation pattern" = "Шаблон анімації"; +"Antigravity login is managed in the app" = "Вхід в Antigravity керується в додатку"; +"Applies only to the Security.framework OAuth keychain reader." = "Застосовується лише до зчитувача брелоків OAuth Security.framework."; +"Auto falls back to the next source if the preferred one fails." = "Автоматичний перехід до наступного джерела, якщо бажане не вдається."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto спочатку використовує API, а потім повертається до CLI у разі помилок авторизації."; +"Auto-detect" = "Автоматичне визначення"; +"Auto-refresh is off; use the menu's Refresh command." = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; +"Auto-refresh: hourly · Timeout: 10m" = "Автоматичне оновлення: щогодини · Час очікування: 10 хв"; +"Automatic" = "Автоматично"; +"Automatic imports browser cookies and WorkOS tokens." = "Автоматично імпортує файли cookie браузера та маркери WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Автоматично імпортує файли cookie браузера та маркери локального зберігання."; +"Automatic imports browser cookies for dashboard extras." = "Автоматично імпортує файли cookie браузера для додаткових функцій панелі інструментів."; +"Automatic imports browser cookies for the web API." = "Автоматично імпортує файли cookie браузера для веб-API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Автоматично імпортує файли cookie браузера з Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Автоматично імпортує файли cookie браузера з admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Автоматично імпортує файли cookie браузера з opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Автоматично імпортує файли cookie браузера або збережені сесії."; +"Automatic imports browser cookies." = "Автоматично імпортує файли cookie браузера."; +"Automatically imports browser session cookie." = "Автоматично імпортує файл cookie сесії браузера."; +"Automatically opens CodexBar when you start your Mac." = "Автоматично відкриває CodexBar під час запуску Mac."; +"Automation" = "Автоматизація"; +"Average (\\(label1) + \\(label2))" = "Середній (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Середній (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Уникайте підказок Keychain"; +"Balance" = "Баланс"; +"Battery Saver" = "Економія батареї"; +"Bordered" = "З рамкою"; +"Build" = "Збірка"; +"Built \\(buildTimestamp)" = "Побудовано \\(buildTimestamp)"; +"Buy Credits..." = "Купити кредити..."; +"Buy Credits…" = "Купити кредити…"; +"CLI paths" = "Шляхи CLI"; +"CLI sessions" = "Сесії CLI"; +"Caches" = "Кеші"; +"Cancel" = "Скасувати"; +"Check for Updates…" = "Перевірити наявність оновлень…"; +"Check for updates automatically" = "Автоматично перевіряти наявність оновлень"; +"Check if you like your agents having some fun up there." = "Перевірте, чи подобається вам, що ваші агенти розважаються там."; +"Check provider status" = "Перевірте статус провайдера"; +"Choose Codex workspace" = "Виберіть робочу область Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Виберіть хост MiniMax (глобальний .io або материковий Китай .com)."; +"Choose up to " = "Виберіть до"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Виберіть до \\(Self.maxOverviewProviders) постачальників"; +"Choose up to \\(count) providers" = "Виберіть до \\(count) постачальників"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Виберіть, що відображати на панелі меню (Pace показує використання порівняно з очікуваним)."; +"Choose which Codex account CodexBar should follow." = "Виберіть, який обліковий запис Codex має дотримуватися CodexBar."; +"Choose which window drives the menu bar percent." = "Виберіть, яке вікно керує відсотками панелі меню."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI не знайдено"; +"Claude binary" = "Claude бінарний"; +"Claude cookies" = "Печиво Claude"; +"Claude login failed" = "Помилка входу Claude"; +"Claude login timed out" = "Час очікування входу Claude минув"; +"Close" = "Закрити"; +"Code review" = "Огляд коду"; +"Codex CLI not found" = "Codex CLI не знайдено"; +"Codex account login already running" = "Вхід до облікового запису Codex уже запущено"; +"Codex binary" = "Двійковий код Codex"; +"Codex login failed" = "Помилка входу в Codex"; +"Codex login timed out" = "Час очікування входу в Codex минув"; +"CodexBar Lifecycle Keepalive" = "Життєвий цикл CodexBar Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar не може показати піктограму панелі меню"; +"CodexBar could not read managed account storage. " = "CodexBar не вдалося прочитати сховище керованого облікового запису."; +"Configure…" = "Налаштувати…"; +"Connected" = "Підключено"; +"Controls how much detail is logged." = "Контролює, скільки деталей реєструється."; +"Cookie header" = "Заголовок файлу cookie"; +"Cookie source" = "Джерело файлів cookie"; +"Cookie: ..." = "Печиво: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте запис cURL із інформаційної панелі Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте значення __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте значення маркера kimi-auth"; +"Cookie: …" = "Печиво: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Вартість"; +"Could not add Codex account" = "Не вдалося додати обліковий запис Codex"; +"Could not open Terminal for Gemini" = "Не вдалося відкрити термінал для Gemini"; +"Could not start claude /login" = "Не вдалося запустити claude /login"; +"Could not start codex login" = "Не вдалося розпочати вхід до Codex"; +"Could not switch system account" = "Не вдалося змінити обліковий запис системи"; +"Credits" = "Кредити"; +"Credits history" = "Кредитна історія"; +"Cursor login failed" = "Помилка входу в систему курсору"; +"Custom" = "Користувацький"; +"Custom Path" = "Спеціальний шлях"; +"Daily Routines" = "Розпорядок дня"; +"Debug" = "Налагодження"; +"Default" = "Типово"; +"Disable Keychain access" = "Вимкнути доступ Keychain"; +"Disabled" = "Вимкнено"; +"Dismiss" = "Закрити"; +"Disconnected" = "Відключено"; +"Display" = "Відображення"; +"Display mode" = "Режим відображення"; +"Display reset times as absolute clock values instead of countdowns." = "Відображення часу скидання як абсолютних значень годинника замість зворотного відліку."; +"Done" = "Готово"; +"Effective PATH" = "Ефективний ШЛЯХ"; +"Email" = "Ел. пошта"; +"Enable Merge Icons to configure Overview tab providers." = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; +"Enable file logging" = "Увімкнути журналювання файлів"; +"Enabled" = "Увімкнено"; +"Error" = "Помилка"; +"Error simulation" = "Симуляція помилок"; +"Expose troubleshooting tools in the Debug tab." = "Розкрийте інструменти усунення несправностей на вкладці Debug."; +"Failed" = "Помилка"; +"False" = "Неправда"; +"Fetch strategy attempts" = "Спроби отримання стратегії"; +"Fetching" = "Отримання"; +"Field" = "Поле"; +"Field subtitle" = "Підзаголовок поля"; +"Finish the current managed account change before switching the system account." = "Завершіть зміну поточного керованого облікового запису, перш ніж змінювати обліковий запис системи."; +"Force animation on next refresh" = "Примусово запускати анімацію під час наступного оновлення"; +"Gateway region" = "Регіон шлюзу"; +"Gemini CLI not found" = "Gemini CLI не знайдено"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, інциденти на поверхні в іконці та меню."; +"General" = "Загальні"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Логін GitHub Copilot"; +"GitHub Login" = "Вхід на GitHub"; +"Hide details" = "Приховати деталі"; +"Hide personal information" = "Приховати особисту інформацію"; +"Historical tracking" = "Історичне відстеження"; +"How often CodexBar polls providers in the background." = "Як часто CodexBar опитує постачальників у фоновому режимі."; +"Inactive" = "Неактивний"; +"Install CLI" = "Встановіть CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Встановіть CLAude CLI (npm i -g @anthropic-ai/claude-code) і повторіть спробу."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Встановіть Codex CLI (npm i -g @openai/codex) і повторіть спробу."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Установіть Gemini CLI (npm i -g @google/gemini-cli) і повторіть спробу."; +"JetBrains AI is ready" = "JetBrains AI готовий"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Підтримуйте сесії CLI"; +"Keyboard shortcut" = "Комбінація клавіш"; +"Keychain access" = "Доступ через брелок"; +"Keychain prompt policy" = "Політика оперативного брелока"; +"Last \\(name) fetch failed:" = "Помилка останнього \\(name) отримання:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Помилка отримання останнього \\(self.store.metadata(for: self.provider).displayName):"; +"Last attempt" = "Остання спроба"; +"Link" = "Посилання"; +"Loading animations" = "Завантаження анімацій"; +"Loading…" = "Завантаження…"; +"Local" = "Місцевий"; +"Logging" = "Лісозаготівля"; +"Login failed" = "Помилка входу"; +"Login shell PATH (startup capture)" = "ШЛЯХ до оболонки входу (запис під час запуску)"; +"Login timed out" = "Час входу минув"; +"MCP details" = "Деталі MCP"; +"Managed Codex accounts unavailable" = "Керовані облікові записи Codex недоступні"; +"Managed account storage is unreadable. Live account access is still available, " = "Сховище керованого облікового запису не читається. Доступ до реального облікового запису все ще доступний,"; +"Manual" = "Вручну"; +"May your tokens never run out—keep agent limits in view." = "Нехай ваші токени ніколи не закінчаться — пам’ятайте про ліміти агентів."; +"Menu bar" = "Рядок меню"; +"Menu bar auto-shows the provider closest to its rate limit." = "Рядок меню автоматично показує постачальника, який найближче до ліміту."; +"Menu bar metric" = "Метрика панелі меню"; +"Menu bar shows percent" = "Рядок меню показує відсотки"; +"Menu content" = "Зміст меню"; +"Merge Icons" = "Злиття значків"; +"Never prompt" = "Ніколи не підказуйте"; +"No" = "Ні"; +"No Codex accounts detected yet." = "Облікових записів Codex ще не виявлено."; +"No JetBrains IDE detected" = "JetBrains IDE не виявлено"; +"No cost history data." = "Немає даних історії витрат."; +"No data available" = "Немає даних"; +"No data yet" = "Даних ще немає"; +"No enabled providers available for Overview." = "Немає активованих постачальників, доступних для огляду."; +"No providers selected" = "Не вибрано жодного постачальника"; +"No token accounts yet." = "Жетонів ще немає."; +"No usage breakdown data." = "Немає даних про використання."; +"None" = "Жодного"; +"Notifications" = "Сповіщення"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Повідомляє, коли 5-годинна квота сеансу досягає 0% і коли вона стає"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Незрозумілі адреси електронної пошти на панелі меню та інтерфейсі меню."; +"Off" = "Вимкнено"; +"Offline" = "Офлайн"; +"On" = "Увімкнено"; +"Online" = "Онлайн"; +"Only on user action" = "Тільки за діями користувача"; +"Open" = "Відкрити"; +"Open API Keys" = "Відкрити ключі API"; +"Open Amp Settings" = "Відкрийте налаштування підсилювача"; +"Open Antigravity to sign in, then refresh CodexBar." = "Відкрийте Antigravity, щоб увійти, а потім оновіть CodexBar."; +"Open Browser" = "Відкрийте браузер"; +"Open Coding Plan" = "Відкрити план кодування"; +"Open Console" = "Відкрийте консоль"; +"Open Dashboard" = "Відкрийте інформаційну панель"; +"Open Mistral Admin" = "Відкрийте Mistral Admin"; +"Open Menu Bar Settings" = "Відкрийте панель меню Параметри"; +"Open Ollama Settings" = "Відкрийте налаштування Ollama"; +"Open Terminal" = "Відкрийте термінал"; +"Open Usage Page" = "Відкрити сторінку використання"; +"Open Warp API Key Guide" = "Відкрийте посібник з ключів API Warp"; +"Open menu" = "Відкрити меню"; +"Open token file" = "Відкрити файл маркера"; +"OpenAI cookies" = "Файли cookie OpenAI"; +"OpenAI web extras" = "Веб-додатки OpenAI"; +"Option A" = "Варіант А"; +"Option B" = "Варіант Б"; +"Optional override if workspace lookup fails." = "Додаткове перевизначення, якщо пошук робочої області не вдається."; +"Options" = "Опції"; +"Override auto-detection with a custom IDE base path" = "Замініть автоматичне виявлення власним базовим шляхом IDE"; +"Overview" = "Огляд"; +"Overview rows always follow provider order." = "Оглядові рядки завжди відповідають порядку постачальника."; +"Overview tab providers" = "Постачальники вкладок огляду"; +"Paste API key…" = "Вставити ключ API…"; +"Paste API token…" = "Вставити маркер API…"; +"Paste key…" = "Вставити ключ…"; +"Paste sessionKey or OAuth token…" = "Вставте sessionKey або маркер OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Вставте заголовок Cookie із запиту до admin.mistral.ai."; +"Paste token…" = "Вставити маркер…"; +"Personal" = "Особистий"; +"Picker" = "Пікер"; +"Picker subtitle" = "Підзаголовок засобу вибору"; +"Placeholder" = "Заповнювач"; +"Plan" = "План"; +"Play full-screen confetti when weekly usage resets." = "Відтворення конфетті на весь екран, коли тижневе використання скидається."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Опитує сторінки статусу OpenAI/Claude і Google Workspace для"; +"Prevents any Keychain access while enabled." = "Запобігає будь-якому доступу Keychain, коли ввімкнено."; +"Primary (API key limit)" = "Основний (обмеження ключа API)"; +"Primary (\\(label))" = "Основний (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Основний (\\(metadata.sessionLabel))"; +"Probe logs" = "Зондові журнали"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Індикатори прогресу заповнюються, коли ви витрачаєте квоту (замість відображення залишку)."; +"Provider" = "Провайдер"; +"Providers" = "Провайдери"; +"Quit CodexBar" = "Закрийте CodexBar"; +"Random (default)" = "Випадковий (за замовчуванням)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Читає локальні журнали використання. Показує сьогодні + вибране вікно історії в меню."; +"Refresh" = "Оновити"; +"Refresh cadence" = "Оновити каденцію"; +"Remote" = "Дистанційний"; +"Remove" = "Видалити"; +"Remove Codex account?" = "Видалити обліковий запис Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Видалити \\(account.email) з CodexBar? Його керовану домашню сторінку Codex буде видалено."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Видалити \\(email) з CodexBar? Його керовану домашню сторінку Codex буде видалено."; +"Remove selected account" = "Видалити вибраний обліковий запис"; +"Replace critter bars with provider branding icons and a percentage." = "Замініть смужки тварин на значки бренду постачальника та відсоток."; +"Replay selected animation" = "Повторити вибрану анімацію"; +"Requires authentication via GitHub Device Flow." = "Потрібна автентифікація через GitHub Device Flow."; +"Resets: \\(reset)" = "Скидання: \\(reset)"; +"Rolling five-hour limit" = "Рухливий п'ятигодинний ліміт"; +"Search hourly" = "Пошук щогодини"; +"Secondary (\\(label))" = "Вторинний (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Вторинний (\\(metadata.weeklyLabel))"; +"Select a provider" = "Виберіть провайдера"; +"Select the IDE to monitor" = "Виберіть IDE для моніторингу"; +"Session quota notifications" = "Сповіщення про квоту сеансу"; +"Session tokens" = "Токени сесії"; +"Settings" = "Налаштування"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Показати в меню розділи використання кредитів Codex і Claude Extra."; +"Show Debug Settings" = "Показати налаштування налагодження"; +"Show all token accounts" = "Показати всі облікові записи маркерів"; +"Show cost summary" = "Показати підсумок витрат"; +"Show credits + extra usage" = "Показати кредити + додаткове використання"; +"Show details" = "Показати деталі"; +"Show most-used provider" = "Показати постачальника, який найчастіше використовується"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Показувати піктограми постачальників у комутаторі (інакше показувати щотижневий рядок прогресу)."; +"Show reset time as clock" = "Показувати час скидання як годинник"; +"Show usage as used" = "Показати використання як використане"; +"Sign in via button below" = "Увійдіть за допомогою кнопки нижче"; +"Skip teardown between probes (debug-only)." = "Пропустити демонтаж між зондами (лише для налагодження)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Облікові записи маркерів стека в меню (інакше відображати панель перемикання облікових записів)."; +"Start at Login" = "Почніть із входу"; +"Status" = "Статус"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Зберігайте файли cookie Claude sessionKey або маркери доступу OAuth."; +"Store multiple Abacus AI Cookie headers." = "Зберігайте кілька заголовків файлів cookie Abacus AI."; +"Store multiple Augment Cookie headers." = "Зберігайте кілька заголовків Augment Cookie."; +"Store multiple Cursor Cookie headers." = "Зберігайте кілька заголовків Cursor Cookie."; +"Store multiple Factory Cookie headers." = "Зберігайте кілька заголовків Factory Cookie."; +"Store multiple MiniMax Cookie headers." = "Зберігайте кілька заголовків MiniMax Cookie."; +"Store multiple Mistral Cookie headers." = "Зберігайте кілька заголовків Mistral Cookie."; +"Store multiple Ollama Cookie headers." = "Зберігайте кілька заголовків файлів cookie Ollama."; +"Store multiple OpenCode Cookie headers." = "Зберігайте кілька заголовків файлів cookie OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Зберігайте кілька заголовків OpenCode Go Cookie."; +"Stored in the CodexBar config file." = "Зберігається у конфігураційному файлі CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Зберігається в ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Зберігається в ~/.codexbar/config.json. Згенеруйте його на kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Зберігається в ~/.codexbar/config.json. Вставте ключ із панелі приладів Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Зберігається в ~/.codexbar/config.json. Вставте ключ API плану кодування з Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Зберігається в ~/.codexbar/config.json. Вставте ключ MiniMax API."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Зберігається в ~/.codexbar/config.json. Ви також можете надати KILO_API_KEY або"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Зберігає локальну історію використання Codex (8 тижнів) для персоналізації прогнозів Pace."; +"Subscription Utilization" = "Використання підписки"; +"Surprise me" = "Здивуйте мене"; +"Switcher shows icons" = "Перемикач показує значки"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Символьне посилання CodexBarCLI на /usr/local/bin і /opt/homebrew/bin як codexbar."; +"System" = "Система"; +"Temporarily shows the loading animation after the next refresh." = "Тимчасово показує анімацію завантаження після наступного оновлення."; +"Tertiary (\\(label))" = "Вищий (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Вищий (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Обліковий запис Codex за умовчанням на цьому Mac."; +"Toggle" = "Перемикач"; +"Toggle subtitle" = "Перемкнути субтитри"; +"Token" = "Токен"; +"Trigger the menu bar menu from anywhere." = "Викликати меню панелі меню з будь-якого місця."; +"True" = "Так"; +"Twitter" = "Twitter"; +"Unsupported" = "Не підтримується"; +"Update Channel" = "Оновити канал"; +"Updated" = "Оновлено"; +"Updates unavailable in this build." = "Оновлення недоступні в цій збірці."; +"Usage" = "Використання"; +"Usage breakdown" = "Розбивка використання"; +"Usage history (30 days)" = "Історія використання"; +"Usage source" = "Джерело використання"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Використовуйте BigModel для кінцевих точок материкового Китаю (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Використовуйте одну піктограму панелі меню з перемикачем провайдерів."; +"Use international or China mainland console gateways for quota fetches." = "Використовуйте міжнародні консольні шлюзи або шлюзи материкової частини Китаю для отримання квот."; +"Version" = "Версія"; +"Version \\(self.versionString)" = "Версія \\(self.versionString)"; +"Version \\(version)" = "Версія \\(version)"; +"Version \\(versionString)" = "Версія \\(versionString)"; +"Vertex AI Login" = "Вхід у Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Перш ніж додавати інший обліковий запис, дочекайтеся завершення поточного керованого входу в Codex."; +"Waiting for Authentication..." = "Очікування автентифікації..."; +"Website" = "Веб-сайт"; +"Weekly limit confetti" = "Щотижневий ліміт конфетті"; +"Weekly token limit" = "Тижневий ліміт жетонів"; +"Weekly usage" = "Щотижневе використання"; +"Weekly usage unavailable for this account." = "Щотижневе використання недоступне для цього облікового запису."; +"Window: \\(window)" = "Вікно: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Записати журнали в \\(self.fileLogPath) для налагодження."; +"Yes" = "Так"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 дн \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): отримання…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): остання спроба \\(when)"; +"\\(name): no data yet" = "\\(name): ще немає даних"; +"\\(name): unsupported" = "\\(name): не підтримується"; +"all browsers" = "всі браузери"; +"available again." = "знову доступний."; +"built_format" = "Побудовано %@"; +"copilot_complete_in_browser" = "Завершіть вхід у свій браузер."; +"copilot_device_code" = "Код пристрою скопійовано в буфер обміну: %1$@\n\nПеревірити за адресою: %2$@"; +"copilot_device_code_copied" = "Код пристрою скопійовано."; +"copilot_verify_at" = "Підтвердити в %@"; +"copilot_waiting_text" = "Завершіть вхід у свій браузер.\nЦе вікно закриється автоматично, коли вхід завершиться."; +"copilot_window_closes_auto" = "Це вікно закривається автоматично після завершення входу."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: отримання… %2$@"; +"cost_status_last_attempt" = "%1$@: остання спроба %2$@"; +"cost_status_no_data" = "%@: ще немає даних"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: не підтримується"; +"credits_remaining" = "Кредити: %@"; +"cursor_on_demand" = "На вимогу: %@"; +"cursor_on_demand_with_limit" = "На вимогу: %1$@ / %2$@"; +"extra_usage_format" = "Додаткове використання: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Виявлено: %@. Скористайтеся помічником штучного інтелекту один раз, щоб створити дані квоти, а потім оновіть CodexBar."; +"jetbrains_detected_select" = "Виявлено: %@. Виберіть бажану IDE у налаштуваннях, а потім оновіть CodexBar."; +"last_fetch_failed_with_provider" = "Помилка останнього %@ отримання:"; +"last_spend" = "Останні витрати: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Скидання: %@"; +"mcp_window" = "Вікно: %@"; +"metric_average" = "Середній (%1$@ + %2$@)"; +"metric_primary" = "Основний (%@)"; +"metric_secondary" = "Вторинний (%@)"; +"metric_tertiary" = "Вищий (%@)"; +"multiple_workspaces_found" = "CodexBar знайшов кілька робочих областей для %@. Виберіть робочу область для додавання."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Виберіть до %@ постачальників"; +"remove_account_message" = "Видалити %@ з CodexBar? Його керовану домашню сторінку Codex буде видалено."; +"version_format" = "Версія %@"; +"vertex_ai_login_instructions" = "Щоб відстежувати використання Vertex AI, пройдіть автентифікацію в Google Cloud.\n\n1. Відкрийте термінал\n2. Запустіть: gcloud auth application-default login\n3. Дотримуйтесь підказок браузера, щоб увійти\n4. Налаштуйте свій проект: gcloud config set project PROJECT_ID\n\nВідкрити термінал зараз?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID встановлено, але лише opencode, opencodego та deepgram підтримують workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Пітер Штайнбергер. Ліцензія MIT."; + +/* General Pane */ +"section_system" = "Система"; +"section_usage" = "Використання"; +"section_automation" = "Автоматизація"; +"language_title" = "Мова"; +"language_subtitle" = "Змінює мову інтерфейсу. Для повного застосування потрібно перезапустити застосунок."; +"language_system" = "Система"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Svenska"; +"language_french" = "Français"; +"language_ukrainian" = "Українська"; +"start_at_login_title" = "Почніть із входу"; +"start_at_login_subtitle" = "Автоматично відкриває CodexBar під час запуску Mac."; +"show_cost_summary" = "Показати підсумок витрат"; +"show_cost_summary_subtitle" = "Читає локальні журнали використання. Показує сьогодні + вибране вікно історії в меню."; +"cost_history_days_title" = "Вікно історії: %d днів"; +"cost_auto_refresh_info" = "Автоматичне оновлення: щогодини · Час очікування: 10 хв"; +"refresh_cadence_title" = "Оновити каденцію"; +"refresh_cadence_subtitle" = "Як часто CodexBar опитує постачальників у фоновому режимі."; +"manual_refresh_hint" = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; +"check_provider_status_title" = "Перевірте статус провайдера"; +"check_provider_status_subtitle" = "Опитує сторінки статусу OpenAI/Claude і Google Workspace для Gemini/Antigravity, виявляючи інциденти в значку та меню."; +"session_quota_notifications_title" = "Сповіщення про квоту сеансу"; +"session_quota_notifications_subtitle" = "Повідомляє, коли 5-годинна квота сеансу досягає 0% і коли вона знову стає доступною."; +"quota_warning_notifications_title" = "Попередження про квоту"; +"quota_warning_notifications_subtitle" = "Попереджає, коли залишок сеансу або тижневої квоти перевищує налаштовані порогові значення."; +"quota_warnings_title" = "Попередження про квоту"; +"quota_warning_session" = "сесії"; +"quota_warning_session_capitalized" = "Сесія"; +"quota_warning_weekly" = "щотижня"; +"quota_warning_weekly_capitalized" = "Щотижня"; +"quota_warning_notification_title" = "%1$@ %2$@ квота низька"; +"quota_warning_notification_body" = "%1$@ залишилося. Досягнуто %2$d%% %3$@ порогового значення попередження."; +"quota_warning_notification_body_with_account" = "Рахунок %1$@. Залишилося %2$@. Досягнуто %3$d%% %4$@ порогового значення попередження."; +"session_depleted_notification_title" = "%@ сеанс вичерпано"; +"session_depleted_notification_body" = "Залишилося 0%. Надішле сповіщення, коли знову стане доступним."; +"session_restored_notification_title" = "%@ сеанс відновлено"; +"session_restored_notification_body" = "Квота сесії знову доступна."; +"quota_warning_warn_at" = "Попередити при"; +"quota_warning_global_threshold_subtitle" = "Відсотки, що залишилися для вікон сесії та тижня, якщо постачальник не замінить їх."; +"quota_warning_sound" = "Відтворити звук сповіщення"; +"quota_warning_provider_inherits" = "Використовує глобальні параметри попередження про квоту, якщо тут не налаштовано вікно."; +"quota_warning_customize_thresholds" = "Налаштувати порогові значення %@"; +"quota_warning_enable_warnings" = "Увімкнути %@ попереджень"; +"quota_warning_window_warn_at" = "%@ попередити о"; +"quota_warning_off" = "Вимкнено"; +"quota_warning_inherited" = "Успадковано: %@"; +"quota_warning_depleted_only" = "лише виснажені"; +"quota_warning_upper" = "Верхній"; +"quota_warning_lower" = "Нижній"; +"apply" = "Застосувати"; +"quit_app" = "Закрийте CodexBar"; + +/* Tab titles */ +"tab_general" = "Загальні"; +"tab_providers" = "Провайдери"; +"tab_display" = "Відображення"; +"tab_advanced" = "Розширені"; +"tab_about" = "Про програму"; +"tab_debug" = "Налагодження"; + +/* Providers Pane */ +"select_a_provider" = "Виберіть провайдера"; +"cancel" = "Скасувати"; +"last_fetch_failed" = "остання вибірка не вдалася"; +"usage_not_fetched_yet" = "використання ще не отримано"; +"managed_account_storage_unreadable" = "Сховище керованого облікового запису не читається. Доступ до поточного облікового запису все ще доступний, але керовані дії додавання, повторної авторизації та видалення вимкнено, доки магазин не буде відновлено."; +"remove_codex_account_title" = "Видалити обліковий запис Codex?"; +"remove" = "видалити"; +"managed_login_already_running" = "Керований вхід до Codex вже запущено. Зачекайте, поки це завершиться, перш ніж додавати або повторно автентифікувати інший обліковий запис."; +"managed_login_failed" = "Керований вхід Codex не завершено. Переконайтеся, що `codex --version` працює в терміналі. Якщо macOS заблокувала або перемістила `codex` у кошик, видаліть застарілі повторювані встановлення, запустіть `npm install -g --include=optional @openai/codex@latest`, а потім повторіть спробу."; +"codex_login_output" = "код входу в систему:"; +"managed_login_missing_email" = "Вхід до Codex завершено, але електронна адреса облікового запису недоступна. Повторіть спробу після того, як підтвердите, що обліковий запис повністю ввійшли."; +"login_success_notification_title" = "%@ вхід успішний"; +"login_success_notification_body" = "Ви можете повернутися до програми; аутентифікація завершена."; +"workspace_selection_cancelled" = "CodexBar знайшов кілька робочих областей, але жодна робоча область не була вибрана."; +"unsafe_managed_home" = "CodexBar відмовився змінити неочікуваний керований домашній шлях: %@"; +"menu_bar_metric_title" = "Метрика панелі меню"; +"menu_bar_metric_subtitle" = "Виберіть, яке вікно керує відсотками панелі меню."; +"menu_bar_metric_subtitle_deepseek" = "Показує баланс DeepSeek на панелі меню."; +"menu_bar_metric_subtitle_moonshot" = "Показує баланс API Moonshot / Kimi на панелі меню."; +"menu_bar_metric_subtitle_mistral" = "Показує поточні витрати Mistral API на панелі меню."; +"menu_bar_metric_subtitle_kimik2" = "Показує кредити API-ключа Kimi K2 на панелі меню."; +"automatic" = "Автоматичний"; +"primary_api_key_limit" = "Основний (обмеження ключа API)"; + +/* Display Pane */ +"section_menu_bar" = "Рядок меню"; +"merge_icons_title" = "Злиття значків"; +"merge_icons_subtitle" = "Використовуйте одну піктограму панелі меню з перемикачем провайдерів."; +"switcher_shows_icons_title" = "Перемикач показує значки"; +"switcher_shows_icons_subtitle" = "Показувати піктограми постачальників у комутаторі (інакше показувати щотижневий рядок прогресу)."; +"show_most_used_provider_title" = "Показати постачальника, який найчастіше використовується"; +"show_most_used_provider_subtitle" = "Рядок меню автоматично показує постачальника, який найближче до ліміту."; +"menu_bar_shows_percent_title" = "Рядок меню показує відсотки"; +"menu_bar_shows_percent_subtitle" = "Замініть смужки тварин на значки бренду постачальника та відсоток."; +"display_mode_title" = "Режим відображення"; +"display_mode_subtitle" = "Виберіть, що відображати на панелі меню (Pace показує використання порівняно з очікуваним)."; +"section_menu_content" = "Зміст меню"; +"show_usage_as_used_title" = "Показати використання як використане"; +"show_usage_as_used_subtitle" = "Індикатори прогресу заповнюються, коли ви витрачаєте квоту (замість відображення залишку)."; +"show_quota_warning_markers_title" = "Показати маркери попередження про квоти"; +"show_quota_warning_markers_subtitle" = "Малюйте порогові позначки на панелях використання, коли налаштовано попередження про квоту."; +"weekly_progress_work_days_title" = "Щотижневі робочі дні"; +"weekly_progress_work_days_subtitle" = "Позначте межі дня на смугах тижневого використання."; +"show_reset_time_as_clock_title" = "Показувати час скидання як годинник"; +"show_reset_time_as_clock_subtitle" = "Відображення часу скидання як абсолютних значень годинника замість зворотного відліку."; +"show_provider_changelog_links_title" = "Показати посилання журналу змін провайдера"; +"show_provider_changelog_links_subtitle" = "Додає в меню посилання на примітки до випуску для підтримуваних постачальників, що підтримують CLI."; +"show_credits_extra_usage_title" = "Показати кредити + додаткове використання"; +"show_credits_extra_usage_subtitle" = "Показати в меню розділи використання кредитів Codex і Claude Extra."; +"show_all_token_accounts_title" = "Показати всі облікові записи маркерів"; +"show_all_token_accounts_subtitle" = "Облікові записи маркерів стека в меню (інакше відображати панель перемикання облікових записів)."; +"multi_account_layout_title" = "Макет кількох облікових записів"; +"multi_account_layout_subtitle" = "Виберіть сегментоване перемикання облікових записів або складені картки облікових записів."; +"multi_account_layout_segmented" = "Сегментований"; +"multi_account_layout_stacked" = "складені"; +"overview_tab_providers_title" = "Постачальники вкладок огляду"; +"configure" = "Налаштувати…"; +"overview_enable_merge_icons_hint" = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; +"overview_no_providers_hint" = "Немає активованих постачальників, доступних для огляду."; +"overview_rows_follow_order" = "Оглядові рядки завжди відповідають порядку постачальника."; +"overview_no_providers_selected" = "Не вибрано жодного постачальника"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Комбінація клавіш"; +"open_menu_shortcut_title" = "Відкрити меню"; +"open_menu_shortcut_subtitle" = "Викликати меню панелі меню з будь-якого місця."; +"install_cli" = "Встановіть CLI"; +"install_cli_subtitle" = "Символьне посилання CodexBarCLI на /usr/local/bin і /opt/homebrew/bin як codexbar."; +"cli_not_found" = "CodexBarCLI не знайдено в пакеті програм."; +"no_writable_bin_dirs" = "Не знайдено записуваних каталогів кошика."; +"show_debug_settings_title" = "Показати налаштування налагодження"; +"show_debug_settings_subtitle" = "Розкрийте інструменти усунення несправностей на вкладці Debug."; +"surprise_me_title" = "Здивуйте мене"; +"surprise_me_subtitle" = "Перевірте, чи подобається вам, що ваші агенти розважаються там."; +"weekly_limit_confetti_title" = "Щотижневий ліміт конфетті"; +"weekly_limit_confetti_subtitle" = "Відтворення конфетті на весь екран, коли тижневе використання скидається."; +"hide_personal_info_title" = "Приховати особисту інформацію"; +"hide_personal_info_subtitle" = "Незрозумілі адреси електронної пошти на панелі меню та інтерфейсі меню."; +"show_provider_storage_usage_title" = "Показати використання сховища постачальника"; +"show_provider_storage_usage_subtitle" = "Показати використання локального диска в меню. Сканує відомі шляхи, що належать провайдеру, у фоновому режимі."; +"section_keychain_access" = "Доступ через брелок"; +"keychain_access_caption" = "Вимкніть усі функції читання та запису Keychain. Використовуйте це, якщо macOS постійно запитує «Chrome/Brave/Edge Safe Storage» навіть після натискання «Завжди дозволяти». Імпорт файлів cookie браузера недоступний, якщо ввімкнено; вставте заголовки файлів cookie вручну в Провайдери. Claude/Codex OAuth через CLI все ще працює."; +"disable_keychain_access_title" = "Вимкнути доступ Keychain"; +"disable_keychain_access_subtitle" = "Запобігає будь-якому доступу Keychain, коли ввімкнено."; + +/* About Pane */ +"about_tagline" = "Нехай ваші токени ніколи не закінчаться — пам’ятайте про ліміти агентів."; +"link_github" = "GitHub"; +"link_website" = "Веб-сайт"; +"link_twitter" = "Twitter"; +"link_email" = "Електронна пошта"; +"check_updates_auto" = "Автоматично перевіряти наявність оновлень"; +"update_channel" = "Оновити канал"; +"check_for_updates" = "Перевірити наявність оновлень…"; +"updates_unavailable" = "Оновлення недоступні в цій збірці."; +"copyright" = "© 2026 Пітер Штайнбергер. Ліцензія MIT."; + +/* Debug Pane */ +"section_logging" = "Лісозаготівля"; +"enable_file_logging" = "Увімкнути журналювання файлів"; +"enable_file_logging_subtitle" = "Записати журнали до %@ для налагодження."; +"verbosity_title" = "Багатослівність"; +"verbosity_subtitle" = "Контролює, скільки деталей реєструється."; +"open_log_file" = "Відкрити файл журналу"; +"force_animation_next_refresh" = "Примусово запускати анімацію під час наступного оновлення"; +"force_animation_next_refresh_subtitle" = "Тимчасово показує анімацію завантаження після наступного оновлення."; +"section_loading_animations" = "Завантаження анімацій"; +"loading_animations_caption" = "Виберіть шаблон і відтворіть його на панелі меню. \\\"Випадкове\\\" зберігає існуючу поведінку."; +"animation_random_default" = "Випадковий (за замовчуванням)"; +"replay_selected_animation" = "Повторити вибрану анімацію"; +"blink_now" = "Поморгай зараз"; +"section_probe_logs" = "Зондові журнали"; +"probe_logs_caption" = "Отримати останній результат тестування для налагодження; Копія зберігає повний текст."; +"fetch_log" = "Отримати журнал"; +"copy" = "Копіювати"; +"save_to_file" = "Зберегти у файл"; +"load_parse_dump" = "Завантажити дамп аналізу"; +"rerun_provider_autodetect" = "Повторно запустіть автоматичне визначення постачальника"; +"loading" = "Завантаження…"; +"no_log_yet_fetch" = "Журналу ще немає. Отримати для завантаження."; +"section_fetch_strategy" = "Спроби отримання стратегії"; +"fetch_strategy_caption" = "Рішення та помилки конвеєра останньої вибірки для постачальника."; +"section_openai_cookies" = "Файли cookie OpenAI"; +"openai_cookies_caption" = "Імпорт файлів cookie + сканування журналів WebKit з останньої спроби файлів cookie OpenAI."; +"no_log_yet" = "Журналу ще немає. Оновіть файли cookie OpenAI у Постачальники → Codex, щоб запустити імпорт."; +"section_caches" = "Тайники"; +"caches_caption" = "Очистити кеш-пам’ять результатів сканування витрат або кешу файлів cookie браузера."; +"clear_cookie_cache" = "Очистити кеш cookie"; +"clear_cost_cache" = "Очистити кеш вартості"; +"section_notifications" = "Сповіщення"; +"notifications_caption" = "Запуск тестових сповіщень для 5-годинного вікна сеансу (вичерпано/відновлено)."; +"post_depleted" = "Повідомлення вичерпано"; +"post_restored" = "Пост відновлено"; +"section_cli_sessions" = "Сесії CLI"; +"cli_sessions_caption" = "Підтримуйте сесії Codex/Claude CLI після зонду. За замовчуванням виходить після збору даних."; +"keep_cli_sessions_alive" = "Підтримуйте сесії CLI"; +"keep_cli_sessions_alive_subtitle" = "Пропустити демонтаж між зондами (лише для налагодження)."; +"reset_cli_sessions" = "Скидання сеансів CLI"; +"section_error_simulation" = "Симуляція помилок"; +"error_simulation_caption" = "Введіть фальшиве повідомлення про помилку в картку меню для тестування макета."; +"set_menu_error" = "Помилка налаштування меню"; +"clear_menu_error" = "Помилка очищення меню"; +"set_cost_error" = "Помилка встановлення вартості"; +"clear_cost_error" = "Очистити помилку вартості"; +"section_cli_paths" = "Шляхи CLI"; +"cli_paths_caption" = "Вирішено двійковий шар Codex і PATH; запуск входу PATH захоплення (короткий тайм-аут)."; +"codex_binary" = "Двійковий код Codex"; +"claude_binary" = "Claude бінарний"; +"effective_path" = "Ефективний ШЛЯХ"; +"unavailable" = "Недоступний"; +"login_shell_path" = "ШЛЯХ до оболонки входу (запис під час запуску)"; +"cleared" = "Очищено."; +"no_fetch_attempts" = "Ще жодних спроб отримання."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe може блокувати програми панелі меню в системних параметрах → Рядок меню → Дозволити на панелі меню. CodexBar працює, але macOS може приховувати свій значок. Відкрийте налаштування рядка меню та ввімкніть CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Автоматичний"; +"metric_pref_primary" = "Первинний"; +"metric_pref_secondary" = "Вторинний"; +"metric_pref_tertiary" = "Третинний"; +"metric_pref_extra_usage" = "Додаткове використання"; +"metric_pref_average" = "Середній"; + +/* Display modes */ +"display_mode_percent" = "Відсоток"; +"display_mode_pace" = "Темп"; +"display_mode_both" = "Обидва"; +"display_mode_percent_desc" = "Показати залишок/використаний відсоток (наприклад, 45%)"; +"display_mode_pace_desc" = "Показати індикатор темпу (наприклад, +5%)"; +"display_mode_both_desc" = "Показати відсоток і темп (наприклад, 45% · +5%)"; + +/* Provider status */ +"status_operational" = "Працює"; +"status_partial_outage" = "Часткове відключення"; +"status_major_outage" = "Серйозний збій"; +"status_critical_issue" = "Критична проблема"; +"status_maintenance" = "Технічне обслуговування"; +"status_unknown" = "Статус невідомий"; + +/* Refresh frequency */ +"refresh_manual" = "Інструкція"; +"refresh_1min" = "1 хв"; +"refresh_2min" = "2 хв"; +"refresh_5min" = "5 хв"; +"refresh_15min" = "15 хв"; +"refresh_30min" = "30 хв"; + +/* Additional keys */ +"not_found" = "Не знайдено"; + +/* Cost estimation */ +"cost_header_estimated" = "Вартість (орієнтовна)"; +"cost_estimate_hint" = "Оцінка з місцевих журналів · може відрізнятися від вашого рахунку"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Не виявлено JetBrains IDE з AI Assistant. Встановіть JetBrains IDE і ввімкніть AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Маркер OpenRouter API не налаштовано. Установіть змінну середовища OPENROUTER_API_KEY або налаштуйте її в налаштуваннях."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Маркер API z.ai не знайдено. Установіть apiKey у ~/.codexbar/config.json або Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Відсутній ключ API DeepSeek."; +"%@ is unavailable in the current environment." = "%@ недоступний у поточному середовищі."; +"All Systems Operational" = "Всі системи працюють"; +"Last 30 days" = "Останні 30 днів"; +"Last 30 days:" = "Останні 30 днів:"; +"This month" = "Цей місяць"; +"Store multiple OpenAI API keys." = "Зберігайте кілька ключів OpenAI API."; +"Admin API key" = "Ключ API адміністратора"; +"Open billing" = "Відкритий білінг"; +"Google accounts" = "облікові записи Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Зберігайте кілька облікових записів Antigravity Google OAuth для швидкого перемикання."; +"Add Google Account" = "Додайте обліковий запис Google"; +"Open Token Plan" = "Відкритий план токенів"; +"Text Generation" = "Генерація тексту"; +"Text to Speech" = "Перетворення тексту в мовлення"; +"Music Generation" = "Музичне покоління"; +"Image Generation" = "Генерація зображень"; +"No local data found" = "Немає локальних даних"; +"Credits unavailable; keep Codex running to refresh." = "Кредити недоступні; продовжуйте працювати Codex для оновлення."; +"No available fetch strategy for minimax." = "Немає доступної стратегії вибірки для minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Сеанс курсору не знайдено. Увійдіть на cursor.com у Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX або Edge Canary. Якщо ви користуєтеся Safari, надайте CodexBar повний доступ до диска в системних параметрах ▸ Конфіденційність і безпека. Ви також можете ввійти в Cursor з меню CodexBar (Додати/змінити обліковий запис)."; +"No OpenCode session cookies found in browsers." = "У браузерах не знайдено сеансових файлів cookie OpenCode."; +"No available fetch strategy for %@." = "Немає доступної стратегії отримання для %@."; +"Today" = "Сьогодні"; +"Today tokens" = "Сьогодні жетони"; +"30d cost" = "Вартість 30д"; +"30d tokens" = "30d жетонів"; +"Latest tokens" = "Останні жетони"; +"Top model" = "Топ модель"; +"Storage" = "Зберігання"; +"Add Account..." = "Додати обліковий запис..."; +"Usage Dashboard" = "Панель використання"; +"Status Page" = "Сторінка стану"; +"Settings..." = "Налаштування..."; +"About CodexBar" = "Про CodexBar"; +"Quit" = "Вийти"; +"Last %d day" = "Останній %d день"; +"Last %d days" = "Останні %d днів"; +"%@ tokens" = "%@ токенів"; +"Latest billing day" = "Останній розрахунковий день"; +"Latest billing day (%@)" = "Останній розрахунковий день (%@)"; +"%@ left" = "Залишилося %@"; +"Resets %@" = "Скидання %@"; +"Resets in %@" = "Скидання через %@"; +"Resets now" = "Скидає зараз"; +"Lasts until reset" = "Триває до скидання"; +"Updated %@" = "Оновлено %@"; +"Updated %@h ago" = "Оновлено %@ год тому"; +"Updated %@m ago" = "Оновлено %@хв тому"; +"Updated just now" = "Оновлено щойно"; +"Projected empty in %@" = "Передбачається порожній у %@"; +"Runs out in %@" = "Закінчується за %@"; +"Pace: %@" = "Темп: %@"; +"Pace: %@ · %@" = "Темп: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% ризик вичерпання"; +"%d%% in deficit" = "%d%% в дефіциті"; +"%d%% in reserve" = "%d%% в резерві"; +"usage_percent_suffix_left" = "зліва"; +"usage_percent_suffix_used" = "використовується"; +"Store multiple DeepSeek API keys." = "Зберігайте кілька ключів DeepSeek API."; +"This week" = "Цього тижня"; +"Week" = "тиждень"; +"Month" = "місяць"; +"Models" = "Моделі"; +"24h tokens" = "24-годинні жетони"; +"Latest hour" = "Остання година"; +"Peak hour" = "Година пік"; +"Top method" = "Топовий спосіб"; +"30d cash" = "30д готівкою"; +"30d billing history from MiniMax web session" = "30-денна історія платежів з веб-сесії MiniMax"; +"AWS Cost Explorer billing can lag." = "Виставлення рахунків AWS Cost Explorer може затримуватися."; +"Rate limit: %d / %@" = "Ліміт швидкості: %d / %@"; +"Key remaining" = "Ключ залишився"; +"No limit set for the API key" = "Для ключа API не встановлено обмежень"; +"API key limit unavailable right now" = "Ліміт ключів API зараз недоступний"; +"This month: %@ tokens" = "Цей місяць: %@ токенів"; +"No utilization data yet." = "Даних про використання ще немає."; +"No %@ utilization data yet." = "Ще немає даних про використання %@."; +"%@: %@%% used" = "%@: використано %@%%."; +"%dd" = "%dд"; +"today" = "сьогодні"; +"just now" = "тільки зараз"; +"On pace" = "В темпі"; +"Runs out now" = "Зараз закінчується"; +"Projected empty now" = "Зараз проектується порожнім"; +"Switch Account..." = "Змінити обліковий запис..."; +"Update ready, restart now?" = "Оновлення готове, перезапустити?"; +"Daily" = "Щодня"; +"Hourly Tokens" = "Погодинні жетони"; +"No data" = "Немає даних"; +"No usage breakdown data available." = "Немає даних про розподіл використання."; + +"Today: %@ · %@ tokens" = "Сьогодні: %@ · %@ токенів"; +"Today: %@" = "Сьогодні: %@"; +"Today: %@ tokens" = "Сьогодні: %@ токенів"; +"Last 30 days: %@ · %@ tokens" = "Останні 30 днів: %@ · %@ токенів"; +"Last 30 days: %@" = "Останні 30 днів: %@"; +"Est. total (30d): %@" = "Приблизно всього (30 днів): %@"; +"Est. total (%@): %@" = "Приблизно всього (%@): %@"; +"Hover a bar for details" = "Щоб переглянути деталі, наведіть курсор на панель"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ токенів"; +"No providers selected for Overview." = "Для огляду не вибрано жодного постачальника."; +"No overview data available." = "Немає оглядових даних."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto спочатку використовує локальний API IDE, а потім Google OAuth, коли IDE закрито."; +"Login with Google" = "Увійти через Google"; + +/* Popup panels */ +"No usage configured." = "Використання не налаштовано."; +"Quota" = "Квота"; +"tokens" = "жетони"; +"requests" = "запити"; +"Latest" = "Останній"; +"Monthly" = "Щомісяця"; +"Sonnet" = "Сонет"; +"Overages" = "Надлишки"; +"Activity" = "діяльність"; +"Copied" = "Скопійовано"; +"Copy error" = "Помилка копіювання"; +"Copy path" = "Копіювати шлях"; +"Extra usage spent" = "Витрачено додаткове використання"; +"Credits remaining" = "Залишок кредитів"; +"Using CLI fallback" = "Використання запасного CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "Оновлення балансу майже в реальному часі (затримка до 5 хвилин)"; +"Daily billing data finalizes at 07:00 UTC" = "Щоденні платіжні дані завершуються о 07:00 UTC"; +"%@ of %@ credits left" = "Залишилося %@ з %@ кредитів"; +"%@ of %@ bonus credits left" = "Залишилося %@ з %@ бонусних кредитів"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (залишилося %@)"; +"%@/%@ left" = "Залишилося %@/%@"; +"Gemini Flash" = "Gemini Флеш"; +"Regenerates %@" = "Регенерує %@"; +"used after next regen" = "використовується після наступної регенерації"; +"after next regen" = "після наступної реген"; +"Near full" = "Майже повний"; +"Full in ~1 regen" = "Повний за ~1 регенерацію"; +"Full in ~%.0f regens" = "Повний ~%.0f регенерацій"; +"Overage usage" = "Надмірне використання"; +"Overage cost" = "Перевищення вартості"; +"credits" = "кредити"; +"Zen balance" = "Дзен баланс"; +"API spend" = "Витрати API"; +"Extra usage" = "Додаткове використання"; +"Quota usage" = "Використання квоти"; +"%.0f%% used" = "Використано %.0f%%."; +"Usage history (today)" = "Історія використання (сьогодні)"; +"Usage history (%d days)" = "Історія використання (%d днів)"; +"%d percent remaining" = "Залишилося %d відсотків"; +"Unknown" = "Невідомо"; +"stale data" = "застарілі дані"; +"No credits history data." = "Немає даних про кредитну історію."; +"No credits history data available." = "Немає даних про кредитну історію."; +"Credits history chart" = "Графік кредитної історії"; +"%d days of credits data" = "Дані кредитів за %d днів"; +"Usage breakdown chart" = "Діаграма розподілу використання"; +"%d days of usage data across %d services" = "%d днів використання даних у %d службах"; +"Cost history chart" = "Графік історії витрат"; +"%d days of cost data" = "Дані про витрати за %d днів"; +"Plan utilization chart" = "Графік використання плану"; +"%d utilization samples" = "%d зразки використання"; +"Hourly Usage" = "Погодинне використання"; +"Usage remaining" = "Залишилося використання"; +"Usage used" = "Використання використано"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "Ключ API перевірено. Ollama не розкриває обмеження квот Cloud через API."; +"Last 30 days: %@ tokens" = "Останні 30 днів: %@ токенів"; +"7d spend" = "7д витратити"; +"30d spend" = "витратити 30 днів"; +"Cache read" = "Читання кешу"; +"Claude Admin API 30 day spend trend" = "Claude Admin API 30-денна тенденція витрат"; +"OpenRouter API key spend trend" = "Тенденція витрат на ключ OpenRouter API"; +"z.ai hourly token trend" = "погодинний тренд токена z.ai"; +"MiniMax 30 day token usage trend" = "Тенденція використання токенів MiniMax за 30 днів"; +"Today cash" = "Сьогодні готівкою"; +"DeepSeek 30 day token usage trend" = "30-денна тенденція використання токенів DeepSeek"; +"cache-hit input" = "введення кешу"; +"cache-miss input" = "cache-miss input"; +"output" = "вихід"; +"Requests" = "Запити"; +"Reported by OpenAI Admin API organization usage." = "Повідомлено про використання організацією OpenAI Admin API."; +"Reported by Mistral billing usage." = "Повідомлено Mistral billing usage."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Додайте облікові записи через GitHub OAuth Device Flow на вибраному хості."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Зберігає кожен обліковий запис Google, у який ви ввійшли, для швидкого перемикання Antigravity. Використовує OAuth Antigravity.app, якщо доступний, або ANTIGRAVITY_OAUTH_CLIENT_ID і ANTIGRAVITY_OAUTH_CLIENT_SECRET як заміну."; +"Manual cleanup: past sessions" = "Очищення вручну: минулі сесії"; +"Clearing removes past resume, continue, and rewind history." = "Очищення видаляє історію відновлення, продовження та перемотування назад."; +"Manual cleanup: file checkpoints" = "Ручне очищення: контрольні точки файлів"; +"Clearing removes checkpoint restore data for previous edits." = "Очищення видаляє дані відновлення контрольної точки для попередніх змін."; +"Manual cleanup: saved plans" = "Ручне очищення: збережені плани"; +"Clearing removes old plan-mode files." = "Очищення видаляє старі файли планового режиму."; +"Manual cleanup: debug logs" = "Ручне очищення: журнали налагодження"; +"Clearing removes past debug logs." = "Очищення видаляє попередні журнали налагодження."; +"Manual cleanup: attachment cache" = "Очищення вручну: кеш вкладень"; +"Clearing removes cached large pastes or attached images." = "Очищення видаляє кешовані великі вставки або прикріплені зображення."; +"Manual cleanup: session metadata" = "Очищення вручну: метадані сеансу"; +"Clearing removes per-session environment metadata." = "Очищення видаляє метадані середовища для кожного сеансу."; +"Manual cleanup: shell snapshots" = "Ручне очищення: знімки оболонки"; +"Clearing removes leftover runtime shell snapshot files." = "Очищення видаляє залишкові файли знімків оболонки виконання."; +"Manual cleanup: legacy todos" = "Очищення вручну: застарілі завдання"; +"Clearing removes legacy per-session task lists." = "Очищення видаляє застарілі списки сеансових завдань."; +"Manual cleanup: sessions" = "Ручне очищення: сесії"; +"Clearing removes past Codex session history." = "Очищення видаляє минулу історію сеансів Codex."; +"Manual cleanup: archived sessions" = "Очищення вручну: заархівовані сеанси"; +"Clearing removes archived Codex session history." = "Очищення видаляє архівну історію сеансів Codex."; +"Manual cleanup: cache" = "Очищення вручну: кеш"; +"Clearing removes provider-owned cached data." = "Очищення видаляє кешовані дані постачальника."; +"Manual cleanup: logs" = "Ручне очищення: журнали"; +"Clearing removes local diagnostic logs." = "Очищення видаляє локальні журнали діагностики."; +"Manual cleanup: file history" = "Ручне очищення: історія файлів"; +"Clearing removes local edit checkpoint history." = "Очищення видаляє локальну історію контрольних точок редагування."; +"Manual cleanup: temporary data" = "Очищення вручну: тимчасові дані"; +"Clearing removes local temporary provider data." = "Очищення видаляє локальні тимчасові дані постачальника."; +"Total: %@" = "Усього: %@"; +"%d more items" = "ще %d елементів"; +"Cleanup ideas" = "Ідеї ​​очищення"; +"%d unreadable item(s) skipped" = "%d нечитабельних елементів пропущено"; + +"API key limit" = "Обмеження ключа API"; +"Auth" = "Авт"; +"Auto" = "Авто"; +"Disabled — no recent data" = "Вимкнено — немає останніх даних"; +"Limits not available" = "Обмеження недоступні"; +"No usage yet" = "Поки що не використовується"; +"Not fetched yet" = "Ще не отримано"; +"Refreshing" = "Освіжаючий"; +"Session" = "Сесія"; +"Source" = "Джерело"; +"State" = "Держава"; +"Unavailable" = "Недоступний"; +"Weekly" = "Щотижня"; +"not detected" = "не виявлено"; +"Estimated from local Codex logs for the selected account." = "Оцінено з локальних журналів Codex для вибраного облікового запису."; +"minimax_usage_amount_format" = "Використання: %@ / %@"; +"minimax_used_percent_format" = "Використаний %@"; +"minimax_service_text_generation" = "Генерація тексту"; +"minimax_service_text_to_speech" = "Перетворення тексту в мовлення"; +"minimax_service_music_generation" = "Музичне покоління"; +"minimax_service_image_generation" = "Генерація зображень"; +"minimax_service_lyrics_generation" = "Генерація пісень"; +"minimax_service_coding_plan_vlm" = "План кодування VLM"; +"minimax_service_coding_plan_search" = "Пошук плану кодування"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ чекає на дозвіл"; +"%@ requests" = "%@ запитів"; +"%@: %@ credits" = "%@: %@ кредитів"; +"30d requests" = "30d запитів"; +"4 days" = "4 дні"; +"5 days" = "5 днів"; +"7 days" = "7 днів"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "Ключ API перевіряє доступ до Ollama Cloud; файли cookie все ще розкривають обмеження квоти."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "Ідентифікатор ключа доступу до AWS. Також можна встановити за допомогою AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Регіон AWS. Також можна встановити за допомогою AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Секретний ключ доступу до AWS. Також можна встановити за допомогою AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "Ідентифікатор ключа доступу"; +"Add Account" = "Додати обліковий запис"; +"Adding Account…" = "Додавання облікового запису…"; +"Antigravity login failed" = "Помилка входу в Antigravity"; +"Antigravity login timed out" = "Час очікування входу в антигравітацію минув"; +"Auth source" = "Джерело авторизації"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Автоматично імпортує файли cookie браузера Chrome із Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Автоматично імпортує дані сесії Windsurf із локального сховища браузера Chromium."; +"Automatic imports browser cookies from Bailian." = "Автоматично імпортує файли cookie браузера з Bailian."; +"Automatically imports browser cookies." = "Автоматично імпортує файли cookie браузера."; +"Automatically imports browser session cookies." = "Автоматично імпортує файли cookie сеансу браузера."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Назва розгортання Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME також підтримується."; +"Azure OpenAI key" = "Ключ Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Кінцева точка ресурсу Azure OpenAI. AZURE_OPENAI_ENDPOINT також підтримується."; +"Base URL" = "Базовий URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Базова URL-адреса для примірника LLM-API-Key-Proxy."; +"Browser cookies" = "Файли cookie браузера"; +"Cap end" = "Кінець кришки"; +"Cap start" = "Початок шапки"; +"Capacity End" = "Кінець ємності"; +"Capacity Start" = "Ємність Старт"; +"Changelog" = "Журнал змін"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Виберіть хост API Moonshot/Kimi для міжнародних або материкового Китаю облікових записів."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar не може замінити системний обліковий запис, який увійшов лише за допомогою ключа API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar не може знайти збережену авторизацію для цього облікового запису. Повторно автентифікуйте його та повторіть спробу."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar не вдалося прочитати сховище керованого облікового запису. Відновіть магазин перед додаванням іншого облікового запису."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar не зміг прочитати збережену авторизацію для цього облікового запису. Повторно автентифікуйте його та повторіть спробу."; +"CodexBar could not read the current system account on this Mac." = "CodexBar не вдалося прочитати поточний обліковий запис системи на цьому Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar не зміг замінити поточну автентифікацію Codex на цьому Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar не зміг безпечно зберегти поточний обліковий запис системи перед перемиканням."; +"CodexBar could not save the current system account before switching." = "CodexBar не зміг зберегти поточний обліковий запис системи перед перемиканням."; +"CodexBar could not update managed account storage." = "CodexBar не вдалося оновити сховище керованого облікового запису."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar знайшов інший керований обліковий запис, який уже використовує поточний системний обліковий запис. Усуньте дублікат облікового запису перед переходом."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar запитає у macOS Keychain «%@», щоб він міг розшифрувати файли cookie браузера та автентифікувати ваш обліковий запис. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar запитає у macOS Keychain маркер Claude Code OAuth, щоб отримати дані про використання Claude. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок cookie Amp, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок файлу cookie Augment, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок файлу cookie Claude, щоб отримати інформацію про використання веб-сайту Claude. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок cookie Cursor, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок Factory cookie, щоб отримати дані про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш маркер GitHub Copilot, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш ключ API Kimi K2, щоб він міг отримати дані про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш токен автентифікації Kimi, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш токен MiniMax API, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок cookie MiniMax, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок файлів cookie OpenAI, щоб він міг отримати додаткові елементи панелі інструментів Codex. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок файлу cookie OpenCode, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш синтетичний ключ API, щоб отримати дані про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш токен API z.ai, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"Could not open Cursor login in your browser." = "Не вдалося відкрити Cursor login у вашому браузері."; +"Could not open browser for Antigravity" = "Не вдалося відкрити браузер для Антигравітації"; +"Credits used" = "Використані кредити"; +"Day" = "День"; +"Deployment" = "Розгортання"; +"Drag to reorder" = "Перетягніть, щоб змінити порядок"; +"Endpoint" = "Кінцева точка"; +"Enterprise host" = "Корпоративний хост"; +"Extra usage balance: %@" = "Баланс додаткового використання: %@"; +"Keychain Access Required" = "Потрібен доступ до брелка"; +"Kiro menu bar value" = "Значення панелі меню Kiro"; +"Label" = "Мітка"; +"No organizations loaded. Click Refresh after setting your API key." = "Організації не завантажено. Натисніть «Оновити» після встановлення ключа API."; +"No output captured." = "Немає вихідних даних."; +"No system account" = "Немає системного облікового запису"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Відкрити доповнення (вийти та повернутися)"; +"Open Codebuff Dashboard" = "Відкрийте інформаційну панель Codebuff"; +"Open Command Code Settings" = "Відкрийте налаштування коду команди"; +"Open Crof dashboard" = "Відкрийте інформаційну панель Crof"; +"Open Manus" = "Відкрийте Manus"; +"Open MiMo Balance" = "Відкрийте MiMo Balance"; +"Open Moonshot Console" = "Відкрийте консоль Moonshot"; +"Open Ollama API Keys" = "Відкрийте ключі Ollama API"; +"Open StepFun Platform" = "Відкрийте платформу StepFun"; +"Open T3 Chat Settings" = "Відкрийте налаштування чату T3"; +"Open Volcengine Ark Console" = "Відкрийте консоль Volcengine Ark"; +"Open legacy provider docs" = "Відкрити застарілі документи постачальника"; +"Open projects" = "Відкриті проекти"; +"Open this URL manually to continue login:\n\n%@" = "Відкрийте цю URL-адресу вручну, щоб продовжити вхід: \n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Додатковий ідентифікатор організації для облікових записів, пов’язаних із кількома організаціями Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Додатково. Застосовується до налаштованого ключа API адміністратора; вибрані облікові записи маркерів не успадковують OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Додатково. Введіть свій хост GitHub Enterprise, наприклад octocorp.ghe.com. Залиште поле порожнім для github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Додатково. Залиште поле порожнім, щоб виявити та об’єднати проекти, видимі для ключа API."; +"Org ID (optional)" = "Ідентифікатор організації (необов’язково)"; +"Organizations" = "організації"; +"Password" = "Пароль"; +"%@ authentication is disabled." = "Автентифікацію %@ вимкнено."; +"%@ cookies are disabled." = "Файли cookie %@ вимкнено."; +"%@ web API access is disabled." = "Доступ до веб-API %@ вимкнено."; +"Disable %@ dashboard cookie usage." = "Вимкнути використання файлів cookie панелі інструментів %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Доступ Keychain вимкнено в Advanced, тому імпорт файлів cookie браузера недоступний."; +"Manually paste an %@ from a browser session." = "Вручну вставте %@ із сеансу браузера."; +"Paste a Cookie header captured from %@." = "Вставте заголовок файлу cookie, отриманий із %@."; +"Paste a Cookie header from %@." = "Вставте заголовок файлу cookie з %@."; +"Paste a Cookie header or cURL capture from %@." = "Вставте заголовок файлу cookie або запис cURL із %@."; +"Paste a Cookie header or full cURL capture from %@." = "Вставте заголовок файлу cookie або повний запис cURL із %@."; +"Paste a Cookie or Authorization header from %@." = "Вставте файл cookie або заголовок авторизації з %@."; +"Paste a full cookie header or the %@ value." = "Вставте повний заголовок cookie або значення %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Вставте заголовок файлу cookie або повний запис cURL із налаштувань T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Вставте заголовок Cookie із запиту до admin.mistral.ai. Має містити файл cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Вставте Oasis-Token із сеансу браузера, у якому ви ввійшли в систему, на platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Вставте пакет JSON %@ з %@."; +"Paste the %@ value or a full Cookie header." = "Вставте значення %@ або повний заголовок файлу cookie."; +"Personal account" = "Особистий рахунок"; +"Project ID" = "ID проекту"; +"Re-auth" = "Повторна авторизація"; +"Re-authenticating…" = "Повторна автентифікація…"; +"Refresh Session" = "Оновити сеанс"; +"Refresh organizations" = "Оновити організації"; +"Region" = "Регіон"; +"Reload" = "Перезавантажити"; +"Reorder" = "Змінити порядок"; +"Secret access key" = "Секретний ключ доступу"; +"Series" = "Серія"; +"Service" = "Сервіс"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Показати або приховати кредити Kiro, відсотки або обидва поряд із піктограмою панелі меню."; +"Show usage for organizations you belong to. Personal account is always shown." = "Показати використання для організацій, до яких ви належите. Особистий рахунок відображається завжди."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Увійдіть на cursor.com у своєму браузері, а потім оновіть курсор у CodexBar."; +"Simulated error text" = "Змодельований текст помилки"; +"StepFun platform account (phone number or email)." = "Обліковий запис на платформі StepFun (номер телефону або електронна пошта)."; +"Stored in ~/.codexbar/config.json." = "Зберігається в ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Зберігається в ~/.codexbar/config.json. Також підтримується AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Зберігається в ~/.codexbar/config.json. Для офіційного Kimi API використовуйте Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Зберігається в ~/.codexbar/config.json. Отримайте ключ API з консолі Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Зберігається в ~/.codexbar/config.json. Отримайте ключ із налаштувань Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Зберігається в ~/.codexbar/config.json. Отримайте ключ на console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Зберігається в ~/.codexbar/config.json. Отримайте свій ключ на сайті elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Зберігається в ~/.codexbar/config.json. Отримайте свій ключ із openrouter.ai/settings/keys і встановіть там ліміт витрат на ключ, щоб увімкнути відстеження квоти ключів API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Зберігається в ~/.codexbar/config.json. У Warp відкрийте Налаштування > Платформа > Ключі API, а потім створіть один."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Зберігається в ~/.codexbar/config.json. Метрики потребують доступу до Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Зберігається в ~/.codexbar/config.json. OPENAI_ADMIN_KEY є кращим; OPENAI_API_KEY все ще працює."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Зберігається в ~/.codexbar/config.json. Потрібен ключ API адміністратора Anthropic."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Зберігається в ~/.codexbar/config.json. Використовується для /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Зберігається в ~/.codexbar/config.json. Ви також можете надати CODEBUFF_API_KEY або дозволити CodexBar читати ~/.config/manicode/credentials.json (створений `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Зберігається в ~/.codexbar/config.json. Ви також можете надати CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Зберігається в ~/.codexbar/config.json. Ви також можете надати KILO_API_KEY або ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "T3 Чат cookie"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Цей обліковий запис більше не доступний у CodexBar. Оновіть список облікових записів і повторіть спробу."; +"The browser login did not complete in time. Try Antigravity login again." = "Вхід у браузер не завершено вчасно. Спробуйте ще раз увійти в Antigravity."; +"Timed out waiting for Cursor login. %@" = "Минув час очікування входу курсору. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Минув час очікування входу курсору. %@ Остання помилка: %@"; +"Today requests" = "Сьогоднішні запити"; +"Total (30d): %@ credits" = "Усього (30 днів): %@ кредитів"; +"Username" = "Ім'я користувача"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Використовує ім’я користувача + пароль для входу та автоматичного отримання Oasis-Token."; +"Uses username + password to login and obtain an %@ automatically." = "Використовує ім’я користувача + пароль для входу та автоматичного отримання %@."; +"Utilization End" = "Кінець використання"; +"Utilization Start" = "Початок використання"; +"Verbosity" = "Багатослівність"; +"Windsurf session JSON bundle" = "Пакет JSON сеансу віндсерфінгу"; +"Workspace ID" = "Ідентифікатор робочої області"; +"Your StepFun platform password. Used to login and obtain a session token." = "Ваш пароль платформи StepFun. Використовується для входу та отримання маркера сесії."; +"claude /login exited with status %d." = "claude /login вийшов зі статусом %d."; +"codex login exited with status %d." = "вихід із входу в кодек із статусом %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Файл cookie: …\n\nабо вставте запис cURL із інформаційної панелі Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Файл cookie: …\n\nабо вставте значення __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Файл cookie: …\n\nабо вставте значення маркера kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nабо вставте лише значення session_id"; +"Clear" = "ясно"; +"No matching providers" = "Немає відповідних постачальників"; +"Search providers" = "Пошук провайдерів"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 9aa345dba..181d001c2 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -407,6 +407,7 @@ "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "language_french" = "法语"; +"language_ukrainian" = "乌克兰语"; "start_at_login_title" = "开机启动"; "start_at_login_subtitle" = "启动 Mac 时自动打开 CodexBar。"; "show_cost_summary" = "显示费用摘要"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 929497565..8f4acc26d 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -407,6 +407,7 @@ "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "language_french" = "法語"; +"language_ukrainian" = "烏克蘭語"; "start_at_login_title" = "登入時啟動"; "start_at_login_subtitle" = "登入 Mac 時自動開啟 CodexBar。"; "show_cost_summary" = "顯示費用摘要"; diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift new file mode 100644 index 000000000..a71eb81d1 --- /dev/null +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import CodexBar + +struct LocalizationLanguageCatalogTests { + @Test + func `app language catalog includes Ukrainian`() { + #expect(AppLanguage.allCases.contains(.ukrainian)) + #expect(AppLanguage.ukrainian.rawValue == "uk") + } + + @Test + func `ukrainian localization bundle exists and contains key UI labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let ukURL = root.appendingPathComponent("Sources/CodexBar/Resources/uk.lproj/Localizable.strings") + let contents = try String(contentsOf: ukURL, encoding: .utf8) + + let requiredKeys = [ + "\"language_title\"", + "\"language_subtitle\"", + "\"language_system\"", + "\"language_ukrainian\"", + "\"tab_general\"", + "\"quit_app\"", + ] + for key in requiredKeys { + #expect(contents.contains(key), "Missing localization key: \(key)") + } + } +} From 702af5a4f48c10f7c06d6b6399f97d55cb44da92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Jun 2026 17:29:25 -0700 Subject: [PATCH 31/93] Fix Claude selected metric reserve display Fixes #1302 by computing menu-bar pace/reserve from the selected metric window for normal providers while preserving Codex weekly projection pace and Abacus primary fallback. --- CHANGELOG.md | 1 + .../StatusItemController+Animation.swift | 13 ++- .../StatusItemAnimationTests.swift | 98 +++++++++++++++++++ 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1dbcbdb4..5c446e85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! ### Fixed +- Menu bar: compute Claude pace/reserve from the selected menu-bar metric window so Primary (Session) no longer pairs the session percentage with the weekly reserve (#1302). Thanks @outfoxer! - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! - Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! - Menu bar: keep provider-switcher quota bars from replacing Auto Layout constraints when the visible ratio is unchanged, making tab switches responsive with many providers enabled (#1303, #1315). Thanks @juanjoseluisgarcia! diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 54175bb94..bbb42c442 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -682,12 +682,15 @@ extension StatusItemController { case .percent: pace = nil case .pace, .both: - let weeklyWindow = - codexProjection?.rateWindow(for: .weekly) - ?? snapshot?.secondary + let paceWindow: RateWindow? = if let codexProjection { + codexProjection.rateWindow(for: .weekly) + } else if provider == .abacus { // Abacus has no secondary window; pace is computed on primary monthly credits - ?? (provider == .abacus ? snapshot?.primary : nil) - pace = weeklyWindow.flatMap { window in + snapshot?.primary + } else { + percentWindow + } + pace = paceWindow.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } } diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index ba9bc457d..fc96c1310 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -932,6 +932,104 @@ struct StatusItemAnimationTests { #expect(both == "40%") } + @Test + func `claude primary menu bar metric computes pace from selected session window`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-primary-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "20% · +60%") + } + + @Test + func `codex menu bar pace does not fall back to session when weekly projection is unavailable`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-codex-no-weekly-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .codex) + + 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText == "20%") + } + @Test func `menu bar display text uses credits when codex weekly is exhausted`() { let settings = SettingsStore( From 88b00e557bd2b19aeefe5a8fcd0961b3b84beb85 Mon Sep 17 00:00:00 2001 From: thadwildes <100804836+turbothad@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:21:34 -0700 Subject: [PATCH 32/93] Memoize models.dev catalog load outcomes Refs #1311. Memoizes models.dev cache load outcomes, including failed loads, so large cost scans do not repeatedly read and decode the same cache file. Adds regression coverage and a maintainer changelog entry. --- CHANGELOG.md | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/ModelsDevPricing.swift | 109 ++++++++++++++++-- .../CodexBarTests/ModelsDevPricingTests.swift | 77 +++++++++++++ 4 files changed, 180 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c446e85f..999a4755f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! ### Fixed +- Performance: memoize models.dev cost catalog load outcomes so large Codex history scans no longer re-read and decode the same cache file per row (#1322, refs #1311). Thanks @turbothad! - Menu bar: compute Claude pace/reserve from the selected menu-bar metric window so Primary (Session) no longer pairs the session percentage with the weekly reserve (#1302). Thanks @outfoxer! - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! - Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 1aacbced4..a48f4f08e 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "8ff0c1544161ed2a" + static let value = "7049d0743a7cd98f" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 87801eb67..1d4797f42 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -318,6 +318,57 @@ struct ModelsDevCacheLoadResult: Equatable { var error: ModelsDevCache.Error? } +/// In-memory memo for the decoded models.dev catalog, keyed by file path + on-disk identity. +/// +/// `ModelsDevCache.load` is called once per usage row whenever a cost lookup is performed without a +/// pre-resolved catalog (see `CostUsagePricing.modelsDevLookup`). Without this memo, scanning a large +/// `~/.codex` history re-reads and re-decodes the ~800 KB catalog JSON for every row, which pegs the CPU +/// and freezes the menu during a refresh. +/// +/// The full load *outcome* is memoized, not just successful decodes: a corrupt or wrong-version cache is +/// read and decode-attempted exactly as expensively as a valid one, so caching only successes would leave +/// the per-row storm in place whenever the cache is unreadable. Reusing the outcome while the file is +/// unchanged keeps every fallback path cheap. +private final class ModelsDevCacheMemo: @unchecked Sendable { + enum Outcome { + case decoded(ModelsDevCacheArtifact) + case failure(ModelsDevCache.Error) + } + + private struct Entry { + let modificationDate: Date? + let size: Int? + let outcome: Outcome + } + + private let lock = NSLock() + private var entries: [String: Entry] = [:] + + func outcome(path: String, modificationDate: Date?, size: Int?) -> Outcome? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entries[path], + entry.modificationDate == modificationDate, + entry.size == size + else { + return nil + } + return entry.outcome + } + + func store(path: String, modificationDate: Date?, size: Int?, outcome: Outcome) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries[path] = Entry(modificationDate: modificationDate, size: size, outcome: outcome) + } + + func invalidate(path: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries.removeValue(forKey: path) + } +} + enum ModelsDevCache { enum Error: Swift.Error, Equatable { case unreadable @@ -328,6 +379,17 @@ enum ModelsDevCache { static let artifactVersion = 1 static let ttlSeconds: TimeInterval = 24 * 60 * 60 + private static let memo = ModelsDevCacheMemo() + + private static func fileMetadata(at url: URL) -> (modificationDate: Date?, size: Int?) { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return (nil, nil) + } + let modificationDate = attributes[.modificationDate] as? Date + let size = (attributes[.size] as? NSNumber)?.intValue + return (modificationDate, size) + } + private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) @@ -342,23 +404,52 @@ enum ModelsDevCache { static func load(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCacheLoadResult { let url = self.cacheFileURL(cacheRoot: cacheRoot) + let metadata = Self.fileMetadata(at: url) + + // Staleness depends on `now`, so the result is always rebuilt; only the read+decode outcome is memoized. + if let outcome = Self.memo.outcome( + path: url.path, + modificationDate: metadata.modificationDate, + size: metadata.size) + { + return Self.result(for: outcome, now: now) + } + + let outcome = Self.readOutcome(at: url) + Self.memo.store( + path: url.path, + modificationDate: metadata.modificationDate, + size: metadata.size, + outcome: outcome) + return Self.result(for: outcome, now: now) + } + + private static func readOutcome(at url: URL) -> ModelsDevCacheMemo.Outcome { guard let data = try? Data(contentsOf: url) else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .unreadable) + return .failure(.unreadable) } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let decoded = try? decoder.decode(ModelsDevCacheArtifact.self, from: data) else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidJSON) + return .failure(.invalidJSON) } guard decoded.version == Self.artifactVersion else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidVersion) + return .failure(.invalidVersion) + } + return .decoded(decoded) + } + + private static func result(for outcome: ModelsDevCacheMemo.Outcome, now: Date) -> ModelsDevCacheLoadResult { + switch outcome { + case let .decoded(artifact): + ModelsDevCacheLoadResult( + artifact: artifact, + isStale: now.timeIntervalSince(artifact.fetchedAt) > Self.ttlSeconds, + error: nil) + case let .failure(error): + ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: error) } - - return ModelsDevCacheLoadResult( - artifact: decoded, - isStale: now.timeIntervalSince(decoded.fetchedAt) > Self.ttlSeconds, - error: nil) } static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) { @@ -386,6 +477,8 @@ enum ModelsDevCache { } else { try FileManager.default.moveItem(at: tmp, to: url) } + // The on-disk catalog changed; drop the memo so the next load decodes the fresh file. + Self.memo.invalidate(path: url.path) } catch { try? FileManager.default.removeItem(at: tmp) } diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 3a4d1807d..90bdeed5d 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -508,6 +508,72 @@ struct ModelsDevPricingTests { #expect(load.error == .invalidJSON) } + @Test + func `serves decoded catalog from memo while the file is unchanged`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + + // Pin a whole-second modification date so the memo key (which compares modification dates) round-trips + // deterministically through the filesystem. + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + // Prime the in-memory memo with a successful decode. + let primed = ModelsDevCache.load(cacheRoot: root) + let cachedArtifact = try #require(primed.artifact) + + // Corrupt the file contents while preserving its size and modification date, so the on-disk identity + // the memo keys on is unchanged. A re-decode would now fail; a memo hit returns the cached artifact. + let size = try #require( + try (FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber).intValue + try Data(repeating: 0, count: size).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == nil) + #expect(reloaded.artifact == cachedArtifact) + } + + @Test + func `saving a new catalog invalidates the memo`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + #expect(ModelsDevCache.load(cacheRoot: root).artifact?.catalog.providers["openai"] != nil) + + // Overwriting the cache must drop the memo so the next load reflects the freshly written catalog. + ModelsDevCache.save(catalog: ModelsDevCatalog(providers: [:]), fetchedAt: Date(), cacheRoot: root) + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == nil) + #expect(reloaded.artifact?.catalog.providers.isEmpty == true) + } + + @Test + func `serves a failed load from memo while the file is unchanged`() throws { + let root = try Self.cacheRoot() + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let validData = try Self.encodedArtifactData() + + // Write invalid JSON of the same size as a valid encoding, with a pinned modification date, then prime + // the memo with the resulting failure. + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try Data(repeating: 0x7B, count: validData.count).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + #expect(ModelsDevCache.load(cacheRoot: root).error == .invalidJSON) + + // Replace the bytes with a valid encoding of identical size + modification date. A re-read would now + // succeed, so a returned failure proves the unchanged-identity file was not read and decoded again. + try validData.write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == .invalidJSON) + #expect(reloaded.artifact == nil) + } + @Test func `client fetches with mock transport`() async throws { let data = try Self.fixtureData() @@ -545,6 +611,17 @@ struct ModelsDevPricingTests { try JSONDecoder().decode(ModelsDevCatalog.self, from: self.fixtureData()) } + /// A valid `ModelsDevCacheArtifact` encoding, written the same way `ModelsDevCache.save` writes the file. + private static func encodedArtifactData() throws -> Data { + let artifact = try ModelsDevCacheArtifact( + version: ModelsDevCache.artifactVersion, + fetchedAt: Date(timeIntervalSince1970: 0), + catalog: self.fixtureCatalog()) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(artifact) + } + private static func catalog(_ json: String) throws -> ModelsDevCatalog { try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } From 0da296f878902a333bfa2c9a455ee5a2d27f782d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Jun 2026 19:02:38 -0700 Subject: [PATCH 33/93] fix: respect constrained Antigravity summary lane --- CHANGELOG.md | 1 + .../MenuBarMetricWindowResolver.swift | 5 +- .../CodexBar/UsageStore+HighestUsage.swift | 11 +++++ .../MenuBarMetricWindowResolverTests.swift | 47 +++++++++++++++++++ .../UsageStoreHighestUsageTests.swift | 39 +++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 999a4755f..2738c7d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! ### Fixed +- Antigravity: make the automatic menu-bar summary choose the most constrained family quota so an exhausted Gemini lane is no longer hidden by a full Claude lane (#1334). Thanks @dhruv-anand-aintech! - Performance: memoize models.dev cost catalog load outcomes so large Codex history scans no longer re-read and decode the same cache file per row (#1322, refs #1311). Thanks @turbothad! - Menu bar: compute Claude pace/reserve from the selected menu-bar metric window so Primary (Session) no longer pairs the session percentage with the weekly reserve (#1302). Thanks @outfoxer! - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index bf603407d..20085b502 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -84,7 +84,10 @@ enum MenuBarMetricWindowResolver { private static func automaticWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { if provider == .antigravity { - return self.window(in: snapshot, following: [.primary, .secondary, .tertiary]) + return self.mostConstrainedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: snapshot.tertiary) } if provider == .perplexity { return snapshot.automaticPerplexityWindow() diff --git a/Sources/CodexBar/UsageStore+HighestUsage.swift b/Sources/CodexBar/UsageStore+HighestUsage.swift index 9a2d730e3..3f8f986c2 100644 --- a/Sources/CodexBar/UsageStore+HighestUsage.swift +++ b/Sources/CodexBar/UsageStore+HighestUsage.swift @@ -60,6 +60,17 @@ extension UsageStore { guard !percents.isEmpty else { return true } return percents.allSatisfy { $0 >= 100 } } + if provider == .antigravity, + effectivePreference == .automatic + { + let percents = [ + snapshot.primary?.usedPercent, + snapshot.secondary?.usedPercent, + snapshot.tertiary?.usedPercent, + ].compactMap(\.self) + guard !percents.isEmpty else { return true } + return percents.allSatisfy { $0 >= 100 } + } return true } } diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 4047bbaef..d0e769550 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -38,6 +38,53 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.windowMinutes == 7 * 24 * 60) } + @Test + func `automatic metric uses constrained antigravity family lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.resetDescription == "Gemini Pro") + } + + @Test + func `explicit antigravity metric keeps requested family lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + let primary = MenuBarMetricWindowResolver.rateWindow( + preference: .primary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + let secondary = MenuBarMetricWindowResolver.rateWindow( + preference: .secondary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + let tertiary = MenuBarMetricWindowResolver.rateWindow( + preference: .tertiary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(primary?.resetDescription == "Claude") + #expect(secondary?.resetDescription == "Gemini Pro") + #expect(tertiary?.resetDescription == "Gemini Flash") + } + @Test func `extra usage metric maps provider cost into a menu bar window`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 49a353234..ebdbd3d16 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -156,6 +156,45 @@ struct UsageStoreHighestUsageTests { #expect(highest?.usedPercent == 85) } + @Test + func `automatic metric ranks antigravity by constrained gemini family lane`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-constrained-gemini"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 100) + } + @Test func `automatic metric uses zai 5-hour token lane when ranking highest usage`() { let settings = SettingsStore( From 610bdbc9c39c061c860d0a6593c93df4b5093b6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Jun 2026 19:48:18 -0700 Subject: [PATCH 34/93] fix: show Codex Spark pace details --- CHANGELOG.md | 1 + .../CodexBar/MenuCardView+ModelHelpers.swift | 40 ++++++++++++++++--- .../MenuCardModelCodexProjectionTests.swift | 12 ++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2738c7d0f..b977955b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! ### Fixed +- Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! - Antigravity: make the automatic menu-bar summary choose the most constrained family quota so an exhausted Gemini lane is no longer hidden by a full Claude lane (#1334). Thanks @dhruv-anand-aintech! - Performance: memoize models.dev cost catalog load outcomes so large Codex history scans no longer re-read and decode the same cache file per row (#1322, refs #1311). Thanks @turbothad! - Menu bar: compute Claude pace/reserve from the selected menu-bar metric window so Primary (Session) no longer pairs the session percentage with the weekly reserve (#1302). Thanks @outfoxer! diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 973ef05c1..a86d208ae 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -175,7 +175,11 @@ extension UsageMenuCardView.Model { return [] } return extraRateWindows.map { namedWindow in - Metric( + let paceDetail = Self.extraRateWindowPaceDetail( + provider: input.provider, + window: namedWindow.window, + input: input) + return Metric( id: namedWindow.id, title: namedWindow.title, percent: Self.clamped( @@ -188,10 +192,36 @@ extension UsageMenuCardView.Model { style: input.resetTimeDisplayStyle, now: input.now), detailText: nil, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) + detailLeftText: paceDetail?.leftLabel, + detailRightText: paceDetail?.rightLabel, + pacePercent: paceDetail?.pacePercent, + paceOnTop: paceDetail?.paceOnTop ?? true) + } + } + + private static func extraRateWindowPaceDetail( + provider: UsageProvider, + window: RateWindow, + input: Input) -> PaceDetail? + { + guard provider == .codex else { return nil } + switch window.windowMinutes { + case 300: + return self.sessionPaceDetail( + provider: provider, + window: window, + now: input.now, + showUsed: input.usageBarsShowUsed) + case 10080: + let pace = UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) + .flatMap { $0.expectedUsedPercent >= 3 ? $0 : nil } + return Self.weeklyPaceDetail( + window: window, + now: input.now, + pace: pace, + showUsed: input.usageBarsShowUsed) + default: + return nil } } diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift index cd6e59419..506f4b23e 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -632,9 +632,9 @@ struct MenuCardModelCodexProjectionTests { id: "codex-spark", title: "Codex Spark 5-hour", window: RateWindow( - usedPercent: 30, + usedPercent: 80, windowMinutes: 300, - resetsAt: now.addingTimeInterval(60 * 60), + resetsAt: now.addingTimeInterval(2 * 60 * 60), resetDescription: nil)), NamedRateWindow( id: "codex-spark-weekly", @@ -683,14 +683,18 @@ struct MenuCardModelCodexProjectionTests { let spark = try #require(model.metrics.first { $0.id == "codex-spark" }) #expect(spark.title == "Codex Spark 5-hour") - #expect(spark.percent == 70) - #expect(spark.percentLabel == "70% left") + #expect(spark.percent == 20) + #expect(spark.percentLabel == "20% left") #expect(spark.resetText != nil) + #expect(spark.detailLeftText == "20% in deficit") + #expect(spark.detailRightText == "Projected empty in 45m") let sparkWeekly = try #require(model.metrics.first { $0.id == "codex-spark-weekly" }) #expect(sparkWeekly.title == "Codex Spark Weekly") #expect(sparkWeekly.percent == 0) #expect(sparkWeekly.percentLabel == "0% left") #expect(sparkWeekly.resetText != nil) + #expect(sparkWeekly.detailLeftText == "86% in deficit") + #expect(sparkWeekly.detailRightText == "Runs out now") // Spark trails the core session/weekly lanes rather than replacing them. let sparkIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark" }) let sparkWeeklyIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark-weekly" }) From 91824283af5f01d0912b493c58ee23d1a2a141dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Jun 2026 20:47:09 -0700 Subject: [PATCH 35/93] fix: show Cursor billing-cycle pace details --- CHANGELOG.md | 1 + .../CodexBar/MenuCardView+ModelHelpers.swift | 19 +++++++ Sources/CodexBar/MenuCardView.swift | 22 +++++++-- .../Providers/Cursor/CursorStatusProbe.swift | 29 +++++++++-- .../CursorMenuCardModelTests.swift | 49 +++++++++++++++++++ .../CursorStatusProbeTests.swift | 10 +++- 6 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 Tests/CodexBarTests/CursorMenuCardModelTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b977955b3..2598a7495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! ### Fixed +- Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech! - Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! - Antigravity: make the automatic menu-bar summary choose the most constrained family quota so an exhausted Gemini lane is no longer hidden by a full Claude lane (#1334). Thanks @dhruv-anand-aintech! - Performance: memoize models.dev cost catalog load outcomes so large Codex history scans no longer re-read and decode the same cache file per row (#1322, refs #1311). Thanks @turbothad! diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index a86d208ae..54d6ce015 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -133,6 +133,25 @@ extension UsageMenuCardView.Model { paceOnTop: paceOnTop) } + static func cursorBillingCyclePaceDetail( + window: RateWindow, + input: Input, + pace: UsagePace? = nil) -> PaceDetail? + { + guard input.provider == .cursor, + window.windowMinutes != nil + else { return nil } + let resolved = pace ?? UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) + guard let resolved, + resolved.expectedUsedPercent >= 3 + else { return nil } + return Self.weeklyPaceDetail( + window: window, + now: input.now, + pace: resolved, + showUsed: input.usageBarsShowUsed) + } + static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left var metrics = [ diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 4d345bf6e..1f5d6a8d1 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1156,6 +1156,7 @@ extension UsageMenuCardView.Model { let opusResetText: String? = input.provider == .perplexity ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) : Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now) + let tertiaryPaceDetail = Self.cursorBillingCyclePaceDetail(window: opus, input: input) metrics.append(Metric( id: "tertiary", title: labels.tertiary, @@ -1163,10 +1164,10 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, resetText: opusResetText, detailText: tertiaryDetailText, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true, + detailLeftText: tertiaryPaceDetail?.leftLabel, + detailRightText: tertiaryPaceDetail?.rightLabel, + pacePercent: tertiaryPaceDetail?.pacePercent, + paceOnTop: tertiaryPaceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( thresholds: input.quotaWarningThresholds[.weekly], showUsed: input.usageBarsShowUsed))) @@ -1319,6 +1320,12 @@ extension UsageMenuCardView.Model { } } } + if let paceDetail = Self.cursorBillingCyclePaceDetail(window: primary, input: input) { + primaryDetailLeft = paceDetail.leftLabel + primaryDetailRight = paceDetail.rightLabel + primaryPacePercent = paceDetail.pacePercent + primaryPaceOnTop = paceDetail.paceOnTop + } if input.provider == .synthetic, let regen = Self.syntheticRollingRegenDetail( window: primary, @@ -1420,6 +1427,13 @@ extension UsageMenuCardView.Model { { paceDetail = PaceDetail(leftLabel: detail, rightLabel: nil, pacePercent: nil, paceOnTop: true) } + if let cursorPaceDetail = Self.cursorBillingCyclePaceDetail( + window: weekly, + input: input, + pace: input.weeklyPace) + { + paceDetail = cursorPaceDetail + } // Perplexity bonus credits don't reset; show balance without "Resets" prefix. if input.provider == .perplexity, let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index bb94fdfca..b530c7b40 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -356,6 +356,8 @@ public struct CursorStatusSnapshot: Sendable { public let teamOnDemandUsedUSD: Double? /// Team on-demand limit in USD public let teamOnDemandLimitUSD: Double? + /// Billing cycle start date + public let billingCycleStart: Date? /// Billing cycle reset date public let billingCycleEnd: Date? /// Membership type (e.g., "enterprise", "pro", "hobby") @@ -389,6 +391,7 @@ public struct CursorStatusSnapshot: Sendable { onDemandLimitUSD: Double?, teamOnDemandUsedUSD: Double?, teamOnDemandLimitUSD: Double?, + billingCycleStart: Date? = nil, billingCycleEnd: Date?, membershipType: String?, accountEmail: String?, @@ -406,6 +409,7 @@ public struct CursorStatusSnapshot: Sendable { self.onDemandLimitUSD = onDemandLimitUSD self.teamOnDemandUsedUSD = teamOnDemandUsedUSD self.teamOnDemandLimitUSD = teamOnDemandLimitUSD + self.billingCycleStart = billingCycleStart self.billingCycleEnd = billingCycleEnd self.membershipType = membershipType self.accountEmail = accountEmail @@ -428,9 +432,13 @@ public struct CursorStatusSnapshot: Sendable { self.planPercentUsed } + let billingCycleWindowMinutes = Self.billingCycleWindowMinutes( + start: self.billingCycleStart, + end: self.billingCycleEnd) + let primary = RateWindow( usedPercent: primaryUsedPercent, - windowMinutes: nil, + windowMinutes: billingCycleWindowMinutes, resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) @@ -438,7 +446,7 @@ public struct CursorStatusSnapshot: Sendable { let secondary: RateWindow? = self.autoPercentUsed.map { pct in RateWindow( usedPercent: pct, - windowMinutes: nil, + windowMinutes: billingCycleWindowMinutes, resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) } @@ -447,7 +455,7 @@ public struct CursorStatusSnapshot: Sendable { let tertiary: RateWindow? = self.apiPercentUsed.map { pct in RateWindow( usedPercent: pct, - windowMinutes: nil, + windowMinutes: billingCycleWindowMinutes, resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) } @@ -502,6 +510,14 @@ public struct CursorStatusSnapshot: Sendable { return "Resets " + formatter.string(from: date) } + private static func billingCycleWindowMinutes(start: Date?, end: Date?) -> Int? { + guard let start, + let end + else { return nil } + let minutes = Int((end.timeIntervalSince(start) / 60).rounded()) + return minutes > 0 ? minutes : nil + } + private static func formatMembershipType(_ type: String) -> String { switch type.lowercased() { case "enterprise": @@ -1018,12 +1034,14 @@ public struct CursorStatusProbe: Sendable { rawJSON: String?, requestUsage: CursorUsageResponse? = nil) -> CursorStatusSnapshot { - // Parse billing cycle end date - let billingCycleEnd: Date? = summary.billingCycleEnd.flatMap { dateString in + func parseBillingCycleDate(_ dateString: String?) -> Date? { + guard let dateString else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.date(from: dateString) ?? ISO8601DateFormatter().date(from: dateString) } + let billingCycleStart = parseBillingCycleDate(summary.billingCycleStart) + let billingCycleEnd = parseBillingCycleDate(summary.billingCycleEnd) // Convert cents to USD (plan percent derives from raw values to avoid percent unit mismatches). // Use plan.limit directly - breakdown.total represents total *used* credits, not the limit. @@ -1119,6 +1137,7 @@ public struct CursorStatusProbe: Sendable { onDemandLimitUSD: onDemandLimit, teamOnDemandUsedUSD: teamOnDemandUsed, teamOnDemandLimitUSD: teamOnDemandLimit, + billingCycleStart: billingCycleStart, billingCycleEnd: billingCycleEnd, membershipType: summary.membershipType, accountEmail: userInfo?.email, diff --git a/Tests/CodexBarTests/CursorMenuCardModelTests.swift b/Tests/CodexBarTests/CursorMenuCardModelTests.swift new file mode 100644 index 000000000..7ac845b4a --- /dev/null +++ b/Tests/CodexBarTests/CursorMenuCardModelTests.swift @@ -0,0 +1,49 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CursorMenuCardModelTests { + @Test + func `cursor billing cycle metrics show deficit and run out details`() throws { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval(6 * 24 * 3600) + let cycleMinutes = 30 * 24 * 60 + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + secondary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + tertiary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Total", "Auto", "API"]) + for metric in model.metrics { + #expect(metric.percentLabel == "10% left") + #expect(metric.detailLeftText == "10% in deficit") + #expect(metric.detailRightText == "Runs out in 2d 16h") + #expect(metric.pacePercent == 20) + #expect(metric.paceOnTop == false) + } + } +} diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index e34277f9f..988d14e2e 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -314,7 +314,12 @@ struct CursorStatusProbeTests { #expect(snapshot.planPercentUsed == 0.441025641025641) #expect(snapshot.autoPercentUsed == 0.36) #expect(snapshot.apiPercentUsed == 0.7111111111111111) - #expect(snapshot.toUsageSnapshot().primary?.remainingPercent == 99.55897435897436) + #expect(snapshot.billingCycleStart != nil) + let usageSnapshot = snapshot.toUsageSnapshot() + #expect(usageSnapshot.primary?.remainingPercent == 99.55897435897436) + #expect(usageSnapshot.primary?.windowMinutes == 44640) + #expect(usageSnapshot.secondary?.windowMinutes == 44640) + #expect(usageSnapshot.tertiary?.windowMinutes == 44640) } @Test @@ -329,6 +334,7 @@ struct CursorStatusProbeTests { onDemandLimitUSD: 100.0, teamOnDemandUsedUSD: 25.0, teamOnDemandLimitUSD: 500.0, + billingCycleStart: Date(timeIntervalSince1970: 1_735_689_600), // Jan 1, 2025 billingCycleEnd: Date(timeIntervalSince1970: 1_738_368_000), // Feb 1, 2025 membershipType: "pro", accountEmail: "user@example.com", @@ -342,6 +348,8 @@ struct CursorStatusProbeTests { #expect(usageSnapshot.loginMethod(for: .cursor) == "Cursor Pro") #expect(usageSnapshot.secondary != nil) #expect(usageSnapshot.secondary?.usedPercent == 5.0) + #expect(usageSnapshot.primary?.windowMinutes == 44640) + #expect(usageSnapshot.secondary?.windowMinutes == 44640) #expect(usageSnapshot.providerCost?.used == 5.0) #expect(usageSnapshot.providerCost?.limit == 100.0) #expect(usageSnapshot.providerCost?.currencyCode == "USD") From 9a2d78032d4da3bafdea561a55da11081fbe3ee6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Jun 2026 22:10:21 -0700 Subject: [PATCH 36/93] fix: time out stalled managed Codex login --- CHANGELOG.md | 1 + Sources/CodexBar/CodexLoginRunner.swift | 81 +++++++++++++++---- .../CodexBarTests/CodexLoginRunnerTests.swift | 40 +++++++++ .../ManagedCodexAccountCoordinatorTests.swift | 36 +++++++++ 4 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 Tests/CodexBarTests/CodexLoginRunnerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2598a7495..32e6b2313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech! +- Codex: time out stalled managed `codex login` processes so account switches no longer stay stuck in progress after OAuth completes (#1330). Thanks @dhruv-anand-aintech! - Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! - Antigravity: make the automatic menu-bar summary choose the most constrained family quota so an exhausted Gemini lane is no longer hidden by a full Claude lane (#1334). Thanks @dhruv-anand-aintech! - Performance: memoize models.dev cost catalog load outcomes so large Codex history scans no longer re-read and decode the same cache file per row (#1322, refs #1311). Thanks @turbothad! diff --git a/Sources/CodexBar/CodexLoginRunner.swift b/Sources/CodexBar/CodexLoginRunner.swift index f6734588e..e92933f5a 100644 --- a/Sources/CodexBar/CodexLoginRunner.swift +++ b/Sources/CodexBar/CodexLoginRunner.swift @@ -16,18 +16,23 @@ struct CodexLoginRunner { let output: String } - static func run(homePath: String? = nil, timeout: TimeInterval = 120) async -> Result { + static func run( + homePath: String? = nil, + timeout: TimeInterval = 120, + environment: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current) async -> Result + { await Task(priority: .userInitiated) { - var env = ProcessInfo.processInfo.environment + var env = environment env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .tty, .nodeTooling], env: env, - loginPATH: LoginShellPathCache.shared.current) + loginPATH: loginPATH) env = CodexHomeScope.scopedEnvironment(base: env, codexHome: homePath) guard let executable = BinaryLocator.resolveCodexBinary( env: env, - loginPATH: LoginShellPathCache.shared.current) + loginPATH: loginPATH) else { return Result(outcome: .missingBinary, output: "") } @@ -42,6 +47,11 @@ struct CodexLoginRunner { process.standardOutput = stdout process.standardError = stderr + let termination = ProcessTermination() + process.terminationHandler = { _ in + termination.resolve(timedOut: false) + } + var processGroup: pid_t? do { try process.run() @@ -50,7 +60,7 @@ struct CodexLoginRunner { return Result(outcome: .launchFailed(error.localizedDescription), output: "") } - let timedOut = await self.wait(for: process, timeout: timeout) + let timedOut = await self.wait(timeout: timeout, termination: termination) if timedOut { self.terminate(process, processGroup: processGroup) } @@ -68,23 +78,60 @@ struct CodexLoginRunner { }.value } - private static func wait(for process: Process, timeout: TimeInterval) async -> Bool { - await withTaskGroup(of: Bool.self) { group -> Bool in - group.addTask { - process.waitUntilExit() - return false + private final class ProcessTermination: @unchecked Sendable { + private let lock = NSLock() + private var timedOut: Bool? + private var continuation: CheckedContinuation? + + func resolve(timedOut: Bool) { + let continuation: CheckedContinuation? + self.lock.lock() + guard self.timedOut == nil else { + self.lock.unlock() + return } - group.addTask { - let nanos = UInt64(max(0, timeout) * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanos) - return true + self.timedOut = timedOut + continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: timedOut) + } + + func wait() async -> Bool { + await withCheckedContinuation { continuation in + let timedOut: Bool? + self.lock.lock() + timedOut = self.timedOut + if timedOut == nil { + self.continuation = continuation + } + self.lock.unlock() + + if let timedOut { + continuation.resume(returning: timedOut) + } } - let result = await group.next() ?? false - group.cancelAll() - return result } } + private static func wait(timeout: TimeInterval, termination: ProcessTermination) async -> Bool { + let timeoutTask = Task.detached(priority: .userInitiated) { + try? await Task.sleep(nanoseconds: self.timeoutNanoseconds(timeout)) + if Task.isCancelled == false { + termination.resolve(timedOut: true) + } + } + let timedOut = await termination.wait() + timeoutTask.cancel() + return timedOut + } + + private static func timeoutNanoseconds(_ timeout: TimeInterval) -> UInt64 { + guard timeout.isFinite else { return UInt64.max } + let seconds = max(0, min(timeout, Double(UInt64.max) / 1_000_000_000)) + return UInt64(seconds * 1_000_000_000) + } + private static func terminate(_ process: Process, processGroup: pid_t?) { if let pgid = processGroup { kill(-pgid, SIGTERM) diff --git a/Tests/CodexBarTests/CodexLoginRunnerTests.swift b/Tests/CodexBarTests/CodexLoginRunnerTests.swift new file mode 100644 index 000000000..dd33a5397 --- /dev/null +++ b/Tests/CodexBarTests/CodexLoginRunnerTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import CodexBar + +struct CodexLoginRunnerTests { + @Test + func `login runner returns timeout before hung codex exits`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-login-runner-\(UUID().uuidString)", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let homeDir = root.appendingPathComponent("home", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: homeDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let codex = binDir.appendingPathComponent("codex") + let script = """ + #!/usr/bin/python3 + import time + + print("login-started", flush=True) + time.sleep(5) + print("login-finished", flush=True) + """ + try script.write(to: codex, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path) + + let start = Date() + let result = await CodexLoginRunner.run( + homePath: homeDir.path, + timeout: 0.2, + environment: ["PATH": binDir.path], + loginPATH: nil) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.outcome == .timedOut) + #expect(result.output.contains("login-finished") == false) + #expect(elapsed < 2.0, "Timeout should return promptly, took \(elapsed)s") + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift b/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift index 5841ace2e..9e042d7eb 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift @@ -38,6 +38,34 @@ struct ManagedCodexAccountCoordinatorTests { #expect(coordinator.isAuthenticatingManagedAccount == false) #expect(coordinator.authenticatingManagedAccountID == nil) } + + @Test + func `coordinator clears in flight state after managed login timeout`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let loginResult = CodexLoginRunner.Result(outcome: .timedOut, output: "timed out") + let existingAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStoreForCoordinatorTests( + accounts: ManagedCodexAccountSet(version: 1, accounts: [])), + homeFactory: CoordinatorTestManagedCodexHomeFactory(root: root), + loginRunner: TimedOutManagedCodexLoginRunner(result: loginResult), + identityReader: CoordinatorStubManagedCodexIdentityReader(email: "user@example.com")) + let coordinator = ManagedCodexAccountCoordinator(service: service) + + do { + _ = try await coordinator.authenticateManagedAccount(existingAccountID: existingAccountID, timeout: 0.2) + Issue.record("Expected managed login timeout to throw") + } catch let error as ManagedCodexAccountServiceError { + #expect(error == .loginFailed(loginResult)) + } catch { + Issue.record("Expected ManagedCodexAccountServiceError.loginFailed, got \(error)") + } + + #expect(coordinator.isAuthenticatingManagedAccount == false) + #expect(coordinator.authenticatingManagedAccountID == nil) + } } private actor BlockingManagedCodexLoginRunner: ManagedCodexLoginRunning { @@ -68,6 +96,14 @@ private actor BlockingManagedCodexLoginRunner: ManagedCodexLoginRunning { } } +private struct TimedOutManagedCodexLoginRunner: ManagedCodexLoginRunning { + let result: CodexLoginRunner.Result + + func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + self.result + } +} + private final class InMemoryManagedCodexAccountStoreForCoordinatorTests: ManagedCodexAccountStoring, @unchecked Sendable { var snapshot: ManagedCodexAccountSet From 99704e0108327595f8a4d3163204c6341d2a1727 Mon Sep 17 00:00:00 2001 From: Ellis Nieuwpoort <121954036+enieuwy@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:53:46 +0800 Subject: [PATCH 37/93] docs(cli): document serve request timeout --- Sources/CodexBarCLI/CLIHelp.swift | 4 +++- Tests/CodexBarTests/CLIServeRouterTests.swift | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 27e705524..de451398a 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -78,6 +78,7 @@ extension CodexBarCLI { Usage: codexbar serve [--port ] [--refresh-interval ] + [--request-timeout ] [--json-output] [--log-level ] [-v|--verbose] @@ -95,7 +96,7 @@ extension CodexBarCLI { Examples: codexbar serve - codexbar serve --port 8080 --refresh-interval 60 + codexbar serve --port 8080 --refresh-interval 60 --request-timeout 30 curl http://127.0.0.1:8080/usage?provider=all """ } @@ -209,6 +210,7 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] codexbar serve [--port ] [--refresh-interval ] + [--request-timeout ] [--json-output] [--log-level ] [-v|--verbose] codexbar config [--format text|json] [--json] diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index 4b21f61bf..5dbf31cce 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -128,6 +128,16 @@ struct CLIServeRouterTests { flags: [])) == 30) } + @Test + func `serve help documents request timeout option`() { + let serve = CodexBarCLI.serveHelp(version: "0.0.0") + let root = CodexBarCLI.rootHelp(version: "0.0.0") + + #expect(serve.contains("--request-timeout ")) + #expect(serve.contains("codexbar serve --port 8080 --refresh-interval 60 --request-timeout 30")) + #expect(root.contains("--request-timeout ")) + } + @Test func `serve cache skips provider error payloads`() { let success = CLILocalHTTPResponse( From 4da1e510df8e8cf2319c4ce4db831fcb2fddbe3a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 07:20:02 +0100 Subject: [PATCH 38/93] fix: clean up Claude probe session artifacts --- CHANGELOG.md | 1 + .../ClaudeProbeSessionArtifactCleaner.swift | 105 ++++++++++++++++++ .../Providers/Claude/ClaudeStatusProbe.swift | 11 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 5 + .../ClaudeProbeWorkingDirectoryTests.swift | 84 ++++++++++++++ docs/claude.md | 2 + 6 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e6b2313..a7ddb5496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! ### Fixed +- Claude: remove transient ClaudeProbe session artifacts after CLI usage polls so background refreshes no longer fill Claude Code project history with CodexBar `/usage` sessions (#1301). Thanks @LPFchan and @matthewod11-stack! - Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech! - Codex: time out stalled managed `codex login` processes so account switches no longer stay stuck in progress after OAuth completes (#1330). Thanks @dhruv-anand-aintech! - Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift new file mode 100644 index 000000000..fa7cce370 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift @@ -0,0 +1,105 @@ +import Foundation + +enum ClaudeProbeSessionArtifactCleaner { + private static let log = CodexBarLog.logger(LogCategories.claudeProbe) + private static let maxProjectDirectoryNameLength = 200 + + @discardableResult + static func cleanupProbeSessionArtifacts( + probeDirectory: URL = ClaudeStatusProbe.probeWorkingDirectoryURL(), + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager fm: FileManager = .default) -> [URL] + { + let projectDirectoryName = self.claudeProjectDirectoryName(for: probeDirectory) + var visitedDirectories = Set() + var removedFiles: [URL] = [] + + for root in self.claudeConfigRoots(environment: environment, fileManager: fm) { + let projectsRoot = root.appendingPathComponent("projects", isDirectory: true) + let directories = [projectsRoot.appendingPathComponent(projectDirectoryName, isDirectory: true)] + + for directory in directories where visitedDirectories.insert(directory.path).inserted { + guard let entries = try? fm.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { continue } + + for entry in entries where entry.pathExtension == "jsonl" { + let values = try? entry.resourceValues(forKeys: [.isRegularFileKey]) + guard values?.isRegularFile == true else { continue } + do { + try fm.removeItem(at: entry) + removedFiles.append(entry) + } catch { + Self.log.debug( + "Claude probe session artifact cleanup skipped file", + metadata: ["error": error.localizedDescription]) + } + } + + if (try? fm.contentsOfDirectory(atPath: directory.path).isEmpty) == true { + try? fm.removeItem(at: directory) + } + } + } + + return removedFiles + } + + static func claudeProjectDirectoryName(for directory: URL) -> String { + let path = directory.path.precomposedStringWithCanonicalMapping + let sanitized = String(path.utf16.map { codeUnit in + switch codeUnit { + case 48...57, 65...90, 97...122: + Character(UnicodeScalar(codeUnit)!) + default: + "-" + } + }) + + guard sanitized.count > self.maxProjectDirectoryNameLength else { return sanitized } + return "\(sanitized.prefix(self.maxProjectDirectoryNameLength))-\(self.javaScriptHashBase36(path))" + } + + private static func javaScriptHashBase36(_ string: String) -> String { + var hash: Int32 = 0 + for codeUnit in string.utf16 { + hash = hash &* 31 &+ Int32(truncatingIfNeeded: codeUnit) + } + + let magnitude = hash < 0 ? -Int64(hash) : Int64(hash) + return String(magnitude, radix: 36) + } + + private static func claudeConfigRoots( + environment: [String: String], + fileManager fm: FileManager) -> [URL] + { + var roots: [URL] = [] + var seen = Set() + + func append(_ url: URL) { + let standardized = url.standardizedFileURL + guard seen.insert(standardized.path).inserted else { return } + roots.append(standardized) + } + + if let raw = environment["CLAUDE_CONFIG_DIR"] { + for part in raw.split(separator: ",") { + let path = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { continue } + append(URL(fileURLWithPath: path)) + } + } + + let home = environment["HOME"].flatMap { $0.isEmpty ? nil : $0 } ?? NSHomeDirectory() + append(URL(fileURLWithPath: home).appendingPathComponent(".claude", isDirectory: true)) + append(URL(fileURLWithPath: home).appendingPathComponent(".config/claude", isDirectory: true)) + + if roots.isEmpty { + append(fm.homeDirectoryForCurrentUser.appendingPathComponent(".claude", isDirectory: true)) + } + return roots + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 333ec400c..0680f8dba 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -112,17 +112,24 @@ public struct ClaudeStatusProbe: Sendable { "opusPercentLeft": "\(snap.opusPercentLeft ?? -1)", ]) if !keepAlive { - await ClaudeCLISession.shared.reset() + await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } return snap } catch { if !keepAlive { - await ClaudeCLISession.shared.reset() + await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } throw error } } + private static func resetTransientCLISessionAndCleanupProbeArtifacts() async { + await ClaudeCLISession.current.reset() + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts() + guard !removed.isEmpty else { return } + Self.log.debug("Claude probe session artifacts removed", metadata: ["count": "\(removed.count)"]) + } + // MARK: - Parsing helpers private struct LabelSearchContext { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index c7cceae96..fc1075bbd 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -1167,6 +1167,11 @@ extension ClaudeUsageFetcher { let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() var environment = ClaudeCLISession.launchEnvironment(baseEnv: self.environment) environment["PWD"] = workingDirectory.path + defer { + ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: workingDirectory, + environment: environment) + } let result = try await SubprocessRunner.run( binary: claudeBinary, diff --git a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift index 3a5f8724d..722ce155b 100644 --- a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift +++ b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift @@ -55,6 +55,90 @@ struct ClaudeProbeWorkingDirectoryTests { #expect(settings["disableDeepLinkRegistration"] as? String == "disable") } + @Test + func `probe project directory name matches Claude Code encoding`() { + let cases = [ + ( + "/Users/test/Library/Application Support/CodexBar/ClaudeProbe", + "-Users-test-Library-Application-Support-CodexBar-ClaudeProbe"), + ( + "/Users/test.name/t\u{00E9}st_under/Library/Application Support/CodexBar/ClaudeProbe", + "-Users-test-name-t-st-under-Library-Application-Support-CodexBar-ClaudeProbe"), + ( + "/Users/test/emoji_😀/ClaudeProbe", + "-Users-test-emoji----ClaudeProbe"), + ( + "/tmp/\(String(repeating: "segment_", count: 40))/ClaudeProbe", + "-tmp-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-" + + "segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-" + + "segment-segment-seg-x9mpdi"), + ] + + for (path, expected) in cases { + let directory = URL(fileURLWithPath: path) + #expect(ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: directory) == expected) + } + } + + @Test + func `cleanup removes only probe session jsonl artifacts`() throws { + let probeDirectory = try Self.makeTemporaryDirectory() + let claudeRoot = try Self.makeTemporaryDirectory() + let projectsRoot = claudeRoot.appendingPathComponent("projects", isDirectory: true) + let probeProject = projectsRoot + .appendingPathComponent( + ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: probeDirectory), + isDirectory: true) + let unrelatedProject = projectsRoot.appendingPathComponent("unrelated-project", isDirectory: true) + try FileManager.default.createDirectory(at: probeProject, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: unrelatedProject, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: probeDirectory) + try? FileManager.default.removeItem(at: claudeRoot) + } + + let probeSession = probeProject.appendingPathComponent("probe-session.jsonl") + let probeNote = probeProject.appendingPathComponent("keep.txt") + let unrelatedSession = unrelatedProject.appendingPathComponent("user-session.jsonl") + try Data("{}\n".utf8).write(to: probeSession) + try Data("keep".utf8).write(to: probeNote) + try Data("{}\n".utf8).write(to: unrelatedSession) + + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: probeDirectory, + environment: ["CLAUDE_CONFIG_DIR": claudeRoot.path, "HOME": claudeRoot.path]) + + #expect(removed.map(\.lastPathComponent) == ["probe-session.jsonl"]) + #expect(!FileManager.default.fileExists(atPath: probeSession.path)) + #expect(FileManager.default.fileExists(atPath: probeNote.path)) + #expect(FileManager.default.fileExists(atPath: unrelatedSession.path)) + } + + @Test + func `cleanup removes hashed long probe project artifacts`() throws { + let probeDirectory = URL(fileURLWithPath: "/tmp/\(String(repeating: "segment_", count: 40))/ClaudeProbe") + let claudeRoot = try Self.makeTemporaryDirectory() + let projectsRoot = claudeRoot.appendingPathComponent("projects", isDirectory: true) + let probeProject = projectsRoot + .appendingPathComponent( + ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: probeDirectory), + isDirectory: true) + try FileManager.default.createDirectory(at: probeProject, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: claudeRoot) + } + + let probeSession = probeProject.appendingPathComponent("probe-session.jsonl") + try Data("{}\n".utf8).write(to: probeSession) + + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: probeDirectory, + environment: ["CLAUDE_CONFIG_DIR": claudeRoot.path, "HOME": claudeRoot.path]) + + #expect(removed.map(\.lastPathComponent) == ["probe-session.jsonl"]) + #expect(!FileManager.default.fileExists(atPath: probeSession.path)) + } + private static func makeTemporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-claude-probe-\(UUID().uuidString)", isDirectory: true) diff --git a/docs/claude.md b/docs/claude.md index ecafe1786..7aa34287d 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -116,6 +116,8 @@ Admin API key setup: - Default behavior: exit after each probe; Debug → "Keep CLI sessions alive" keeps it running between probes. - Probe working directory: `~/Library/Application Support/CodexBar/ClaudeProbe` with local Claude settings that disable deep-link URL handler registration during headless probes. +- After transient probes exit, CodexBar removes Claude Code `.jsonl` session artifacts for that dedicated + `ClaudeProbe` project directory so background `/usage` polling does not clutter the user's Claude project history. - Command flow: 1) Start CLI with `--allowed-tools ""` (no tools). 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). From 1b29f5528d1278734d5eb957ad15c304ffe5b188 Mon Sep 17 00:00:00 2001 From: Ellis Nieuwpoort <121954036+enieuwy@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:26:35 +0800 Subject: [PATCH 39/93] docs: add showy-quota integration --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 4d8af8a2d..16640d0b4 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,9 @@ CLI install: - [noctalia-codex-usage](https://github.com/rayoplateado/noctalia-codex-usage) — Noctalia/Quickshell plugin that shows Codex 5-hour and weekly usage limits, built on top of the bundled Linux CLI. +## Status bar & terminal integration +- [showy-quota](https://github.com/enieuwy/showy-quota) — always-on AI plan quota strips for SketchyBar, tmux, and Zellij (standalone WASM plugin), built on `codexbar serve` / the bundled CLI. + ## Credits Inspired by [ccusage](https://github.com/ryoppippi/ccusage) (MIT), specifically the cost usage tracking. From 7cd8690648bd0804888e7a3fc6facfadfce43eea Mon Sep 17 00:00:00 2001 From: Rajvardhan Patil Date: Sun, 7 Jun 2026 12:15:59 +0530 Subject: [PATCH 40/93] fix: prevent z.ai overview submenu recursion --- .../CodexBar/StatusItemController+Menu.swift | 8 ++- .../StatusMenuOverviewSubmenuTests.swift | 64 +++++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 14 ++-- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index fb4081a2e..55a5b8470 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -558,9 +558,11 @@ extension StatusItemController { guard let self, let menu else { return } self.selectOverviewProvider(row.provider, menu: menu) }) - // Keep menu item action wired for keyboard activation and accessibility action paths. - item.target = self - item.action = #selector(self.selectOverviewProvider(_:)) + if submenu == nil { + // Keep plain rows wired for keyboard activation and accessibility action paths. + item.target = self + item.action = #selector(self.selectOverviewProvider(_:)) + } menu.addItem(item) if index < rows.count - 1 { menu.addItem(.separator()) diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index 6b90c164e..df65ad9e4 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -61,4 +61,68 @@ extension StatusMenuTests { ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) } + + @Test + func `overview row submenu action does not switch provider detail`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .zai || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: ZaiLimitEntry( + type: .timeLimit, + unit: .minutes, + number: 1, + usage: 100, + currentValue: 50, + remaining: 50, + percentage: 50, + usageDetails: [ZaiUsageDetail(modelCode: "glm-4.5", usage: 512)], + nextResetTime: now.addingTimeInterval(3600)), + planName: "Pro", + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .zai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let zaiRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-zai" + }) + #expect(zaiRow.submenu != nil) + + let action = try #require(zaiRow.action) + let target = try #require(zaiRow.target as? StatusItemController) + _ = target.perform(action, with: zaiRow) + + #expect(settings.mergedMenuLastSelectedWasOverview) + #expect(settings.selectedMenuProvider == .claude) + #expect(menu.items.contains { + ($0.representedObject as? String) == "overviewRow-zai" + }) + } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index c29d770e3..2abbd5182 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -1671,7 +1671,7 @@ extension StatusMenuTests { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || provider == .claude + let shouldEnable = provider == .codex || provider == .cursor settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } @@ -1688,15 +1688,15 @@ extension StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) - let claudeRow = try #require(menu.items.first { - ($0.representedObject as? String) == "overviewRow-claude" + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" }) - let action = try #require(claudeRow.action) - let target = try #require(claudeRow.target as? StatusItemController) - _ = target.perform(action, with: claudeRow) + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) #expect(settings.mergedMenuLastSelectedWasOverview == false) - #expect(settings.selectedMenuProvider == .claude) + #expect(settings.selectedMenuProvider == .cursor) let ids = self.representedIDs(in: menu) #expect(ids.contains("menuCard")) From 574c158ce1e881f0c571c025cb27a136d52511f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Jun 2026 23:47:39 -0700 Subject: [PATCH 41/93] docs: add z.ai submenu changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ddb5496..ce3668308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Claude: remove transient ClaudeProbe session artifacts after CLI usage polls so background refreshes no longer fill Claude Code project history with CodexBar `/usage` sessions (#1301). Thanks @LPFchan and @matthewod11-stack! +- Menu bar: keep z.ai overview rows with detail submenus in Overview so hovering quota details no longer recurses into a nested provider menu (#1279, fixes #1246). Thanks @RajvardhanPatil07! - Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech! - Codex: time out stalled managed `codex login` processes so account switches no longer stay stuck in progress after OAuth completes (#1330). Thanks @dhruv-anand-aintech! - Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! From fa8d8f636266cbe4a4a6fa07305ac3f429965c8c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 07:47:09 +0100 Subject: [PATCH 42/93] fix: backfill Codex account reset windows --- CHANGELOG.md | 1 + Sources/CodexBar/CodexOwnershipContext.swift | 43 +++ .../Codex/UsageStore+CodexAccountState.swift | 1 + .../CodexBar/UsageStore+PlanUtilization.swift | 29 ++ .../CodexBar/UsageStore+TokenAccounts.swift | 214 ++++++++++- Sources/CodexBarCore/UsageFetcher.swift | 7 +- ...CodexAccountScopedRefreshTestSupport.swift | 34 ++ ...exAccountVisibleHistoryBackfillTests.swift | 332 ++++++++++++++++++ .../ResetTimeBackfillTests.swift | 16 + 9 files changed, 664 insertions(+), 13 deletions(-) create mode 100644 Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3668308..c55ce9738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Claude: remove transient ClaudeProbe session artifacts after CLI usage polls so background refreshes no longer fill Claude Code project history with CodexBar `/usage` sessions (#1301). Thanks @LPFchan and @matthewod11-stack! - Menu bar: keep z.ai overview rows with detail submenus in Overview so hovering quota details no longer recurses into a nested provider menu (#1279, fixes #1246). Thanks @RajvardhanPatil07! +- Codex: backfill visible-account reset timestamps and missing 5-hour/weekly window metadata from same-workspace plan history so segmented multi-account JSON keeps machine-readable reset data (#1283). Thanks @callmepopo! - Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech! - Codex: time out stalled managed `codex login` processes so account switches no longer stay stuck in progress after OAuth completes (#1330). Thanks @dhruv-anand-aintech! - Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! diff --git a/Sources/CodexBar/CodexOwnershipContext.swift b/Sources/CodexBar/CodexOwnershipContext.swift index 09326cbfd..4432e9423 100644 --- a/Sources/CodexBar/CodexOwnershipContext.swift +++ b/Sources/CodexBar/CodexOwnershipContext.swift @@ -68,6 +68,34 @@ extension UsageStore { hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto()) } + func codexOwnershipContext( + forVisibleAccount account: CodexVisibleAccount, + currentWeeklyResetAt: Date? = nil) -> CodexOwnershipContext + { + let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) + let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(account.workspaceAccountID) + let canonicalIdentity: CodexIdentity = if let workspaceAccountID { + .providerAccount(id: workspaceAccountID) + } else if let normalizedEmail { + .emailOnly(normalizedEmail: normalizedEmail) + } else { + .unresolved + } + + return CodexOwnershipContext( + canonicalKey: CodexHistoryOwnership.canonicalKey(for: canonicalIdentity), + canonicalEmailHashKey: normalizedEmail.map { CodexHistoryOwnership.canonicalEmailHashKey(for: $0) }, + historicalLegacyEmailHash: normalizedEmail.map { + CodexHistoryOwnership.legacyEmailHash(normalizedEmail: $0) + }, + planUtilizationLegacyEmailHash: normalizedEmail.map { + Self.codexLegacyPlanUtilizationEmailHashKey(for: $0) + }, + currentWeeklyResetAt: currentWeeklyResetAt, + hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto() || + self.codexVisibleAccountsHaveAdjacentMultiAccountVeto()) + } + func codexHasAdjacentMultiAccountVeto() -> Bool { let snapshot = self.settings.codexAccountReconciliationSnapshot var distinctAccounts: Set = [] @@ -87,6 +115,21 @@ extension UsageStore { return distinctAccounts.count > 1 } + private func codexVisibleAccountsHaveAdjacentMultiAccountVeto() -> Bool { + let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts + var distinctAccounts: Set = [] + for account in accounts { + if let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + { + distinctAccounts.insert("provider:\(workspaceAccountID)") + } else if let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) { + distinctAccounts.insert("email:\(normalizedEmail)") + } + } + return distinctAccounts.count > 1 + } + nonisolated static func codexLegacyPlanUtilizationEmailHashKey(for normalizedEmail: String) -> String { self.sha256Hex("\(UsageProvider.codex.rawValue):email:\(normalizedEmail)") } diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 0952a47ae..dd2184e58 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -72,6 +72,7 @@ extension UsageStore { self.failureGates[.codex]?.reset() self.lastKnownSessionRemaining.removeValue(forKey: .codex) self.lastKnownSessionWindowSource.removeValue(forKey: .codex) + self.lastKnownResetSnapshots.removeValue(forKey: .codex) self.credits = nil self.lastCreditsError = nil diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 2f87fbc5a..4031d9b91 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -53,6 +53,35 @@ extension UsageStore { return providerBuckets.histories(for: accountKey) } + func codexPlanUtilizationHistories(forVisibleAccount account: CodexVisibleAccount) + -> [PlanUtilizationSeriesHistory] + { + var providerBuckets = self.planUtilizationHistory[.codex] ?? PlanUtilizationHistoryBuckets() + let originalProviderBuckets = providerBuckets + let ownership = self.codexOwnershipContext(forVisibleAccount: account) + guard let canonicalKey = ownership.canonicalKey else { return [] } + + if canonicalKey == ownership.canonicalEmailHashKey, + ownership.hasAdjacentMultiAccountVeto + { + return [] + } + + let accountKey = self.materializeCodexPlanUtilizationHistoryIfNeeded( + into: canonicalKey, + ownership: ownership, + shouldAdoptUnscopedHistory: true, + providerBuckets: &providerBuckets) + self.planUtilizationHistory[.codex] = providerBuckets + if providerBuckets != originalProviderBuckets { + let snapshotToPersist = self.planUtilizationHistory + Task { + await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) + } + } + return providerBuckets.histories(for: accountKey) + } + func shouldShowRefreshingMenuCard(for provider: UsageProvider) -> Bool { let isRefreshing = self.isRefreshing || self.refreshingProviders.contains(provider) return isRefreshing diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4a5a96d4e..25494947c 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -47,6 +47,8 @@ private struct CodexAccountFetchResult { extension UsageStore { static let tokenAccountMenuSnapshotLimit = 6 + private static let codexSessionWindowMinutes = 5 * 60 + private static let codexWeeklyWindowMinutes = 7 * 24 * 60 func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } @@ -96,7 +98,11 @@ extension UsageStore { let resolved = self.resolveCodexAccountOutcome( outcome, account: account, - priorSnapshot: priorByAccountID[account.id]) + priorSnapshot: priorByAccountID[account.id], + resetBackfillSnapshots: self.codexResetBackfillSnapshots( + for: account, + priorSnapshot: priorByAccountID[account.id], + activeVisibleAccountID: originalVisibleAccountID)) if let snapshot = resolved.snapshot { snapshots.append(snapshot) } @@ -445,6 +451,187 @@ extension UsageStore { return message.isEmpty ? "Refresh failed" : message } + private func codexResetBackfillSnapshots( + for account: CodexVisibleAccount, + priorSnapshot: CodexAccountUsageSnapshot?, + activeVisibleAccountID: String?) -> [UsageSnapshot] + { + var snapshots: [UsageSnapshot] = [] + if let prior = priorSnapshot?.snapshot { + snapshots.append(prior) + } + if account.id == activeVisibleAccountID, + let lastKnown = self.codexLastKnownResetSnapshot(for: account) + { + snapshots.append(lastKnown) + } + if let history = self.codexPlanHistoryResetBackfillSnapshot(for: account) { + snapshots.append(history) + } + return snapshots + } + + private func codexPlanHistoryResetBackfillSnapshot(for account: CodexVisibleAccount) -> UsageSnapshot? { + let histories = self.codexPlanUtilizationHistories(forVisibleAccount: account) + guard !histories.isEmpty + else { + return nil + } + + let now = Date() + let primary = Self.codexResetBackfillWindow( + from: histories, + name: .session, + windowMinutes: Self.codexSessionWindowMinutes, + now: now) + let secondary = Self.codexResetBackfillWindow( + from: histories, + name: .weekly, + windowMinutes: Self.codexWeeklyWindowMinutes, + now: now) + guard primary != nil || secondary != nil else { return nil } + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: nil, + loginMethod: account.workspaceLabel)) + } + + private func codexLastKnownResetSnapshot(for account: CodexVisibleAccount) -> UsageSnapshot? { + guard let snapshot = self.lastKnownResetSnapshots[.codex], + Self.codexVisibleAccountEmailMatches(snapshot: snapshot, account: account), + Self.codexScopedGuard(self.lastCodexAccountScopedRefreshGuard, matches: account) + else { + return nil + } + return snapshot + } + + private nonisolated static func codexVisibleAccountEmailMatches( + snapshot: UsageSnapshot, + account: CodexVisibleAccount) -> Bool + { + guard let identity = snapshot.identity(for: .codex), + let identityEmail = CodexIdentityResolver.normalizeEmail(identity.accountEmail), + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email), + identityEmail == accountEmail + else { + return false + } + return true + } + + private nonisolated static func codexScopedGuard( + _ guardValue: CodexAccountScopedRefreshGuard?, + matches account: CodexVisibleAccount) -> Bool + { + guard let guardValue, guardValue.source == account.selectionSource else { return false } + let identity = self.codexVisibleAccountIdentity(for: account) + if identity != .unresolved { + return guardValue.identity == identity + } + guard let accountKey = CodexIdentityResolver.normalizeEmail(account.email) else { return false } + return guardValue.accountKey == accountKey + } + + private nonisolated static func codexVisibleAccountIdentity(for account: CodexVisibleAccount) -> CodexIdentity { + if let workspaceAccountID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) { + return .providerAccount(id: CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID(workspaceAccountID)) + } + return CodexIdentityResolver.resolve(accountId: nil, email: account.email) + } + + private nonisolated static func normalizedCodexVisibleAccountText(_ text: String?) -> String? { + guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private nonisolated static func codexResetBackfillWindow( + from histories: [PlanUtilizationSeriesHistory], + name: PlanUtilizationSeriesName, + windowMinutes: Int, + now: Date) -> RateWindow? + { + let candidate = histories.lazy + .filter { $0.name == name && name.canonicalWindowMinutes($0.windowMinutes) == windowMinutes } + .flatMap { history in + history.entries.map { entry in + (capturedAt: entry.capturedAt, usedPercent: entry.usedPercent, resetsAt: entry.resetsAt) + } + } + .filter { $0.resetsAt.map { $0 > now } ?? false } + .max { lhs, rhs in + if lhs.capturedAt != rhs.capturedAt { + return lhs.capturedAt < rhs.capturedAt + } + return (lhs.resetsAt ?? .distantPast) < (rhs.resetsAt ?? .distantPast) + } + + guard let candidate, let resetsAt = candidate.resetsAt else { return nil } + return RateWindow( + usedPercent: candidate.usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: nil) + } + + private nonisolated static func codexBackfillingResetWindows( + _ snapshot: UsageSnapshot, + from cached: UsageSnapshot) -> UsageSnapshot + { + let primary = self.codexBackfillingResetWindow(snapshot.primary, from: cached.primary) + let secondary = self.codexBackfillingResetWindow(snapshot.secondary, from: cached.secondary) + guard primary != snapshot.primary || secondary != snapshot.secondary else { return snapshot } + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: snapshot.tertiary, + extraRateWindows: snapshot.extraRateWindows, + kiroUsage: snapshot.kiroUsage, + providerCost: snapshot.providerCost, + zaiUsage: snapshot.zaiUsage, + minimaxUsage: snapshot.minimaxUsage, + deepseekUsage: snapshot.deepseekUsage, + openRouterUsage: snapshot.openRouterUsage, + openAIAPIUsage: snapshot.openAIAPIUsage, + claudeAdminAPIUsage: snapshot.claudeAdminAPIUsage, + mistralUsage: snapshot.mistralUsage, + deepgramUsage: snapshot.deepgramUsage, + cursorRequests: snapshot.cursorRequests, + subscriptionExpiresAt: snapshot.subscriptionExpiresAt, + subscriptionRenewsAt: snapshot.subscriptionRenewsAt, + updatedAt: snapshot.updatedAt, + identity: snapshot.identity) + } + + private nonisolated static func codexBackfillingResetWindow( + _ window: RateWindow?, + from cached: RateWindow?) -> RateWindow? + { + guard let cached, + let resetsAt = cached.resetsAt, + resetsAt > Date() + else { + return window + } + if let window { + return window.backfillingResetTime(from: cached) + } + guard let windowMinutes = cached.windowMinutes, windowMinutes > 0 else { return nil } + return RateWindow( + usedPercent: cached.usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: cached.resetDescription) + } + func recordFetchedTokenAccountPlanUtilizationHistory( provider: UsageProvider, samples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)], @@ -502,20 +689,24 @@ extension UsageStore { private func resolveCodexAccountOutcome( _ outcome: ProviderFetchOutcome, account: CodexVisibleAccount, - priorSnapshot: CodexAccountUsageSnapshot? = nil) -> ResolvedCodexAccountOutcome + priorSnapshot: CodexAccountUsageSnapshot? = nil, + resetBackfillSnapshots: [UsageSnapshot] = []) -> ResolvedCodexAccountOutcome { switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: .codex) let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) + let backfilled = resetBackfillSnapshots.reduce(labeled) { partial, cached in + Self.codexBackfillingResetWindows(partial, from: cached) + } let snapshot = CodexAccountUsageSnapshot( account: account, - snapshot: labeled, + snapshot: backfilled, error: nil, sourceLabel: result.sourceLabel) return ResolvedCodexAccountOutcome( snapshot: snapshot, - usage: labeled, + usage: backfilled, sourceLabel: result.sourceLabel) case let .failure(error): if Self.errorIsCancellation(error) { @@ -576,19 +767,18 @@ extension UsageStore { switch outcome.result { case .success: guard let snapshot else { return } - let backfilled = snapshot.backfillingResetTimes(from: self.lastKnownResetSnapshots[.codex]) - self.handleSessionQuotaTransition(provider: .codex, snapshot: backfilled) - self.lastKnownResetSnapshots[.codex] = backfilled - self.snapshots[.codex] = backfilled + self.handleSessionQuotaTransition(provider: .codex, snapshot: snapshot) + self.lastKnownResetSnapshots[.codex] = snapshot + self.snapshots[.codex] = snapshot if let sourceLabel { self.lastSourceLabels[.codex] = sourceLabel } self.errors[.codex] = nil self.failureGates[.codex]?.recordSuccess() - self.rememberLiveSystemCodexEmailIfNeeded(backfilled.accountEmail(for: .codex)) - self.seedCodexAccountScopedRefreshGuard(accountEmail: backfilled.accountEmail(for: .codex)) - await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: backfilled) - self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) + self.rememberLiveSystemCodexEmailIfNeeded(snapshot.accountEmail(for: .codex)) + self.seedCodexAccountScopedRefreshGuard(accountEmail: snapshot.accountEmail(for: .codex)) + await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: snapshot) + self.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) case let .failure(error): guard let message = self.tokenAccountErrorMessage(error) else { self.errors[.codex] = nil diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index d3b335338..5ec231fc2 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -30,9 +30,14 @@ public struct RateWindow: Codable, Equatable, Sendable { public func backfillingResetTime(from cached: RateWindow?, now: Date = .init()) -> RateWindow { if self.resetsAt != nil { return self } guard let cachedReset = cached?.resetsAt, cachedReset > now else { return self } + let windowMinutes = if let windowMinutes = self.windowMinutes, windowMinutes > 0 { + windowMinutes + } else { + cached?.windowMinutes + } return RateWindow( usedPercent: self.usedPercent, - windowMinutes: self.windowMinutes ?? cached?.windowMinutes, + windowMinutes: windowMinutes, resetsAt: cachedReset, resetDescription: self.resetDescription ?? cached?.resetDescription, nextRegenPercent: self.nextRegenPercent) diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift index 8e0fc39cd..48e114c13 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift @@ -199,6 +199,16 @@ extension CodexAccountScopedRefreshTests { } } + func installContextualCodexProvider( + on store: UsageStore, + loader: @escaping @Sendable (ProviderFetchContext) async throws -> UsageSnapshot) + { + let baseSpec = store.providerSpecs[.codex]! + store.providerSpecs[.codex] = Self.makeCodexProviderSpec(baseSpec: baseSpec) { _ in + [ContextualTestCodexFetchStrategy(loader: loader, sourceLabel: "test-codex")] + } + } + static func makeCodexProviderSpec( baseSpec: ProviderSpec, loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec @@ -327,6 +337,30 @@ struct TestCodexFetchStrategy: ProviderFetchStrategy { } } +struct ContextualTestCodexFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable (ProviderFetchContext) async throws -> UsageSnapshot + let sourceLabel: String + + var id = "contextual-test-codex" + var kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.loader(context) + return self.makeResult( + usage: snapshot, + credits: nil, + sourceLabel: self.sourceLabel) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + struct ThrowingTestCodexFetchStrategy: ProviderFetchStrategy { let loader: @Sendable () async throws -> UsageSnapshot diff --git a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift new file mode 100644 index 000000000..43e1e9eff --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift @@ -0,0 +1,332 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `repairs collapsed codex windows from matching provider account history`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-333333333333")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "target@example.com", + providerAccountID: "acct-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling@example.com", + providerAccountID: "acct-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 1 : 22, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + let targetProviderHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-target"))) + let targetEmailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "target@example.com") + let targetLegacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "target@example.com") + let sessionReset = now.addingTimeInterval(4 * 60 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + targetEmailHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 1, resetsAt: sessionReset), + ]), + ], + targetLegacyEmailHistoryKey: [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 13, resetsAt: weeklyReset), + ]), + ], + ]) + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 1) + #expect(targetSnapshot.primary?.windowMinutes == 300) + #expect(targetSnapshot.primary?.resetsAt == sessionReset) + #expect(targetSnapshot.secondary?.usedPercent == 13) + #expect(targetSnapshot.secondary?.windowMinutes == 10080) + #expect(targetSnapshot.secondary?.resetsAt == weeklyReset) + + let siblingSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-sibling" + }?.snapshot) + #expect(siblingSnapshot.primary?.windowMinutes == 0) + #expect(siblingSnapshot.primary?.resetsAt == nil) + #expect(siblingSnapshot.secondary == nil) + + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-target" + }?.snapshot) + #expect(persistedTarget.primary?.resetsAt == sessionReset) + #expect(persistedTarget.secondary?.resetsAt == weeklyReset) + #expect(store.snapshots[.codex]?.primary?.resetsAt == sessionReset) + #expect(store.snapshots[.codex]?.secondary?.resetsAt == weeklyReset) + #expect(store.planUtilizationHistory[.codex]?.accounts[targetProviderHistoryKey]?.count == 2) + #expect(store.planUtilizationHistory[.codex]?.accounts[targetLegacyEmailHistoryKey] == nil) + } + + @Test + func `ignores active reset cache from another visible codex workspace`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-stale-active-cache") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-444444444444")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-555555555555")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-cache-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-cache-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "shared@example.com", + providerAccountID: "acct-cache-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-cache-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "shared@example.com", + providerAccountID: "acct-cache-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-cache-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: siblingID), + identity: .providerAccount(id: "acct-cache-sibling"), + accountKey: "shared@example.com") + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 55, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "shared@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-cache-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + #expect(store.snapshots[.codex]?.primary?.resetsAt == nil) + #expect(store.snapshots[.codex]?.secondary == nil) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == nil) + #expect(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-cache-target" + }?.snapshot?.primary?.resetsAt == nil) + } + + @Test + func `uses active reset cache when scoped guard matches codex workspace with plan label`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-current-active-cache") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-666666666666")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-777777777777")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-current-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-current-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "current@example.com", + providerAccountID: "acct-current-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-current-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "current@example.com", + providerAccountID: "acct-current-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-current-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let sessionReset = now.addingTimeInterval(2 * 60 * 60) + let weeklyReset = now.addingTimeInterval(2 * 24 * 60 * 60) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: targetID), + identity: .providerAccount(id: "acct-current-target"), + accountKey: "current@example.com") + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 55, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "current@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-current-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 300) + #expect(targetSnapshot.primary?.resetsAt == sessionReset) + #expect(targetSnapshot.secondary?.usedPercent == 55) + #expect(targetSnapshot.secondary?.windowMinutes == 10080) + #expect(targetSnapshot.secondary?.resetsAt == weeklyReset) + #expect(store.snapshots[.codex]?.primary?.resetsAt == sessionReset) + #expect(store.snapshots[.codex]?.secondary?.resetsAt == weeklyReset) + #expect(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-current-target" + }?.snapshot?.secondary?.resetsAt == weeklyReset) + } +} diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift index 559bb1928..e03bcf36c 100644 --- a/Tests/CodexBarTests/ResetTimeBackfillTests.swift +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -28,6 +28,22 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertEqual(result.nextRegenPercent, 4) } + func test_backfillsZeroWindowDurationFromCachedWindow() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil) + let fresh = RateWindow(usedPercent: 62, windowMinutes: 0, resetsAt: nil, resetDescription: nil) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertEqual(result.windowMinutes, 300) + XCTAssertEqual(result.resetsAt, reset) + } + func test_skipsExpiredCachedReset() { let now = Date(timeIntervalSince1970: 1_800_000_000) let cached = RateWindow( From 1ea4e83e623594fa27c9d92f25868ac57f83477f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 07:58:25 +0100 Subject: [PATCH 43/93] fix: harden Codex reset backfill ownership --- Sources/CodexBar/CodexOwnershipContext.swift | 50 +++- .../CodexBar/UsageStore+PlanUtilization.swift | 23 +- .../CodexBar/UsageStore+TokenAccounts.swift | 115 +++++++- ...exAccountVisibleHistoryBackfillTests.swift | 277 +++++++++++++++++- 4 files changed, 434 insertions(+), 31 deletions(-) diff --git a/Sources/CodexBar/CodexOwnershipContext.swift b/Sources/CodexBar/CodexOwnershipContext.swift index 4432e9423..1bbe8270c 100644 --- a/Sources/CodexBar/CodexOwnershipContext.swift +++ b/Sources/CodexBar/CodexOwnershipContext.swift @@ -9,6 +9,7 @@ struct CodexOwnershipContext { let planUtilizationLegacyEmailHash: String? let currentWeeklyResetAt: Date? let hasAdjacentMultiAccountVeto: Bool + let hasAdjacentEmailScopeAmbiguity: Bool } extension UsageStore { @@ -65,7 +66,10 @@ extension UsageStore { Self.codexLegacyPlanUtilizationEmailHashKey(for: $0) }, currentWeeklyResetAt: currentWeeklyResetAt, - hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto()) + hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto(), + hasAdjacentEmailScopeAmbiguity: normalizedEmail.map { + self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) + } ?? false) } func codexOwnershipContext( @@ -93,7 +97,11 @@ extension UsageStore { }, currentWeeklyResetAt: currentWeeklyResetAt, hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto() || - self.codexVisibleAccountsHaveAdjacentMultiAccountVeto()) + self.codexVisibleAccountsHaveAdjacentMultiAccountVeto(), + hasAdjacentEmailScopeAmbiguity: normalizedEmail.map { + self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) || + self.codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: $0) + } ?? false) } func codexHasAdjacentMultiAccountVeto() -> Bool { @@ -115,6 +123,29 @@ extension UsageStore { return distinctAccounts.count > 1 } + private func codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: String) -> Bool { + let snapshot = self.settings.codexAccountReconciliationSnapshot + var distinctAccounts: Set = [] + + if let activeManagedAccount = self.settings.activeManagedCodexAccount, + CodexIdentityResolver.normalizeEmail(snapshot.runtimeEmail(for: activeManagedAccount)) == normalizedEmail + { + distinctAccounts.insert(CodexIdentityMatcher.selectionKey( + for: snapshot.runtimeIdentity(for: activeManagedAccount), + fallbackEmail: snapshot.runtimeEmail(for: activeManagedAccount))) + } + + if let liveSystemAccount = snapshot.liveSystemAccount, + CodexIdentityResolver.normalizeEmail(liveSystemAccount.email) == normalizedEmail + { + distinctAccounts.insert(CodexIdentityMatcher.selectionKey( + for: snapshot.runtimeIdentity(for: liveSystemAccount), + fallbackEmail: liveSystemAccount.email)) + } + + return distinctAccounts.count > 1 + } + private func codexVisibleAccountsHaveAdjacentMultiAccountVeto() -> Bool { let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts var distinctAccounts: Set = [] @@ -130,6 +161,21 @@ extension UsageStore { return distinctAccounts.count > 1 } + private func codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: String) -> Bool { + let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts + var distinctAccounts: Set = [] + for account in accounts where CodexIdentityResolver.normalizeEmail(account.email) == normalizedEmail { + if let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + { + distinctAccounts.insert("provider:\(workspaceAccountID)") + } else { + distinctAccounts.insert("email:\(normalizedEmail)") + } + } + return distinctAccounts.count > 1 + } + nonisolated static func codexLegacyPlanUtilizationEmailHashKey(for normalizedEmail: String) -> String { self.sha256Hex("\(UsageProvider.codex.rawValue):email:\(normalizedEmail)") } diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 4031d9b91..ad01e7460 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -61,10 +61,9 @@ extension UsageStore { let ownership = self.codexOwnershipContext(forVisibleAccount: account) guard let canonicalKey = ownership.canonicalKey else { return [] } - if canonicalKey == ownership.canonicalEmailHashKey, - ownership.hasAdjacentMultiAccountVeto - { - return [] + if ownership.hasAdjacentEmailScopeAmbiguity { + guard canonicalKey != ownership.canonicalEmailHashKey else { return [] } + return providerBuckets.histories(for: canonicalKey) } let accountKey = self.materializeCodexPlanUtilizationHistoryIfNeeded( @@ -762,6 +761,7 @@ extension UsageStore { targetCanonicalKey: canonicalKey, canonicalEmailHashKey: ownership.canonicalEmailHashKey) if matchesTargetContinuity, + !Self.codexPlanHistoryOwnerIsAmbiguousEmailScope(owner, ownership: ownership), let accountHistories = providerBuckets.accounts[rawKey], !accountHistories.isEmpty { @@ -805,6 +805,21 @@ extension UsageStore { return canonicalKey } + private static func codexPlanHistoryOwnerIsAmbiguousEmailScope( + _ owner: CodexHistoryPersistedOwner, + ownership: CodexOwnershipContext) -> Bool + { + guard ownership.hasAdjacentEmailScopeAmbiguity else { return false } + return switch owner { + case let .canonical(key): + key == ownership.canonicalEmailHashKey + case .legacyEmailHash: + true + case .legacyOpaqueScoped, .legacyUnscoped: + false + } + } + private func materializeLegacyClaudePlanUtilizationHistoryIfNeeded( into accountKey: String, provider: UsageProvider, diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 25494947c..dd5fc1b17 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -438,6 +438,11 @@ extension UsageStore { let sourceLabel: String? } + private struct CodexResetBackfillWindowCandidate { + let window: RateWindow + let capturedAt: Date + } + func tokenAccountErrorMessage(_ error: any Error) -> String? { guard !Self.errorIsCancellation(error) else { return nil } let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) @@ -457,7 +462,10 @@ extension UsageStore { activeVisibleAccountID: String?) -> [UsageSnapshot] { var snapshots: [UsageSnapshot] = [] - if let prior = priorSnapshot?.snapshot { + if let priorSnapshot, + Self.codexPriorSnapshotAccountMatches(priorSnapshot.account, account: account), + let prior = priorSnapshot.snapshot + { snapshots.append(prior) } if account.id == activeVisibleAccountID, @@ -479,22 +487,24 @@ extension UsageStore { } let now = Date() - let primary = Self.codexResetBackfillWindow( + let primaryCandidate = Self.codexResetBackfillWindowCandidate( from: histories, name: .session, windowMinutes: Self.codexSessionWindowMinutes, now: now) - let secondary = Self.codexResetBackfillWindow( + let secondaryCandidate = Self.codexResetBackfillWindowCandidate( from: histories, name: .weekly, windowMinutes: Self.codexWeeklyWindowMinutes, now: now) + let primary = primaryCandidate?.window + let secondary = secondaryCandidate?.window guard primary != nil || secondary != nil else { return nil } return UsageSnapshot( primary: primary, secondary: secondary, - updatedAt: now, + updatedAt: [primaryCandidate?.capturedAt, secondaryCandidate?.capturedAt].compactMap(\.self).max() ?? now, identity: ProviderIdentitySnapshot( providerID: .codex, accountEmail: account.email, @@ -526,6 +536,38 @@ extension UsageStore { return true } + private nonisolated static func codexPriorSnapshotAccountMatches( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount) -> Bool + { + guard let priorEmail = CodexIdentityResolver.normalizeEmail(prior.email), + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email), + priorEmail == accountEmail + else { + return false + } + + let priorWorkspaceID = self.normalizedCodexVisibleAccountText(prior.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let accountWorkspaceID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + if priorWorkspaceID != nil || accountWorkspaceID != nil { + return priorWorkspaceID == accountWorkspaceID + } + + if prior.selectionSource == account.selectionSource { + switch account.selectionSource { + case .managedAccount: + return true + case .liveSystem: + break + } + } + + guard prior.id != prior.email, account.id != account.email else { return false } + return prior.id == account.id + } + private nonisolated static func codexScopedGuard( _ guardValue: CodexAccountScopedRefreshGuard?, matches account: CodexVisibleAccount) -> Bool @@ -553,11 +595,11 @@ extension UsageStore { return trimmed } - private nonisolated static func codexResetBackfillWindow( + private nonisolated static func codexResetBackfillWindowCandidate( from histories: [PlanUtilizationSeriesHistory], name: PlanUtilizationSeriesName, windowMinutes: Int, - now: Date) -> RateWindow? + now: Date) -> CodexResetBackfillWindowCandidate? { let candidate = histories.lazy .filter { $0.name == name && name.canonicalWindowMinutes($0.windowMinutes) == windowMinutes } @@ -575,11 +617,13 @@ extension UsageStore { } guard let candidate, let resetsAt = candidate.resetsAt else { return nil } - return RateWindow( - usedPercent: candidate.usedPercent, - windowMinutes: windowMinutes, - resetsAt: resetsAt, - resetDescription: nil) + return CodexResetBackfillWindowCandidate( + window: RateWindow( + usedPercent: candidate.usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: nil), + capturedAt: candidate.capturedAt) } private nonisolated static func codexBackfillingResetWindows( @@ -611,6 +655,50 @@ extension UsageStore { identity: snapshot.identity) } + private nonisolated static func codexMergedResetBackfillSnapshot( + _ snapshots: [UsageSnapshot], + now: Date = Date()) -> UsageSnapshot? + { + let primary = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + snapshot.primary.map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + let secondary = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + snapshot.secondary.map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + guard primary != nil || secondary != nil else { return nil } + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: snapshots.map(\.updatedAt).max() ?? now) + } + + private nonisolated static func codexPreferredResetBackfillWindow( + _ windows: [(window: RateWindow, updatedAt: Date, priority: Int)], + now: Date) -> RateWindow? + { + windows + .filter { ($0.window.resetsAt ?? .distantPast) > now } + .max { lhs, rhs in + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt < rhs.updatedAt + } + if lhs.priority != rhs.priority { + return lhs.priority < rhs.priority + } + let lhsReset = lhs.window.resetsAt ?? .distantPast + let rhsReset = rhs.window.resetsAt ?? .distantPast + if lhsReset != rhsReset { + return lhsReset < rhsReset + } + return (lhs.window.windowMinutes ?? 0) < (rhs.window.windowMinutes ?? 0) + } + .map(\.window) + } + private nonisolated static func codexBackfillingResetWindow( _ window: RateWindow?, from cached: RateWindow?) -> RateWindow? @@ -696,9 +784,8 @@ extension UsageStore { case let .success(result): let scoped = result.usage.scoped(to: .codex) let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) - let backfilled = resetBackfillSnapshots.reduce(labeled) { partial, cached in - Self.codexBackfillingResetWindows(partial, from: cached) - } + let backfilled = Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots) + .map { Self.codexBackfillingResetWindows(labeled, from: $0) } ?? labeled let snapshot = CodexAccountUsageSnapshot( account: account, snapshot: backfilled, diff --git a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift index 43e1e9eff..0e076315c 100644 --- a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift +++ b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift @@ -70,20 +70,15 @@ extension CodexAccountScopedRefreshTests { updatedAt: now) } - let targetProviderHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + let targetHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( id: "acct-target"))) - let targetEmailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "target@example.com") - let targetLegacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( - normalizedEmail: "target@example.com") let sessionReset = now.addingTimeInterval(4 * 60 * 60) let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ - targetEmailHistoryKey: [ + targetHistoryKey: [ planSeries(name: .session, windowMinutes: 300, entries: [ planEntry(at: now.addingTimeInterval(-60), usedPercent: 1, resetsAt: sessionReset), ]), - ], - targetLegacyEmailHistoryKey: [ planSeries(name: .weekly, windowMinutes: 10080, entries: [ planEntry(at: now.addingTimeInterval(-60), usedPercent: 13, resetsAt: weeklyReset), ]), @@ -116,8 +111,47 @@ extension CodexAccountScopedRefreshTests { #expect(persistedTarget.secondary?.resetsAt == weeklyReset) #expect(store.snapshots[.codex]?.primary?.resetsAt == sessionReset) #expect(store.snapshots[.codex]?.secondary?.resetsAt == weeklyReset) - #expect(store.planUtilizationHistory[.codex]?.accounts[targetProviderHistoryKey]?.count == 2) - #expect(store.planUtilizationHistory[.codex]?.accounts[targetLegacyEmailHistoryKey] == nil) + #expect(store.planUtilizationHistory[.codex]?.accounts[targetHistoryKey]?.count == 2) + } + + @Test + func `materializes single visible codex account email history into provider account history`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-single-account-materialize") + let store = self.makeUsageStore(settings: settings) + let visibleAccount = CodexVisibleAccount( + id: "materialize@example.com", + email: "materialize@example.com", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-materialize", + storedAccountID: nil, + selectionSource: .managedAccount(id: UUID()), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-materialize"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "materialize@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "materialize@example.com") + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_000_000), usedPercent: 1), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_086_400), usedPercent: 13), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [session], + legacyEmailHistoryKey: [weekly], + ]) + + let histories = store.codexPlanUtilizationHistories(forVisibleAccount: visibleAccount) + + #expect(histories == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[providerHistoryKey] == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[emailHistoryKey] == nil) + #expect(store.planUtilizationHistory[.codex]?.accounts[legacyEmailHistoryKey] == nil) } @Test @@ -269,14 +303,36 @@ extension CodexAccountScopedRefreshTests { settings._test_managedCodexAccountStoreURL = storeURL settings.codexActiveSource = .managedAccount(id: targetID) - let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let now = Date() + let staleSessionReset = now.addingTimeInterval(3 * 60 * 60) + let staleWeeklyReset = now.addingTimeInterval(3 * 24 * 60 * 60) + let priorSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + CodexAccountUsageSnapshot( + account: account, + snapshot: account.workspaceAccountID == "acct-current-target" + ? UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: staleSessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 10080, + resetsAt: staleWeeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-60)) + : nil, + error: nil, + sourceLabel: "cached") + } + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: priorSnapshots) let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, codexAccountUsageSnapshotStore: snapshotStore, startupBehavior: .testing) - let now = Date() let sessionReset = now.addingTimeInterval(2 * 60 * 60) let weeklyReset = now.addingTimeInterval(2 * 24 * 60 * 60) store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( @@ -329,4 +385,203 @@ extension CodexAccountScopedRefreshTests { $0.account.workspaceAccountID == "acct-current-target" }?.snapshot?.secondary?.resetsAt == weeklyReset) } + + @Test + func `ignores prior snapshot from same email different codex workspace`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-prior-workspace") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-888888888888")) + let oldID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-999999999999")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-AAAAAAAAAAAA")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-prior-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-prior-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "prior@example.com", + providerAccountID: "acct-prior-new", + workspaceLabel: "New Team", + workspaceAccountID: "acct-prior-new", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "other-prior@example.com", + providerAccountID: "acct-prior-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-prior-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let oldVisibleAccount = CodexVisibleAccount( + id: "prior@example.com", + email: "prior@example.com", + workspaceLabel: "Old Team", + workspaceAccountID: "acct-prior-old", + storedAccountID: oldID, + selectionSource: .managedAccount(id: oldID), + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: [ + CodexAccountUsageSnapshot( + account: oldVisibleAccount, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 72, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "prior@example.com", + accountOrganization: nil, + loginMethod: "Old Team")), + error: nil, + sourceLabel: "cached"), + ]) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-prior-new" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + } + + @Test + func `ignores ambiguous email history for same email codex workspaces`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-ambiguous-email-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-111111111111")) + let siblingID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-222222222222")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-history-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-history-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "history-shared@example.com", + providerAccountID: "acct-history-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-history-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "history-shared@example.com", + providerAccountID: "acct-history-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-history-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let normalizedEmail = try #require(CodexIdentityResolver.normalizeEmail("history-shared@example.com")) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 2, resetsAt: now.addingTimeInterval(3600)), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry( + at: now.addingTimeInterval(-60), + usedPercent: 33, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60)), + ]), + ], + ]) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-history-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + #expect(store.planUtilizationHistory[.codex]?.histories(for: emailHistoryKey).isEmpty == false) + } } From 870e639289c63aacf1809e008c7f84c9688b49e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 08:08:28 +0100 Subject: [PATCH 44/93] fix: harden Codex reset backfill ownership --- .../CodexAccountUsageSnapshotStore.swift | 59 +++++ Sources/CodexBar/CodexOwnershipContext.swift | 3 +- Sources/CodexBar/ProviderRegistry.swift | 17 +- .../Providers/Codex/CodexSettingsStore.swift | 12 + .../CodexBar/UsageStore+TokenAccounts.swift | 2 +- ...exAccountVisibleHistoryBackfillTests.swift | 248 ++++++++++++++++++ ...usMenuCodexSwitcherPresentationTests.swift | 81 ++++++ 7 files changed, 410 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift index 207ca5b8a..a2f2201e2 100644 --- a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift +++ b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift @@ -14,11 +14,53 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un private struct Record: Codable { let id: String + let accountIdentity: AccountIdentity? let snapshot: UsageSnapshot? let error: String? let sourceLabel: String? } + private struct AccountIdentity: Codable, Equatable { + let normalizedEmail: String? + let workspaceAccountID: String? + let authFingerprint: String? + let storedAccountID: UUID? + let selectionSource: CodexActiveSource? + + init(account: CodexVisibleAccount) { + self.normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) + self.workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + self.authFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + self.storedAccountID = account.storedAccountID + self.selectionSource = account.selectionSource + } + + func matches(_ account: CodexVisibleAccount) -> Bool { + guard self.normalizedEmail == CodexIdentityResolver.normalizeEmail(account.email) else { + return false + } + + let currentWorkspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + if self.workspaceAccountID != nil || currentWorkspaceAccountID != nil { + return self.workspaceAccountID == currentWorkspaceAccountID + } + + if self.storedAccountID != nil || account.storedAccountID != nil { + return self.storedAccountID == account.storedAccountID + } + + let currentAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if self.authFingerprint != nil || currentAuthFingerprint != nil { + return self.authFingerprint == currentAuthFingerprint + } + + guard let selectionSource else { return true } + return selectionSource == account.selectionSource + } + } + private static let currentVersion = 1 private let fileURL: URL @@ -41,6 +83,11 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un let accountsByID = Dictionary(uniqueKeysWithValues: accounts.map { ($0.id, $0) }) return payload.records.compactMap { record in guard let account = accountsByID[record.id] else { return nil } + guard record.accountIdentity?.matches(account) + ?? Self.canHydrateLegacyRecord(record, account: account) + else { + return nil + } return CodexAccountUsageSnapshot( account: account, snapshot: record.snapshot, @@ -55,6 +102,7 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un records: snapshots.map { snapshot in Record( id: snapshot.id, + accountIdentity: AccountIdentity(account: snapshot.account), snapshot: snapshot.snapshot, error: snapshot.error, sourceLabel: snapshot.sourceLabel) @@ -77,6 +125,17 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un } } + private static func canHydrateLegacyRecord(_ record: Record, account: CodexVisibleAccount) -> Bool { + guard record.accountIdentity == nil else { return false } + let normalizedID = CodexIdentityResolver.normalizeEmail(record.id) + let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) + let isEmailOnlyVisibleID = normalizedID == normalizedEmail + guard isEmailOnlyVisibleID else { return true } + return CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(account.workspaceAccountID) == nil && + account.storedAccountID == nil && + CodexAuthFingerprint.normalize(account.authFingerprint) == nil + } + static func defaultURL() -> URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser diff --git a/Sources/CodexBar/CodexOwnershipContext.swift b/Sources/CodexBar/CodexOwnershipContext.swift index 1bbe8270c..7231ee630 100644 --- a/Sources/CodexBar/CodexOwnershipContext.swift +++ b/Sources/CodexBar/CodexOwnershipContext.swift @@ -68,7 +68,8 @@ extension UsageStore { currentWeeklyResetAt: currentWeeklyResetAt, hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto(), hasAdjacentEmailScopeAmbiguity: normalizedEmail.map { - self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) + self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) || + self.codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: $0) } ?? false) } diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index eeeb541b1..6503af373 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -142,19 +142,16 @@ struct ProviderRegistry { } } } - // Managed Codex routing only scopes remote account fetches such as identity, plan, - // quotas, and dashboard data, and only when the active source is a managed account. - // Token-cost/session history is intentionally not routed through the managed home - // because that data is currently treated as provider-level local telemetry from this - // 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. + // Codex account routing scopes remote account fetches such as identity, plan, + // quotas, and dashboard data. Token-cost/session history is intentionally handled + // separately because it is provider-level local telemetry from this Mac's Codex sessions, + // not account-owned remote state. if provider == .codex { let codexActiveSource = codexActiveSourceOverride ?? settings.codexResolvedActiveSource - if case .managedAccount = codexActiveSource, - let managedHomePath = settings.managedCodexRemoteHomePath(forActiveSource: codexActiveSource) - { + if let managedHomePath = settings.managedCodexRemoteHomePath(forActiveSource: codexActiveSource) { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) + } else if let liveHomePath = settings.liveSystemCodexHomePath(forActiveSource: codexActiveSource) { + env = CodexHomeScope.scopedEnvironment(base: env, codexHome: liveHomePath) } } return env diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 592da4899..1f7987b16 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -70,6 +70,18 @@ extension SettingsStore { self.managedCodexRemoteHomePath(forActiveSource: self.codexResolvedActiveSource) } + func liveSystemCodexHomePath(forActiveSource source: CodexActiveSource) -> String? { + guard source == .liveSystem else { + return nil + } + let path = self.codexAccountReconciliationSnapshot(activeSourceOverride: source) + .liveSystemAccount?.codexHomePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard let path, !path.isEmpty else { + return nil + } + return path + } + func managedCodexRemoteHomePath(forActiveSource source: CodexActiveSource) -> String? { guard case let .managedAccount(id) = source else { return nil diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index dd5fc1b17..4fa3e1e04 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -560,7 +560,7 @@ extension UsageStore { case .managedAccount: return true case .liveSystem: - break + return prior.id == account.id } } diff --git a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift index 0e076315c..2ba3670c8 100644 --- a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift +++ b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift @@ -154,6 +154,168 @@ extension CodexAccountScopedRefreshTests { #expect(store.planUtilizationHistory[.codex]?.accounts[legacyEmailHistoryKey] == nil) } + @Test + func `materializes provider account email history when sibling visible account uses another email`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-different-email-materialize") + settings.multiAccountMenuLayout = .stacked + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-121212121212")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-343434343434")) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "materialize-stack@example.com", + providerAccountID: "acct-materialize-stack", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-materialize-stack", + managedHomePath: "/tmp/materialize-stack-target", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "other-stack@example.com", + providerAccountID: "acct-materialize-other", + workspaceLabel: "Other Team", + workspaceAccountID: "acct-materialize-other", + managedHomePath: "/tmp/materialize-stack-other", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + let store = self.makeUsageStore(settings: settings) + let visibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts.first { + $0.workspaceAccountID == "acct-materialize-stack" + }) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-materialize-stack"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "materialize-stack@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "materialize-stack@example.com") + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_000_000), usedPercent: 1), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_086_400), usedPercent: 13), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [session], + legacyEmailHistoryKey: [weekly], + ]) + + let histories = store.codexPlanUtilizationHistories(forVisibleAccount: visibleAccount) + + #expect(histories == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[providerHistoryKey] == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[emailHistoryKey] == nil) + #expect(store.planUtilizationHistory[.codex]?.accounts[legacyEmailHistoryKey] == nil) + } + + @Test + func `selected codex refresh keeps ambiguous same email history out of provider account`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-ambiguous-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "CCCCCCCC-DDDD-EEEE-FFFF-111111111111")) + let siblingID = try #require(UUID(uuidString: "CCCCCCCC-DDDD-EEEE-FFFF-222222222222")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-selected-history-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-selected-history-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "selected-shared@example.com", + plan: "pro", + accountId: "acct-selected-target") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "selected-shared@example.com", + plan: "pro", + accountId: "acct-selected-sibling") + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "selected-shared@example.com", + providerAccountID: "acct-selected-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-selected-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "selected-shared@example.com", + providerAccountID: "acct-selected-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-selected-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let store = self.makeUsageStore(settings: settings) + let now = Date(timeIntervalSince1970: 1_800_000_000) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-selected-target"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "selected-shared@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "selected-shared@example.com") + let staleSession = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-2 * 60 * 60), usedPercent: 12), + ]) + let staleWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-2 * 60 * 60), usedPercent: 24), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [staleSession], + legacyEmailHistoryKey: [staleWeekly], + ]) + let currentSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 4, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 6, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-shared@example.com", + accountOrganization: nil, + loginMethod: "Target Team")) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: currentSnapshot, + now: now) + + let buckets = try #require(store.planUtilizationHistory[.codex]) + let providerHistory = try #require(buckets.accounts[providerHistoryKey]) + #expect(providerHistory.flatMap(\.entries).allSatisfy { $0.capturedAt == now }) + #expect(buckets.accounts[emailHistoryKey] == [staleSession]) + #expect(buckets.accounts[legacyEmailHistoryKey] == [staleWeekly]) + } + @Test func `ignores active reset cache from another visible codex workspace`() async throws { let settings = self.makeSettingsStore( @@ -584,4 +746,90 @@ extension CodexAccountScopedRefreshTests { #expect(targetSnapshot.secondary == nil) #expect(store.planUtilizationHistory[.codex]?.histories(for: emailHistoryKey).isEmpty == false) } + + @Test + func `backfills live codex row from same id prior snapshot`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-prior") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-111111111111")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-prior@example.com", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-prior@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-prior@example.com", + providerAccountID: "acct-managed-prior", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-prior", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: managedID) + + let now = Date() + let priorReset = now.addingTimeInterval(2 * 60 * 60) + let priorSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + CodexAccountUsageSnapshot( + account: account, + snapshot: account.selectionSource == .liveSystem + ? UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: priorReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60)) + : nil, + error: nil, + sourceLabel: "cached") + } + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: priorSnapshots) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 300) + #expect(liveSnapshot.primary?.resetsAt == priorReset) + } } diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift index cf65a2102..ea14767dd 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift @@ -276,4 +276,85 @@ struct StatusMenuCodexSwitcherPresentationTests { #expect(hydrated.first?.snapshot?.primary?.usedPercent == 17) #expect(hydrated.first?.account.email == account.email) } + + @Test + func `codex account snapshot store rejects mismatched workspace records`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let oldAccountID = UUID() + let newAccountID = UUID() + let oldAccount = CodexVisibleAccount( + id: "workspace@example.com", + email: "workspace@example.com", + workspaceLabel: "Old Team", + workspaceAccountID: "acct-old", + storedAccountID: oldAccountID, + selectionSource: .managedAccount(id: oldAccountID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let newAccount = CodexVisibleAccount( + id: "workspace@example.com", + email: "workspace@example.com", + workspaceLabel: "New Team", + workspaceAccountID: "acct-new", + storedAccountID: newAccountID, + selectionSource: .managedAccount(id: newAccountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: oldAccount, + snapshot: self.snapshot(email: oldAccount.email, percent: 71), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [newAccount]) + + #expect(hydrated.isEmpty) + } + + @Test + func `codex account snapshot store rejects legacy workspace records without identity`() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + let payload = """ + { + "records" : [ + { + "error" : "cached", + "id" : "legacy@example.com", + "snapshot" : null, + "sourceLabel" : "legacy" + } + ], + "version" : 1 + } + """ + try Data(payload.utf8).write(to: fileURL) + + let accountID = UUID() + let workspaceAccount = CodexVisibleAccount( + id: "legacy@example.com", + email: "legacy@example.com", + workspaceLabel: "New Team", + workspaceAccountID: "acct-new", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + + let hydrated = store.load(for: [workspaceAccount]) + + #expect(hydrated.isEmpty) + } } From 52d09da38bfefe48369fd828f78da360504a7718 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 08:16:50 +0100 Subject: [PATCH 45/93] fix: guard Codex reset cache by auth fingerprint --- .../CodexAccountUsageSnapshotStore.swift | 8 +- .../CodexBar/UsageStore+TokenAccounts.swift | 6 ++ ...exAccountVisibleHistoryBackfillTests.swift | 99 +++++++++++++++++++ ...usMenuCodexSwitcherPresentationTests.swift | 40 ++++++++ 4 files changed, 149 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift index a2f2201e2..891b40961 100644 --- a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift +++ b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift @@ -47,15 +47,15 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un return self.workspaceAccountID == currentWorkspaceAccountID } - if self.storedAccountID != nil || account.storedAccountID != nil { - return self.storedAccountID == account.storedAccountID - } - let currentAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) if self.authFingerprint != nil || currentAuthFingerprint != nil { return self.authFingerprint == currentAuthFingerprint } + if self.storedAccountID != nil || account.storedAccountID != nil { + return self.storedAccountID == account.storedAccountID + } + guard let selectionSource else { return true } return selectionSource == account.selectionSource } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4fa3e1e04..b4213dbb4 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -555,6 +555,12 @@ extension UsageStore { return priorWorkspaceID == accountWorkspaceID } + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + guard priorAuthFingerprint == accountAuthFingerprint else { return false } + } + if prior.selectionSource == account.selectionSource { switch account.selectionSource { case .managedAccount: diff --git a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift index 2ba3670c8..e7bf93a81 100644 --- a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift +++ b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift @@ -832,4 +832,103 @@ extension CodexAccountScopedRefreshTests { #expect(liveSnapshot.primary?.windowMinutes == 300) #expect(liveSnapshot.primary?.resetsAt == priorReset) } + + @Test + func `ignores live codex prior snapshot after auth fingerprint changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-prior-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-222222222222")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-prior-auth@example.com", + authFingerprint: "current-live-auth-fingerprint", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-prior-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-prior-auth@example.com", + providerAccountID: "acct-managed-prior-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-prior-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: managedID) + + let now = Date() + let priorReset = now.addingTimeInterval(2 * 60 * 60) + let liveAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts.first { + $0.selectionSource == .liveSystem + }) + let priorLiveAccount = CodexVisibleAccount( + id: liveAccount.id, + email: liveAccount.email, + workspaceLabel: liveAccount.workspaceLabel, + workspaceAccountID: liveAccount.workspaceAccountID, + authFingerprint: "stale-live-auth-fingerprint", + storedAccountID: liveAccount.storedAccountID, + selectionSource: liveAccount.selectionSource, + isActive: liveAccount.isActive, + isLive: liveAccount.isLive, + canReauthenticate: liveAccount.canReauthenticate, + canRemove: liveAccount.canRemove) + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: [ + CodexAccountUsageSnapshot( + account: priorLiveAccount, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: priorReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60)), + error: nil, + sourceLabel: "cached"), + ]) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 0) + #expect(liveSnapshot.primary?.resetsAt == nil) + } } diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift index ea14767dd..58352c341 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift @@ -320,6 +320,46 @@ struct StatusMenuCodexSwitcherPresentationTests { #expect(hydrated.isEmpty) } + @Test + func `codex account snapshot store rejects same stored account after auth fingerprint changes`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let accountID = UUID() + let oldAccount = CodexVisibleAccount( + id: "reauth@example.com", + email: "reauth@example.com", + authFingerprint: "old-auth-fingerprint", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let newAccount = CodexVisibleAccount( + id: "reauth@example.com", + email: "reauth@example.com", + authFingerprint: "new-auth-fingerprint", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: oldAccount, + snapshot: self.snapshot(email: oldAccount.email, percent: 71), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [newAccount]) + + #expect(hydrated.isEmpty) + } + @Test func `codex account snapshot store rejects legacy workspace records without identity`() throws { let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) From 0b7de58416e3548d28731962bf1e6872d9c7c88c Mon Sep 17 00:00:00 2001 From: Yash Bans <149187768+oyaah@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:57:36 +0530 Subject: [PATCH 46/93] fix: detect Antigravity CLI usage Detect Antigravity CLI language-server processes and keep empty CSRF fallback scoped to explicit CLI process matches. Tests: - swift test --filter AntigravityStatusProbeTests - make check Co-authored-by: oyaah --- CHANGELOG.md | 1 + .../Antigravity/AntigravityStatusProbe.swift | 74 +++++++++-- .../AntigravityStatusProbeTests.swift | 118 ++++++++++++++++++ docs/antigravity.md | 18 ++- 4 files changed, 198 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c55ce9738..dc6812aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Claude: remove transient ClaudeProbe session artifacts after CLI usage polls so background refreshes no longer fill Claude Code project history with CodexBar `/usage` sessions (#1301). Thanks @LPFchan and @matthewod11-stack! - Menu bar: keep z.ai overview rows with detail submenus in Overview so hovering quota details no longer recurses into a nested provider menu (#1279, fixes #1246). Thanks @RajvardhanPatil07! - Codex: backfill visible-account reset timestamps and missing 5-hour/weekly window metadata from same-workspace plan history so segmented multi-account JSON keeps machine-readable reset data (#1283). Thanks @callmepopo! +- Antigravity: detect CLI local language-server processes and allow empty CSRF tokens only for explicit CLI matches so Antigravity CLI quota usage renders without weakening IDE CSRF detection (#1341). Thanks @oyaah! - Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech! - Codex: time out stalled managed `codex login` processes so account switches no longer stay stuck in progress after OAuth completes (#1330). Thanks @dhruv-anand-aintech! - Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index e1d3bbb7f..79c8dd2b8 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -559,7 +559,7 @@ public struct AntigravityStatusProbe: Sendable { // MARK: - Port detection - private struct ProcessInfoResult { + struct ProcessInfoResult { let pid: Int let extensionPort: Int? let extensionServerCSRFToken: String? @@ -592,14 +592,25 @@ public struct AntigravityStatusProbe: Sendable { timeout: timeout, label: "antigravity-ps") - let lines = result.stdout.split(separator: "\n") - var sawAntigravity = false + return try Self.processInfo(fromProcessListOutput: result.stdout) + } + + static func processInfo(fromProcessListOutput output: String) throws -> ProcessInfoResult { + let lines = output.split(separator: "\n") + var sawTokenlessIDE = false for line in lines { let text = String(line) guard let match = Self.matchProcessLine(text) else { continue } - guard Self.isAntigravityLanguageServerCommandLine(match.command) else { continue } - sawAntigravity = true - guard let token = Self.extractFlag("--csrf_token", from: match.command) else { continue } + guard let kind = Self.antigravityProcessKind(match.command) else { continue } + // The IDE language server authenticates local requests with a + // `--csrf_token` and must keep requiring it: skip a tokenless IDE + // match so a later valid IDE server can still be found (and surface + // `missingCSRFToken` if none is). The CLI's language server exposes + // no token flag and needs none, so an empty token is allowed there. + guard let token = Self.resolvedCSRFToken(forKind: kind, command: match.command) else { + sawTokenlessIDE = true + continue + } let port = Self.extractPort("--extension_server_port", from: match.command) let extensionServerCSRFToken = Self.extractFlag("--extension_server_csrf_token", from: match.command) return ProcessInfoResult( @@ -610,7 +621,7 @@ public struct AntigravityStatusProbe: Sendable { commandLine: match.command) } - if sawAntigravity { + if sawTokenlessIDE { throw AntigravityStatusProbeError.missingCSRFToken } throw AntigravityStatusProbeError.notRunning @@ -629,9 +640,43 @@ public struct AntigravityStatusProbe: Sendable { return ProcessLineMatch(pid: pid, command: String(parts[1])) } + enum AntigravityProcessKind: Equatable { + /// IDE language server (`language_server*`). Requires a `--csrf_token`. + case ide + /// CLI language server (`agy` / `antigravity-cli`). Needs no CSRF token. + case cli + } + static func isAntigravityLanguageServerCommandLine(_ command: String) -> Bool { + self.antigravityProcessKind(command) != nil + } + + /// Classify a process command line as the Antigravity IDE language server, + /// the Antigravity CLI language server, or neither. The IDE match takes + /// precedence so its CSRF-token requirement is preserved. + static func antigravityProcessKind(_ command: String) -> AntigravityProcessKind? { let lower = command.lowercased() - return Self.isLanguageServerCommandLine(lower) && Self.isAntigravityCommandLine(lower) + if Self.isLanguageServerCommandLine(lower), Self.isAntigravityCommandLine(lower) { + return .ide + } + if Self.isAntigravityCLICommandLine(lower) { + return .cli + } + return nil + } + + /// Resolve the CSRF token to use for a matched process, or `nil` when the + /// match must be skipped. IDE matches keep requiring `--csrf_token` + /// (tokenless IDE matches are skipped). CLI matches accept an empty token + /// because the CLI's language server requires none. + static func resolvedCSRFToken(forKind kind: AntigravityProcessKind, command: String) -> String? { + if let token = extractFlag("--csrf_token", from: command) { + return token + } + switch kind { + case .ide: return nil + case .cli: return "" + } } private static func isLanguageServerCommandLine(_ lowerCommand: String) -> Bool { @@ -639,6 +684,19 @@ public struct AntigravityStatusProbe: Sendable { return lowerCommand.range(of: pattern, options: .regularExpression) != nil } + /// The Antigravity CLI (`agy` / `antigravity-cli`) hosts the same language + /// server locally as the IDE, but launches it without a `--csrf_token` flag + /// and under a different process name. Match it so usage can be probed when + /// only the CLI is running. + private static func isAntigravityCLICommandLine(_ lowerCommand: String) -> Bool { + let cliPathPattern = #"(^|[/\\])(antigravity-cli|antigravity_cli)([\s/\\]|$)"# + if lowerCommand.range(of: cliPathPattern, options: .regularExpression) != nil { + return true + } + let agyPattern = #"(^|[/\\])agy(\s|$)"# + return lowerCommand.range(of: agyPattern, options: .regularExpression) != nil + } + private static func isAntigravityCommandLine(_ command: String) -> Bool { if command.contains("--app_data_dir") && command.contains("antigravity") { return true } if command.contains("/antigravity/") || command.contains("\\antigravity\\") { return true } diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index fe35ba608..bd71cbd2b 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -62,6 +62,124 @@ struct AntigravityStatusProbeTests { #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(command)) } + @Test + func `process detection accepts antigravity cli without csrf token`() { + // The CLI launches its language server without a `--csrf_token` flag. + let node = """ + node /Users/test/.gemini/antigravity-cli/build/mcp-server.cjs \ + --app_data_dir /Users/test/.gemini/antigravity + """ + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(node)) + + let agy = "/Users/test/.local/bin/agy -p hello" + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(agy)) + + let agyUnderscore = "/usr/local/bin/agy --app_data_dir /Users/test/.gemini/antigravity_cli" + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(agyUnderscore)) + } + + @Test + func `process detection ignores unrelated binaries containing agy substring`() { + // "agy" must be path-anchored so unrelated commands do not match. + #expect(!AntigravityStatusProbe.isAntigravityLanguageServerCommandLine("/usr/bin/legacy --run")) + #expect(!AntigravityStatusProbe.isAntigravityLanguageServerCommandLine("/opt/imagymagic/bin/tool")) + } + + @Test + func `process detection ignores cli names outside explicit cli path segments`() { + #expect( + !AntigravityStatusProbe.isAntigravityLanguageServerCommandLine( + "/usr/bin/node /tmp/not-antigravity-cli/build/server.js")) + #expect( + !AntigravityStatusProbe.isAntigravityLanguageServerCommandLine( + "/usr/bin/helper --workspace antigravity-cli")) + } + + @Test + func `process kind distinguishes ide language server from cli`() { + let ide = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --csrf_token token --app_data_dir antigravity + """ + #expect(AntigravityStatusProbe.antigravityProcessKind(ide) == .ide) + #expect(AntigravityStatusProbe.antigravityProcessKind("/Users/test/.local/bin/agy -p hi") == .cli) + #expect( + AntigravityStatusProbe.antigravityProcessKind( + "node /x/.gemini/antigravity-cli/build/mcp-server.cjs --app_data_dir /x/.gemini/antigravity") == .cli) + #expect(AntigravityStatusProbe.antigravityProcessKind("/usr/bin/legacy --run") == nil) + } + + @Test + func `csrf token stays required for ide but optional for cli`() { + // IDE with a token returns it. + let ideWithToken = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --csrf_token ide-token --app_data_dir antigravity + """ + #expect(AntigravityStatusProbe.resolvedCSRFToken(forKind: .ide, command: ideWithToken) == "ide-token") + + // Tokenless IDE is skipped (nil) so detection keeps scanning for a valid + // server and preserves the missing-token diagnostic — no empty-token probe. + let ideNoToken = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --app_data_dir antigravity + """ + #expect(AntigravityStatusProbe.resolvedCSRFToken(forKind: .ide, command: ideNoToken) == nil) + + // CLI without a token resolves to an empty token (its server needs none). + #expect( + AntigravityStatusProbe.resolvedCSRFToken( + forKind: .cli, command: "/Users/test/.local/bin/agy -p hi")?.isEmpty == true) + + // A CLI that does carry a token still uses it. + #expect( + AntigravityStatusProbe.resolvedCSRFToken( + forKind: .cli, command: "/Users/test/.local/bin/agy --csrf_token cli-token") == "cli-token") + } + + @Test + func `process scan skips tokenless ide before later valid ide`() throws { + let tokenlessIDE = + " 100 /Applications/Antigravity.app/Contents/Resources/bin/language_server --app_data_dir antigravity" + let validIDE = " 101 /Applications/Antigravity.app/Contents/Resources/bin/language_server " + + "--csrf_token ide-token --app_data_dir antigravity " + + "--extension_server_port 64432 --extension_server_csrf_token extension-token" + let output = [tokenlessIDE, validIDE].joined(separator: "\n") + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + + #expect(result.pid == 101) + #expect(result.csrfToken == "ide-token") + #expect(result.extensionPort == 64432) + #expect(result.extensionServerCSRFToken == "extension-token") + } + + @Test + func `process scan reports missing csrf when only tokenless ide matches`() { + let output = """ + 100 /Applications/Antigravity.app/Contents/Resources/bin/language_server --app_data_dir antigravity + """ + + #expect(throws: AntigravityStatusProbeError.missingCSRFToken) { + try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + } + } + + @Test + func `process scan allows empty csrf only for explicit cli match`() throws { + let output = """ + 200 /Users/test/.local/bin/agy -p hello + """ + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + + #expect(result.pid == 200) + #expect(result.csrfToken.isEmpty) + #expect(result.commandLine == "/Users/test/.local/bin/agy -p hello") + } +} + +extension AntigravityStatusProbeTests { @Test func `localhost trust policy only accepts local server trust challenges`() { #expect( diff --git a/docs/antigravity.md b/docs/antigravity.md index 5ba4978da..4907ce3fb 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -9,7 +9,7 @@ read_when: # Antigravity provider -Antigravity supports local IDE probing and Google OAuth-backed remote usage. The OAuth path can store multiple Google accounts through the shared token-account switcher. +Antigravity supports local probing of either the IDE or the CLI (`agy` / `antigravity-cli`) language server, plus Google OAuth-backed remote usage. The OAuth path can store multiple Google accounts through the shared token-account switcher. ## OAuth account switching @@ -32,11 +32,19 @@ Antigravity supports local IDE probing and Google OAuth-backed remote usage. The 1) **Process detection** - Command: `ps -ax -o pid=,command=`. - - Match process name: `language_server_macos` plus Antigravity markers: - - `--app_data_dir antigravity` OR path contains `/antigravity/`. + - Match either: + - the **IDE** language server: process name `language_server_macos` plus Antigravity + markers (`--app_data_dir antigravity` OR path contains `/antigravity/`); or + - the **CLI**: an `antigravity-cli` / `antigravity_cli` path segment, or the + `agy` binary (path-anchored so unrelated arguments/binaries do not match). - Extract CLI flags: - - `--csrf_token ` (required). - - `--extension_server_port ` (HTTP fallback). + - `--csrf_token `. Requirement depends on the match kind: + - **IDE** matches still require it — a tokenless IDE `language_server` match is + skipped so a later valid IDE server can be found, otherwise `missingCSRFToken` + is reported (unchanged behavior). + - **CLI** matches accept an empty token, because the CLI's language server + exposes no `--csrf_token` flag and requires none. + - `--extension_server_port ` (HTTP fallback; IDE only). 2) **Port discovery** - Command: `lsof -nP -iTCP -sTCP:LISTEN -p `. From 4652e40682a8d3500945571d6afee5c26ea9bc53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Hidemaru=20Ogoshi=20/=20=E5=A4=A7=E8=B6=8A?= =?UTF-8?q?=E3=83=8B=E3=82=B3=E3=83=A9=E3=82=B9=E7=A7=80=E4=B8=B8?= <40846197+Nicolas0315@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:25:34 +0900 Subject: [PATCH 47/93] fix: avoid closed menu rebuilds during data refresh --- CHANGELOG.md | 1 + .../StatusItemController+MenuTracking.swift | 18 +- .../StatusMenuClosedPreparationTests.swift | 161 ++++++++++++++++++ .../StatusMenuOpenRefreshTests.swift | 98 ++++++++++- 4 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index dc6812aec..e9bee6dba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Menu bar: keep z.ai overview rows with detail submenus in Overview so hovering quota details no longer recurses into a nested provider menu (#1279, fixes #1246). Thanks @RajvardhanPatil07! - Codex: backfill visible-account reset timestamps and missing 5-hour/weekly window metadata from same-workspace plan history so segmented multi-account JSON keeps machine-readable reset data (#1283). Thanks @callmepopo! - Antigravity: detect CLI local language-server processes and allow empty CSRF tokens only for explicit CLI matches so Antigravity CLI quota usage renders without weakening IDE CSRF detection (#1341). Thanks @oyaah! +- Menu bar: skip closed attached-menu rebuilds during stale background data-refresh ticks so closed dropdowns are not pre-warmed while the user is not interacting (#1291). Thanks @Nicolas0315! - Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech! - Codex: time out stalled managed `codex login` processes so account switches no longer stay stuck in progress after OAuth completes (#1330). Thanks @dhruv-anand-aintech! - Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index a04c50c70..f5c0b8ed9 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -52,9 +52,25 @@ extension StatusItemController { guard self.isMenuRefreshEnabled else { return } guard self.openMenus.isEmpty else { return } guard !self.isMenuDataRefreshInFlight else { return } - for menu in self.attachedMenusForClosedPreparation() { + let menus = self.attachedMenusForClosedPreparation() + let requiredClosedPreparationVersion: Int? + if self.menuContentVersion > self.latestRequiredMenuRebuildVersion { + guard self.latestRequiredMenuRebuildVersion > 0 else { return } + let hasRequiredClosedMenu = menus.contains { menu in + let key = ObjectIdentifier(menu) + return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion + } + guard hasRequiredClosedMenu else { return } + requiredClosedPreparationVersion = self.latestRequiredMenuRebuildVersion + } else { + requiredClosedPreparationVersion = nil + } + for menu in menus { let key = ObjectIdentifier(menu) guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue } + if let requiredClosedPreparationVersion { + guard (self.menuVersions[key] ?? -1) < requiredClosedPreparationVersion else { continue } + } // Pre-warming the merged menu while it is closed runs a full main-thread populateMenu // (incl. SwiftUI hosting-view layout) that menuWillOpen redoes synchronously on display // anyway. In Merge Icons mode it is the only attached menu, so this just relocates that diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift new file mode 100644 index 000000000..e2e1fbaf3 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift @@ -0,0 +1,161 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `stale data refresh suppresses icon attached closed menu preparation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Simulate a closed menu that was attached by an icon update but has never been opened. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + let key = ObjectIdentifier(menu) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.prepareAttachedClosedMenusIfNeeded() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == nil) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `stale refresh completion requeues required closed menu preparation blocked by refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + let requiredVersion = controller.latestRequiredMenuRebuildVersion + store.isRefreshing = true + for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { + await Task.yield() + } + + #expect(requiredVersion > (openedVersion ?? -1)) + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `data refresh while persistent menu is open rebuilds on close`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 9695f90e9..21e57ae44 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -199,7 +199,8 @@ extension StatusMenuTests { let key = ObjectIdentifier(menu) let openedVersion = controller.menuVersions[key] - // Background data-refresh tick (stale allowed): the closed merged menu must not be pre-warmed. + // Background data-refresh tick (stale allowed): closed prep is skipped entirely, so + // the closed merged menu must not be pre-warmed or marked deferred. controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) for _ in 0..<40 { await Task.yield() @@ -207,7 +208,7 @@ extension StatusMenuTests { #expect(controller.openMenus.isEmpty) #expect(controller.menuContentVersion != openedVersion) #expect(controller.menuVersions[key] == openedVersion) - #expect(controller.closedMenusDeferredUntilNextOpen.contains(key)) + #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) // A required (non-stale) invalidation must also leave the closed merged menu deferred. controller.invalidateMenus() @@ -224,6 +225,99 @@ extension StatusMenuTests { #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) } + @Test + func `data refresh invalidation does not rebuild closed non merged attached menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Use a non-merged attached menu: stale data-refresh invalidations should not pre-warm any + // closed attached menu, while required invalidations still may prepare non-merged menus. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `required non merged closed menu preparation survives later data refresh invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Use a non-merged attached menu so this covers the delayed closed-menu rebuild path. Merged + // menus are intentionally deferred until next open on current main (#1274). + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + let requiredVersion = controller.latestRequiredMenuRebuildVersion + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(requiredVersion > (openedVersion ?? -1)) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + @Test func `closed attached menu preparation waits for store refresh to finish`() async { self.disableMenuCardsForTesting() From 1583d6cc10058b9aeb24a0d300cb6d3ce517e65d Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:15:51 +0800 Subject: [PATCH 48/93] feat: add native Dutch localization support --- Sources/CodexBar/PreferencesGeneralPane.swift | 2 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1065 +++++++++++++++++ .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + 9 files changed, 1074 insertions(+) create mode 100644 Sources/CodexBar/Resources/nl.lproj/Localizable.strings diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 15fc731b6..335da5695 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -12,6 +12,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case portugueseBrazilian = "pt-BR" case swedish = "sv" case french = "fr" + case dutch = "nl" case ukrainian = "uk" var id: String { @@ -29,6 +30,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .portugueseBrazilian: L("language_portuguese_brazilian") case .swedish: L("language_swedish") case .french: L("language_french") + case .dutch: L("language_dutch") case .ukrainian: L("language_ukrainian") } } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 04c387a90..3b195aa70 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; "language_french" = "Francès"; "language_ukrainian" = "Ucraïnès"; "start_at_login_title" = "Obrir en iniciar la sessió"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 7d4e81fd6..97919f1d4 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; "language_swedish" = "Svenska"; "language_french" = "French"; "language_ukrainian" = "Українська"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 77d78e43b..cfaad5d04 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; "language_french" = "Francés"; "language_ukrainian" = "Ucraniano"; "start_at_login_title" = "Abrir al iniciar sesión"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings new file mode 100644 index 000000000..f8ad29ba1 --- /dev/null +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -0,0 +1,1065 @@ +/* Dutch localization for CodexBar */ + +" providers" = " providers"; +"(System)" = "(Systeem)"; +"30d" = "30d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat je het toevoegt"; +"API key" = "API-sleutel"; +"API region" = "API-regio"; +"API token" = "API-token"; +"API tokens" = "API-tokens"; +"About" = "Over"; +"Account" = "Account"; +"Accounts" = "Accounts"; +"Accounts subtitle" = "Accounts"; +"Active" = "Actief"; +"Add" = "Toevoegen"; +"Add Workspace" = "Werkruimte toevoegen"; +"Advanced" = "Geavanceerd"; +"All" = "Alle"; +"Always allow prompts" = "Sta altijd aanwijzingen toe"; +"Animation pattern" = "Animatie patroon"; +"Antigravity login is managed in the app" = "Antigravity-login wordt beheerd in de app"; +"Applies only to the Security.framework OAuth keychain reader." = "Geldt alleen voor de Security.framework OAuth-sleutelhangerlezer."; +"Auto falls back to the next source if the preferred one fails." = "Auto valt terug naar de volgende bron als de voorkeursbron uitvalt."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto gebruikt eerst de API en valt vervolgens terug op CLI bij auth-mislukkingen."; +"Auto-detect" = "Automatische detectie"; +"Auto-refresh is off; use the menu's Refresh command." = "Automatisch vernieuwen is uitgeschakeld; gebruik de opdracht Vernieuwen van het menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Automatisch vernieuwen: elk uur · Time-out: 10m"; +"Automatic" = "Automatisch"; +"Automatic imports browser cookies and WorkOS tokens." = "Importeert automatisch browsercookies en WorkOS-tokens."; +"Automatic imports browser cookies and local storage tokens." = "Importeert automatisch browsercookies en lokale opslagtokens."; +"Automatic imports browser cookies for dashboard extras." = "Importeert automatisch browsercookies voor dashboardextra's."; +"Automatic imports browser cookies for the web API." = "Importeert automatisch browsercookies voor de web-API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Importeert automatisch browsercookies van Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Importeert automatisch browsercookies van admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Importeert automatisch browsercookies van opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Importeert automatisch browsercookies of opgeslagen sessies."; +"Automatic imports browser cookies." = "Automatische import van browsercookies."; +"Automatically imports browser session cookie." = "Importeert automatisch een browsersessiecookie."; +"Automatically opens CodexBar when you start your Mac." = "Opent automatisch CodexBar wanneer u uw Mac start."; +"Automation" = "Automatisering"; +"Average (\\(label1) + \\(label2))" = "Gemiddeld (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Gemiddeld (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Vermijd sleutelhangerprompts"; +"Balance" = "Saldo"; +"Battery Saver" = "Batterijbesparing"; +"Bordered" = "Omzoomd"; +"Build" = "Bouwen"; +"Built \\(buildTimestamp)" = "Gebouwd \\(buildTimestamp)"; +"Buy Credits..." = "Koop tegoeden..."; +"Buy Credits…" = "Koop tegoeden…"; +"CLI paths" = "CLI-paden"; +"CLI sessions" = "CLI-sessies"; +"Caches" = "Caches"; +"Cancel" = "Annuleren"; +"Check for Updates…" = "Controleer op updates…"; +"Check for updates automatically" = "Automatisch controleren op updates"; +"Check if you like your agents having some fun up there." = "Controleer of je het leuk vindt dat je agenten daar plezier hebben."; +"Check provider status" = "Controleer de status van de provider"; +"Choose Codex workspace" = "Kies Codex-werkruimte"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Kies de MiniMax-host (global .io of China vasteland .com)."; +"Choose up to " = "Kies tot"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Kies maximaal \\(Self.maxOverviewProviders) providers"; +"Choose up to \\(count) providers" = "Kies maximaal \\(count) providers"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Kies wat u wilt weergeven in de menubalk (Tempo toont gebruik vs. verwacht)."; +"Choose which Codex account CodexBar should follow." = "Kies welk Codex-account CodexBar moet volgen."; +"Choose which window drives the menu bar percent." = "Kies welk venster het menubalkpercentage aanstuurt."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI niet gevonden"; +"Claude binary" = "Claude binair"; +"Claude cookies" = "Claude-koekjes"; +"Claude login failed" = "Inloggen bij Claude is mislukt"; +"Claude login timed out" = "Er is een time-out opgetreden bij het inloggen bij Claude"; +"Close" = "Sluiten"; +"Code review" = "Code review"; +"Codex CLI not found" = "Codex-CLI niet gevonden"; +"Codex account login already running" = "Inloggen op Codex-account is al actief"; +"Codex binary" = "Codex binair"; +"Codex login failed" = "Codex-aanmelding mislukt"; +"Codex login timed out" = "Er is een time-out opgetreden bij het inloggen op de Codex"; +"CodexBar Lifecycle Keepalive" = "CodexBar-levenscyclus Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar kan het menubalkpictogram niet weergeven"; +"CodexBar could not read managed account storage. " = "CodexBar kan de beheerde accountopslag niet lezen."; +"Configure…" = "Configureer…"; +"Connected" = "Aangesloten"; +"Controls how much detail is logged." = "Bepaalt hoeveel details worden geregistreerd."; +"Cookie header" = "Cookie-header"; +"Cookie source" = "Cookie-bron"; +"Cookie: ..." = "Koekje: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nof plak een cURL-opname vanuit het Abacus AI-dashboard"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nof plak de __Secure-next-auth.session-token-waarde"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nof plak de kimi-auth tokenwaarde"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Kosten"; +"Could not add Codex account" = "Kan Codex-account niet toevoegen"; +"Could not open Terminal for Gemini" = "Kan Terminal voor Gemini niet openen"; +"Could not start claude /login" = "Kan claude /login niet starten"; +"Could not start codex login" = "Kan codex-aanmelding niet starten"; +"Could not switch system account" = "Kan van systeemaccount niet wisselen"; +"Credits" = "Kredieten"; +"Credits history" = "Creditgeschiedenis"; +"Cursor login failed" = "Cursoraanmelding mislukt"; +"Custom" = "Aangepast"; +"Custom Path" = "Aangepast pad"; +"Daily Routines" = "Dagelijkse routines"; +"Debug" = "Foutopsporing"; +"Default" = "Standaard"; +"Disable Keychain access" = "Schakel sleutelhangertoegang uit"; +"Disabled" = "Uitgeschakeld"; +"Dismiss" = "Afwijzen"; +"Disconnected" = "Verbinding verbroken"; +"Display" = "Weergave"; +"Display mode" = "Weergavemodus"; +"Display reset times as absolute clock values instead of countdowns." = "Geef resettijden weer als absolute klokwaarden in plaats van aftellingen."; +"Done" = "Klaar"; +"Effective PATH" = "Effectief PAD"; +"Email" = "E-mail"; +"Enable Merge Icons to configure Overview tab providers." = "Schakel Pictogrammen samenvoegen in om de providers van tabbladen Overzicht te configureren."; +"Enable file logging" = "Bestandsregistratie inschakelen"; +"Enabled" = "Ingeschakeld"; +"Error" = "Fout"; +"Error simulation" = "Foutsimulatie"; +"Expose troubleshooting tools in the Debug tab." = "Geef hulpprogramma's voor probleemoplossing weer op het tabblad Foutopsporing."; +"Failed" = "Mislukt"; +"False" = "Onwaar"; +"Fetch strategy attempts" = "Strategiepogingen ophalen"; +"Fetching" = "Ophalen"; +"Field" = "Veld"; +"Field subtitle" = "Ondertitel van veld"; +"Finish the current managed account change before switching the system account." = "Voltooi de huidige beheerde accountwijziging voordat u van systeemaccount wisselt."; +"Force animation on next refresh" = "Animatie forceren bij volgende vernieuwing"; +"Gateway region" = "Gateway-regio"; +"Gemini CLI not found" = "Gemini-CLI niet gevonden"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, incidenten verschijnen in het pictogram en het menu."; +"General" = "Algemeen"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot-aanmelding"; +"GitHub Login" = "GitHub-aanmelding"; +"Hide details" = "Details verbergen"; +"Hide personal information" = "Verberg persoonlijke informatie"; +"Historical tracking" = "Historische tracking"; +"How often CodexBar polls providers in the background." = "Hoe vaak CodexBar providers op de achtergrond ondervraagt."; +"Inactive" = "Inactief"; +"Install CLI" = "Installeer CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installeer de Claude CLI (npm i -g @anthropic-ai/claude-code) en probeer het opnieuw."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installeer de Codex CLI (npm i -g @openai/codex) en probeer het opnieuw."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installeer de Gemini CLI (npm i -g @google/gemini-cli) en probeer het opnieuw."; +"JetBrains AI is ready" = "JetBrains AI is klaar"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Houd CLI-sessies levend"; +"Keyboard shortcut" = "Sneltoets"; +"Keychain access" = "Toegang via sleutelhanger"; +"Keychain prompt policy" = "Sleutelhangerpromptbeleid"; +"Last \\(name) fetch failed:" = "Laatste \\(name) ophalen mislukt:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Laatste ophalen van \\(self.store.metadata(for: self.provider).displayName) mislukt:"; +"Last attempt" = "Laatste poging"; +"Link" = "Link"; +"Loading animations" = "Animaties laden"; +"Loading…" = "Laden…"; +"Local" = "Lokaal"; +"Logging" = "Loggen"; +"Login failed" = "Inloggen mislukt"; +"Login shell PATH (startup capture)" = "Login shell PATH (opstartopname)"; +"Login timed out" = "Er is een time-out opgetreden voor het inloggen"; +"MCP details" = "MCP-details"; +"Managed Codex accounts unavailable" = "Beheerde Codex-accounts zijn niet beschikbaar"; +"Managed account storage is unreadable. Live account access is still available, " = "Beheerde accountopslag is onleesbaar. Live accounttoegang is nog steeds beschikbaar,"; +"Manual" = "Handmatig"; +"May your tokens never run out—keep agent limits in view." = "Moge uw tokens nooit opraken: houd de limieten van agenten in het oog."; +"Menu bar" = "Menubalk"; +"Menu bar auto-shows the provider closest to its rate limit." = "De menubalk toont automatisch de aanbieder die het dichtst bij de tarieflimiet zit."; +"Menu bar metric" = "Menubalkstatistiek"; +"Menu bar shows percent" = "Menubalk toont percentage"; +"Menu content" = "Menu-inhoud"; +"Merge Icons" = "Pictogrammen samenvoegen"; +"Never prompt" = "Nooit vragen"; +"No" = "Nee"; +"No Codex accounts detected yet." = "Er zijn nog geen Codex-accounts gedetecteerd."; +"No JetBrains IDE detected" = "Geen JetBrains IDE gedetecteerd"; +"No cost history data." = "Geen kostengeschiedenisgegevens."; +"No data available" = "Geen gegevens beschikbaar"; +"No data yet" = "Nog geen gegevens"; +"No enabled providers available for Overview." = "Er zijn geen ingeschakelde providers beschikbaar voor Overzicht."; +"No providers selected" = "Geen aanbieders geselecteerd"; +"No token accounts yet." = "Nog geen tokenaccounts."; +"No usage breakdown data." = "Geen gebruiksgegevens."; +"None" = "Geen"; +"Notifications" = "Meldingen"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Geeft een melding wanneer het sessiequotum van 5 uur 0% bereikt en wanneer dit wordt bereikt"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Onduidelijke e-mailadressen in de menubalk en menu-UI."; +"Off" = "Uit"; +"Offline" = "Offline"; +"On" = "Op"; +"Online" = "Online"; +"Only on user action" = "Alleen bij gebruikersactie"; +"Open" = "Open"; +"Open API Keys" = "Open API-sleutels"; +"Open Amp Settings" = "Open Versterkerinstellingen"; +"Open Antigravity to sign in, then refresh CodexBar." = "Open Antigravity om in te loggen en vernieuw vervolgens CodexBar."; +"Open Browser" = "Browser openen"; +"Open Coding Plan" = "Coderingsplan openen"; +"Open Console" = "Console openen"; +"Open Dashboard" = "Dashboard openen"; +"Open Mistral Admin" = "Open Mistral-beheer"; +"Open Menu Bar Settings" = "Open Menubalkinstellingen"; +"Open Ollama Settings" = "Open Ollama-instellingen"; +"Open Terminal" = "Terminal openen"; +"Open Usage Page" = "Gebruikspagina openen"; +"Open Warp API Key Guide" = "Open Warp API-sleutelgids"; +"Open menu" = "Menu openen"; +"Open token file" = "Tokenbestand openen"; +"OpenAI cookies" = "OpenAI-cookies"; +"OpenAI web extras" = "OpenAI-webextra's"; +"Option A" = "Optie A"; +"Option B" = "Optie B"; +"Optional override if workspace lookup fails." = "Optioneel overschrijven als het opzoeken van de werkruimte mislukt."; +"Options" = "Opties"; +"Override auto-detection with a custom IDE base path" = "Overschrijf automatische detectie met een aangepast IDE-basispad"; +"Overview" = "Overzicht"; +"Overview rows always follow provider order." = "Overzichtsrijen volgen altijd de volgorde van de provider."; +"Overview tab providers" = "Overzicht tabblad aanbieders"; +"Paste API key…" = "API-sleutel plakken…"; +"Paste API token…" = "API-token plakken…"; +"Paste key…" = "Sleutel plakken…"; +"Paste sessionKey or OAuth token…" = "SessionKey of OAuth-token plakken..."; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Plak de Cookie-header uit een verzoek naar admin.mistral.ai."; +"Paste token…" = "Token plakken..."; +"Personal" = "Persoonlijk"; +"Picker" = "Kikker"; +"Picker subtitle" = "Ondertitel kiezen"; +"Placeholder" = "Tijdelijke aanduiding"; +"Plan" = "Plan"; +"Play full-screen confetti when weekly usage resets." = "Speel confetti op volledig scherm af wanneer het wekelijkse gebruik wordt gereset."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Polls OpenAI/Claude-statuspagina's en Google Workspace voor"; +"Prevents any Keychain access while enabled." = "Voorkomt elke sleutelhangertoegang indien ingeschakeld."; +"Primary (API key limit)" = "Primair (API-sleutellimiet)"; +"Primary (\\(label))" = "Primair (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primair (\\(metadata.sessionLabel))"; +"Probe logs" = "Sondelogboeken"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Voortgangsbalken worden gevuld naarmate u uw quotum verbruikt (in plaats van de resterende hoeveelheid weer te geven)."; +"Provider" = "Aanbieder"; +"Providers" = "Providers"; +"Quit CodexBar" = "Sluit CodexBar af"; +"Random (default)" = "Willekeurig (standaard)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Leest lokale gebruikslogboeken. Toont vandaag + het geselecteerde geschiedenisvenster in het menu."; +"Refresh" = "Vernieuwen"; +"Refresh cadence" = "Cadans vernieuwen"; +"Remote" = "Op afstand"; +"Remove" = "Verwijderen"; +"Remove Codex account?" = "Codex-account verwijderen?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "\\(account.email) verwijderen uit CodexBar? Het beheerde Codex-huis wordt verwijderd."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "\\(email) verwijderen uit CodexBar? Het beheerde Codex-huis wordt verwijderd."; +"Remove selected account" = "Geselecteerd account verwijderen"; +"Replace critter bars with provider branding icons and a percentage." = "Vervang critterbalken door brandingpictogrammen van de provider en een percentage."; +"Replay selected animation" = "Speel de geselecteerde animatie opnieuw af"; +"Requires authentication via GitHub Device Flow." = "Vereist authenticatie via GitHub Device Flow."; +"Resets: \\(reset)" = "Resetten: \\(reset)"; +"Rolling five-hour limit" = "Doorlopende limiet van vijf uur"; +"Search hourly" = "Zoek per uur"; +"Secondary (\\(label))" = "Secundair (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secundair (\\(metadata.weeklyLabel))"; +"Select a provider" = "Selecteer een aanbieder"; +"Select the IDE to monitor" = "Selecteer de IDE die u wilt monitoren"; +"Session quota notifications" = "Meldingen over sessiequota"; +"Session tokens" = "Sessietokens"; +"Settings" = "Instellingen"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Toon Codex Credits en Claude Extra gebruikssecties in het menu."; +"Show Debug Settings" = "Toon foutopsporingsinstellingen"; +"Show all token accounts" = "Toon alle tokenaccounts"; +"Show cost summary" = "Kostenoverzicht weergeven"; +"Show credits + extra usage" = "Toon credits + extra gebruik"; +"Show details" = "Details weergeven"; +"Show most-used provider" = "Toon meest gebruikte provider"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Toon providerpictogrammen in de switcher (toon anders een wekelijkse voortgangslijn)."; +"Show reset time as clock" = "Toon resettijd als klok"; +"Show usage as used" = "Toon gebruik zoals gebruikt"; +"Sign in via button below" = "Meld u aan via onderstaande knop"; +"Skip teardown between probes (debug-only)." = "Sla demontage tussen tests over (alleen voor foutopsporing)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stapel token-accounts in het menu (laat anders een accountwisselbalk zien)."; +"Start at Login" = "Begin bij Inloggen"; +"Status" = "Status"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Bewaar Claude sessionKey-cookies of OAuth-toegangstokens."; +"Store multiple Abacus AI Cookie headers." = "Bewaar meerdere Abacus AI Cookie-headers."; +"Store multiple Augment Cookie headers." = "Bewaar meerdere Augment Cookie-headers."; +"Store multiple Cursor Cookie headers." = "Bewaar meerdere Cursor Cookie-headers."; +"Store multiple Factory Cookie headers." = "Bewaar meerdere Factory Cookie-headers."; +"Store multiple MiniMax Cookie headers." = "Bewaar meerdere MiniMax Cookie-headers."; +"Store multiple Mistral Cookie headers." = "Bewaar meerdere Mistral Cookie-headers."; +"Store multiple Ollama Cookie headers." = "Bewaar meerdere Ollama Cookie-headers."; +"Store multiple OpenCode Cookie headers." = "Bewaar meerdere OpenCode Cookie-headers."; +"Store multiple OpenCode Go Cookie headers." = "Bewaar meerdere OpenCode Go Cookie-headers."; +"Stored in the CodexBar config file." = "Opgeslagen in het CodexBar-configuratiebestand."; +"Stored in ~/.codexbar/config.json. " = "Opgeslagen in ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Opgeslagen in ~/.codexbar/config.json. Genereer er een op kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Opgeslagen in ~/.codexbar/config.json. Plak de sleutel uit het synthetische dashboard."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Opgeslagen in ~/.codexbar/config.json. Plak de API-sleutel van uw codeerplan uit Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Opgeslagen in ~/.codexbar/config.json. Plak uw MiniMax API-sleutel."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Opgeslagen in ~/.codexbar/config.json. U kunt ook KILO_API_KEY of"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Slaat de lokale Codex-gebruiksgeschiedenis op (8 weken) om tempo-voorspellingen te personaliseren."; +"Subscription Utilization" = "Abonnementsgebruik"; +"Surprise me" = "Verras mij"; +"Switcher shows icons" = "Switcher toont pictogrammen"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI naar /usr/local/bin en /opt/homebrew/bin als codexbar."; +"System" = "Systeem"; +"Temporarily shows the loading animation after the next refresh." = "Toont tijdelijk de laadanimatie na de volgende vernieuwing."; +"Tertiary (\\(label))" = "Tertiair (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Tertiair (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Het standaard Codex-account op deze Mac."; +"Toggle" = "Schakelaar"; +"Toggle subtitle" = "Schakel ondertiteling in"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Activeer het menubalkmenu vanaf elke locatie."; +"True" = "WAAR"; +"Twitter" = "Twitteren"; +"Unsupported" = "Niet ondersteund"; +"Update Channel" = "Kanaal bijwerken"; +"Updated" = "Bijgewerkt"; +"Updates unavailable in this build." = "Updates zijn niet beschikbaar in deze build."; +"Usage" = "Gebruik"; +"Usage breakdown" = "Uitsplitsing van gebruik"; +"Usage history (30 days)" = "Gebruiksgeschiedenis"; +"Usage source" = "Gebruiksbron"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Gebruik BigModel voor de eindpunten op het vasteland van China (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Gebruik één menubalkpictogram met een providerwisselaar."; +"Use international or China mainland console gateways for quota fetches." = "Gebruik internationale of Chinese consolegateways voor het ophalen van quota."; +"Version" = "Versie"; +"Version \\(self.versionString)" = "Versie \\(self.versionString)"; +"Version \\(version)" = "Versie \\(version)"; +"Version \\(versionString)" = "Versie \\(versionString)"; +"Vertex AI Login" = "Vertex AI-login"; +"Wait for the current managed Codex login to finish before adding another account." = "Wacht tot de huidige beheerde Codex-aanmelding is voltooid voordat u een ander account toevoegt."; +"Waiting for Authentication..." = "Wachten op authenticatie..."; +"Website" = "Website"; +"Weekly limit confetti" = "Wekelijkse limiet confetti"; +"Weekly token limit" = "Wekelijkse tokenlimiet"; +"Weekly usage" = "Wekelijks gebruik"; +"Weekly usage unavailable for this account." = "Wekelijks gebruik is niet beschikbaar voor dit account."; +"Window: \\(window)" = "Venster: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Schrijf logboeken naar \\(self.fileLogPath) voor foutopsporing."; +"Yes" = "Ja"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): ophalen…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): laatste poging \\(when)"; +"\\(name): no data yet" = "\\(name): nog geen gegevens"; +"\\(name): unsupported" = "\\(name): niet ondersteund"; +"all browsers" = "alle browsers"; +"available again." = "weer beschikbaar."; +"built_format" = "Gebouwd %@"; +"copilot_complete_in_browser" = "Voltooi het inloggen in uw browser."; +"copilot_device_code" = "Apparaatcode gekopieerd naar klembord: %1$@\n\nVerifiëren op: %2$@"; +"copilot_device_code_copied" = "Apparaatcode gekopieerd."; +"copilot_verify_at" = "Verifiëren op %@"; +"copilot_waiting_text" = "Voltooi het inloggen in uw browser.\nDit venster wordt automatisch gesloten wanneer het inloggen is voltooid."; +"copilot_window_closes_auto" = "Dit venster wordt automatisch gesloten wanneer het inloggen is voltooid."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: ophalen… %2$@"; +"cost_status_last_attempt" = "%1$@: laatste poging %2$@"; +"cost_status_no_data" = "%@: nog geen gegevens"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: niet ondersteund"; +"credits_remaining" = "Tegoeden: %@"; +"cursor_on_demand" = "Op aanvraag: %@"; +"cursor_on_demand_with_limit" = "Op aanvraag: %1$@ / %2$@"; +"extra_usage_format" = "Extra verbruik: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Gedetecteerd: %@. Gebruik de AI-assistent één keer om quotagegevens te genereren en vernieuw vervolgens CodexBar."; +"jetbrains_detected_select" = "Gedetecteerd: %@. Selecteer uw favoriete IDE in Instellingen en vernieuw vervolgens CodexBar."; +"last_fetch_failed_with_provider" = "Laatste %@ ophaalactie mislukt:"; +"last_spend" = "Laatste uitgave: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Resetten: %@"; +"mcp_window" = "Venster: %@"; +"metric_average" = "Gemiddeld (%1$@ + %2$@)"; +"metric_primary" = "Primair (%@)"; +"metric_secondary" = "Secundair (%@)"; +"metric_tertiary" = "Tertiair (%@)"; +"multiple_workspaces_found" = "CodexBar heeft meerdere werkruimten gevonden voor %@. Kies de werkruimte die u wilt toevoegen."; +"ory_session_…=…; csrftoken=…" = "ory_sessie_…=…; csrftoken=…"; +"overview_choose_providers" = "Kies maximaal %@ providers"; +"remove_account_message" = "%@ verwijderen uit CodexBar? Het beheerde Codex-huis wordt verwijderd."; +"version_format" = "Versie %@"; +"vertex_ai_login_instructions" = "Om het gebruik van Vertex AI bij te houden, authenticeert u zich met Google Cloud.\n\n1. Open Terminal\n2. Uitvoeren: gcloud auth applicatie-standaard login\n3. Volg de browserprompts om in te loggen\n4. Stel uw project in: gcloud config set project PROJECT_ID\n\nTerminal nu openen?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID is ingesteld, maar alleen opencode, opencodego en deepgram ondersteunen workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT-licentie."; + +/* General Pane */ +"section_system" = "Systeem"; +"section_usage" = "Gebruik"; +"section_automation" = "Automatisering"; +"language_title" = "Taal"; +"language_subtitle" = "Wijzig de weergavetaal. Vereist een herstart van de app om volledig effect te krijgen."; +"language_system" = "Systeem"; +"language_english" = "Engels"; +"language_spanish" = "Spaans"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Portugees (Brazilië)"; +"language_dutch" = "Nederlands"; +"language_french" = "Frans"; +"language_ukrainian" = "Oekraïens"; +"language_swedish" = "Zweeds"; +"start_at_login_title" = "Begin bij Inloggen"; +"start_at_login_subtitle" = "Opent automatisch CodexBar wanneer u uw Mac start."; +"show_cost_summary" = "Kostenoverzicht weergeven"; +"show_cost_summary_subtitle" = "Leest lokale gebruikslogboeken. Toont vandaag + het geselecteerde geschiedenisvenster in het menu."; +"cost_history_days_title" = "Geschiedenisvenster: %d dagen"; +"cost_auto_refresh_info" = "Automatisch vernieuwen: elk uur · Time-out: 10m"; +"refresh_cadence_title" = "Cadans vernieuwen"; +"refresh_cadence_subtitle" = "Hoe vaak CodexBar providers op de achtergrond ondervraagt."; +"manual_refresh_hint" = "Automatisch vernieuwen is uitgeschakeld; gebruik de opdracht Vernieuwen van het menu."; +"check_provider_status_title" = "Controleer de status van de provider"; +"check_provider_status_subtitle" = "Polls van OpenAI/Claude-statuspagina's en Google Workspace voor Gemini/Antigravity, waarbij incidenten in het pictogram en het menu worden weergegeven."; +"session_quota_notifications_title" = "Meldingen over sessiequota"; +"session_quota_notifications_subtitle" = "Geeft een melding wanneer het sessiequotum van 5 uur 0% bereikt en wanneer het weer beschikbaar komt."; +"quota_warning_notifications_title" = "Quotumwaarschuwingsmeldingen"; +"quota_warning_notifications_subtitle" = "Waarschuwt wanneer het resterende sessie- of wekelijkse quotum de geconfigureerde drempels overschrijdt."; +"quota_warnings_title" = "Quotumwaarschuwingen"; +"quota_warning_session" = "sessie"; +"quota_warning_session_capitalized" = "Sessie"; +"quota_warning_weekly" = "wekelijks"; +"quota_warning_weekly_capitalized" = "Wekelijks"; +"quota_warning_notification_title" = "%1$@ %2$@ quotum laag"; +"quota_warning_notification_body" = "%1$@ over. Je waarschuwingsdrempel van %2$d%% %3$@ is bereikt."; +"quota_warning_notification_body_with_account" = "Rekening %1$@. %2$@ over. Je waarschuwingsdrempel van %3$d%% %4$@ is bereikt."; +"session_depleted_notification_title" = "%@ sessie uitgeput"; +"session_depleted_notification_body" = "0% over. Zal op de hoogte stellen wanneer het weer beschikbaar is."; +"session_restored_notification_title" = "%@ sessie hersteld"; +"session_restored_notification_body" = "Sessiequota zijn weer beschikbaar."; +"quota_warning_warn_at" = "Waarschuw bij"; +"quota_warning_global_threshold_subtitle" = "Resterende percentages voor sessie- en wekelijkse vensters, tenzij een provider deze overschrijft."; +"quota_warning_sound" = "Meldingsgeluid afspelen"; +"quota_warning_provider_inherits" = "Gebruikt de algemene instellingen voor quotawaarschuwingen, tenzij hier een venster wordt aangepast."; +"quota_warning_customize_thresholds" = "Pas %@ drempels aan"; +"quota_warning_enable_warnings" = "Schakel %@ waarschuwingen in"; +"quota_warning_window_warn_at" = "%@ waarschuwen om"; +"quota_warning_off" = "Uit"; +"quota_warning_inherited" = "Geërfd: %@"; +"quota_warning_depleted_only" = "alleen maar uitgeput"; +"quota_warning_upper" = "Bovenste"; +"quota_warning_lower" = "Lager"; +"apply" = "Toepassen"; +"quit_app" = "Sluit CodexBar af"; + +/* Tab titles */ +"tab_general" = "Algemeen"; +"tab_providers" = "Aanbieders"; +"tab_display" = "Weergave"; +"tab_advanced" = "Geavanceerd"; +"tab_about" = "Over"; +"tab_debug" = "Foutopsporing"; + +/* Providers Pane */ +"select_a_provider" = "Selecteer een aanbieder"; +"cancel" = "Annuleren"; +"last_fetch_failed" = "laatste ophaalactie mislukt"; +"usage_not_fetched_yet" = "gebruik nog niet opgehaald"; +"managed_account_storage_unreadable" = "Beheerde accountopslag is onleesbaar. Live accounttoegang is nog steeds beschikbaar, maar beheerde acties voor toevoegen, opnieuw verifiëren en verwijderen zijn uitgeschakeld totdat de winkel kan worden hersteld."; +"remove_codex_account_title" = "Codex-account verwijderen?"; +"remove" = "Verwijderen"; +"managed_login_already_running" = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat u een ander account toevoegt of opnieuw verifieert."; +"managed_login_failed" = "Beheerde Codex-aanmelding is niet voltooid. Controleer of `codex --version` werkt in Terminal. Als macOS `codex` naar de prullenbak heeft geblokkeerd of verplaatst, verwijdert u verouderde dubbele installaties, voert u `npm install -g --include=optioneel @openai/codex@latest` uit en probeert u het vervolgens opnieuw."; +"codex_login_output" = "codex login-uitvoer:"; +"managed_login_missing_email" = "Codex-aanmelding voltooid, maar er was geen account-e-mailadres beschikbaar. Probeer het opnieuw nadat u heeft bevestigd dat het account volledig is aangemeld."; +"login_success_notification_title" = "%@ inloggen succesvol"; +"login_success_notification_body" = "U kunt terugkeren naar de app; authenticatie voltooid."; +"workspace_selection_cancelled" = "CodexBar heeft meerdere werkruimten gevonden, maar er is geen werkruimte geselecteerd."; +"unsafe_managed_home" = "CodexBar weigerde een onverwacht beheerd thuispad te wijzigen: %@"; +"menu_bar_metric_title" = "Menubalkstatistiek"; +"menu_bar_metric_subtitle" = "Kies welk venster het menubalkpercentage aanstuurt."; +"menu_bar_metric_subtitle_deepseek" = "Toont het DeepSeek-saldo in de menubalk."; +"menu_bar_metric_subtitle_moonshot" = "Toont het Moonshot / Kimi API-saldo in de menubalk."; +"menu_bar_metric_subtitle_mistral" = "Toont de Mistral API-uitgaven van de huidige maand in de menubalk."; +"menu_bar_metric_subtitle_kimik2" = "Toont Kimi K2 API-sleutelcredits in de menubalk."; +"automatic" = "Automatisch"; +"primary_api_key_limit" = "Primair (API-sleutellimiet)"; + +/* Display Pane */ +"section_menu_bar" = "Menubalk"; +"merge_icons_title" = "Pictogrammen samenvoegen"; +"merge_icons_subtitle" = "Gebruik één menubalkpictogram met een providerwisselaar."; +"switcher_shows_icons_title" = "Switcher toont pictogrammen"; +"switcher_shows_icons_subtitle" = "Toon providerpictogrammen in de switcher (toon anders een wekelijkse voortgangslijn)."; +"show_most_used_provider_title" = "Toon meest gebruikte provider"; +"show_most_used_provider_subtitle" = "De menubalk toont automatisch de aanbieder die het dichtst bij de tarieflimiet zit."; +"menu_bar_shows_percent_title" = "Menubalk toont percentage"; +"menu_bar_shows_percent_subtitle" = "Vervang critterbalken door brandingpictogrammen van de provider en een percentage."; +"display_mode_title" = "Weergavemodus"; +"display_mode_subtitle" = "Kies wat u wilt weergeven in de menubalk (Tempo toont gebruik vs. verwacht)."; +"section_menu_content" = "Menu-inhoud"; +"show_usage_as_used_title" = "Toon gebruik zoals gebruikt"; +"show_usage_as_used_subtitle" = "Voortgangsbalken worden gevuld naarmate u uw quotum verbruikt (in plaats van de resterende hoeveelheid weer te geven)."; +"show_quota_warning_markers_title" = "Toon waarschuwingsmarkeringen voor quota"; +"show_quota_warning_markers_subtitle" = "Teken drempelmarkeringen op gebruiksbalken wanneer quotawaarschuwingen zijn geconfigureerd."; +"weekly_progress_work_days_title" = "Wekelijkse voortgang werkdagen"; +"weekly_progress_work_days_subtitle" = "Teken daggrensmarkeringen op de wekelijkse gebruiksbalken."; +"show_reset_time_as_clock_title" = "Toon resettijd als klok"; +"show_reset_time_as_clock_subtitle" = "Geef resettijden weer als absolute klokwaarden in plaats van aftellingen."; +"show_provider_changelog_links_title" = "Toon provider changelog-links"; +"show_provider_changelog_links_subtitle" = "Voegt koppelingen naar release-opmerkingen voor ondersteunde CLI-ondersteunde providers toe aan het menu."; +"show_credits_extra_usage_title" = "Toon credits + extra gebruik"; +"show_credits_extra_usage_subtitle" = "Toon Codex Credits en Claude Extra gebruikssecties in het menu."; +"show_all_token_accounts_title" = "Toon alle tokenaccounts"; +"show_all_token_accounts_subtitle" = "Stapel token-accounts in het menu (laat anders een accountwisselbalk zien)."; +"multi_account_layout_title" = "Indeling voor meerdere accounts"; +"multi_account_layout_subtitle" = "Kies voor gesegmenteerd wisselen tussen accounts of gestapelde accountkaarten."; +"multi_account_layout_segmented" = "Gesegmenteerd"; +"multi_account_layout_stacked" = "Gestapeld"; +"overview_tab_providers_title" = "Overzicht tabblad aanbieders"; +"configure" = "Configureer…"; +"overview_enable_merge_icons_hint" = "Schakel Pictogrammen samenvoegen in om de providers van tabbladen Overzicht te configureren."; +"overview_no_providers_hint" = "Er zijn geen ingeschakelde providers beschikbaar voor Overzicht."; +"overview_rows_follow_order" = "Overzichtsrijen volgen altijd de volgorde van de provider."; +"overview_no_providers_selected" = "Geen aanbieders geselecteerd"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Sneltoets"; +"open_menu_shortcut_title" = "Menu openen"; +"open_menu_shortcut_subtitle" = "Activeer het menubalkmenu vanaf elke locatie."; +"install_cli" = "Installeer CLI"; +"install_cli_subtitle" = "Symlink CodexBarCLI naar /usr/local/bin en /opt/homebrew/bin als codexbar."; +"cli_not_found" = "CodexBarCLI niet gevonden in appbundel."; +"no_writable_bin_dirs" = "Geen beschrijfbare mapmap gevonden."; +"show_debug_settings_title" = "Toon foutopsporingsinstellingen"; +"show_debug_settings_subtitle" = "Geef hulpprogramma's voor probleemoplossing weer op het tabblad Foutopsporing."; +"surprise_me_title" = "Verras mij"; +"surprise_me_subtitle" = "Controleer of je het leuk vindt dat je agenten daar plezier hebben."; +"weekly_limit_confetti_title" = "Wekelijkse limiet confetti"; +"weekly_limit_confetti_subtitle" = "Speel confetti op volledig scherm af wanneer het wekelijkse gebruik wordt gereset."; +"hide_personal_info_title" = "Verberg persoonlijke informatie"; +"hide_personal_info_subtitle" = "Onduidelijke e-mailadressen in de menubalk en menu-UI."; +"show_provider_storage_usage_title" = "Toon het opslaggebruik van de provider"; +"show_provider_storage_usage_subtitle" = "Toon lokaal schijfgebruik in menu's. Scant bekende paden van de provider op de achtergrond."; +"section_keychain_access" = "Toegang via sleutelhanger"; +"keychain_access_caption" = "Schakel alle lees- en schrijfbewerkingen van de sleutelhanger uit. Gebruik dit als macOS blijft vragen om 'Chrome/Brave/Edge Safe Storage', zelfs nadat u op Altijd toestaan ​​hebt geklikt. Browsercookie-import is niet beschikbaar als deze is ingeschakeld; plak Cookie-headers handmatig in Providers. Claude/Codex OAuth via de CLI werkt nog steeds."; +"disable_keychain_access_title" = "Schakel sleutelhangertoegang uit"; +"disable_keychain_access_subtitle" = "Voorkomt elke sleutelhangertoegang indien ingeschakeld."; + +/* About Pane */ +"about_tagline" = "Moge uw tokens nooit opraken: houd de limieten van agenten in het oog."; +"link_github" = "GitHub"; +"link_website" = "Website"; +"link_twitter" = "Twitteren"; +"link_email" = "E-mail"; +"check_updates_auto" = "Automatisch controleren op updates"; +"update_channel" = "Kanaal bijwerken"; +"check_for_updates" = "Controleer op updates…"; +"updates_unavailable" = "Updates zijn niet beschikbaar in deze build."; +"copyright" = "© 2026 Peter Steinberger. MIT-licentie."; + +/* Debug Pane */ +"section_logging" = "Loggen"; +"enable_file_logging" = "Bestandsregistratie inschakelen"; +"enable_file_logging_subtitle" = "Schrijf logboeken naar %@ voor foutopsporing."; +"verbosity_title" = "Breedsprakigheid"; +"verbosity_subtitle" = "Bepaalt hoeveel details worden geregistreerd."; +"open_log_file" = "Logbestand openen"; +"force_animation_next_refresh" = "Animatie forceren bij volgende vernieuwing"; +"force_animation_next_refresh_subtitle" = "Toont tijdelijk de laadanimatie na de volgende vernieuwing."; +"section_loading_animations" = "Animaties laden"; +"loading_animations_caption" = "Kies een patroon en speel het opnieuw af in de menubalk. \"Random\" behoudt het bestaande gedrag."; +"animation_random_default" = "Willekeurig (standaard)"; +"replay_selected_animation" = "Speel de geselecteerde animatie opnieuw af"; +"blink_now" = "Knipper nu"; +"section_probe_logs" = "Sondelogboeken"; +"probe_logs_caption" = "Haal de nieuwste testuitvoer op voor foutopsporing; Bij kopiëren blijft de volledige tekst behouden."; +"fetch_log" = "Logboek ophalen"; +"copy" = "Kopiëren"; +"save_to_file" = "Opslaan in bestand"; +"load_parse_dump" = "Parseerdump laden"; +"rerun_provider_autodetect" = "Voer de automatische detectie van de provider opnieuw uit"; +"loading" = "Laden..."; +"no_log_yet_fetch" = "Nog geen logboek. Ophalen om te laden."; +"section_fetch_strategy" = "Strategiepogingen ophalen"; +"fetch_strategy_caption" = "Laatste ophaalpijplijnbeslissingen en fouten voor een provider."; +"section_openai_cookies" = "OpenAI-cookies"; +"openai_cookies_caption" = "Cookie-import + WebKit-scraping-logboeken van de laatste OpenAI-cookiepoging."; +"no_log_yet" = "Nog geen logboek. Update OpenAI-cookies in Providers → Codex om een ​​import uit te voeren."; +"section_caches" = "Caches"; +"caches_caption" = "Wis in het cachegeheugen opgeslagen kostenscanresultaten of caches van browsercookies."; +"clear_cookie_cache" = "Cookie-cache wissen"; +"clear_cost_cache" = "Wis de kostencache"; +"section_notifications" = "Meldingen"; +"notifications_caption" = "Activeer testmeldingen voor het sessievenster van 5 uur (opgebruikt/hersteld)."; +"post_depleted" = "Post uitgeput"; +"post_restored" = "Bericht hersteld"; +"section_cli_sessions" = "CLI-sessies"; +"cli_sessions_caption" = "Houd Codex/Claude CLI-sessies levend na een onderzoek. Standaard wordt afgesloten zodra gegevens zijn vastgelegd."; +"keep_cli_sessions_alive" = "Houd CLI-sessies levend"; +"keep_cli_sessions_alive_subtitle" = "Sla demontage tussen tests over (alleen voor foutopsporing)."; +"reset_cli_sessions" = "CLI-sessies opnieuw instellen"; +"section_error_simulation" = "Foutsimulatie"; +"error_simulation_caption" = "Injecteer een valse foutmelding in de menukaart voor het testen van de lay-out."; +"set_menu_error" = "Menufout instellen"; +"clear_menu_error" = "Menufout wissen"; +"set_cost_error" = "Fout bij instellen van kosten"; +"clear_cost_error" = "Duidelijke kostenfout"; +"section_cli_paths" = "CLI-paden"; +"cli_paths_caption" = "Opgelost Codex binaire en PATH-lagen; opstarten login PATH vastleggen (korte time-out)."; +"codex_binary" = "Codex binair"; +"claude_binary" = "Claude binair"; +"effective_path" = "Effectief PAD"; +"unavailable" = "Niet beschikbaar"; +"login_shell_path" = "Login shell PATH (opstartopname)"; +"cleared" = "Gewist."; +"no_fetch_attempts" = "Nog geen ophaalpogingen."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe kan menubalk-apps blokkeren in Systeeminstellingen → Menubalk → Toestaan ​​in de menubalk. CodexBar is actief, maar macOS verbergt mogelijk het pictogram ervan. Open de Menubalkinstellingen en schakel CodexBar in."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatisch"; +"metric_pref_primary" = "Primair"; +"metric_pref_secondary" = "Secundair"; +"metric_pref_tertiary" = "Tertiair"; +"metric_pref_extra_usage" = "Extra gebruik"; +"metric_pref_average" = "Gemiddeld"; + +/* Display modes */ +"display_mode_percent" = "Procent"; +"display_mode_pace" = "Tempo"; +"display_mode_both" = "Beide"; +"display_mode_percent_desc" = "Toon resterend/gebruikt percentage (bijvoorbeeld 45%)"; +"display_mode_pace_desc" = "Toon tempo-indicator (bijv. +5%)"; +"display_mode_both_desc" = "Toon zowel percentage als tempo (bijvoorbeeld 45% · +5%)"; + +/* Provider status */ +"status_operational" = "Operationeel"; +"status_partial_outage" = "Gedeeltelijke uitval"; +"status_major_outage" = "Grote storing"; +"status_critical_issue" = "Kritieke kwestie"; +"status_maintenance" = "Onderhoud"; +"status_unknown" = "Status onbekend"; + +/* Refresh frequency */ +"refresh_manual" = "Handmatig"; +"refresh_1min" = "1 min"; +"refresh_2min" = "2 minuten"; +"refresh_5min" = "5 min"; +"refresh_15min" = "15 minuten"; +"refresh_30min" = "30 min"; + +/* Additional keys */ +"not_found" = "Niet gevonden"; + +/* Cost estimation */ +"cost_header_estimated" = "Kosten (geschat)"; +"cost_estimate_hint" = "Geschat op basis van lokale logboeken · kan afwijken van uw factuur"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Geen JetBrains IDE met AI Assistant gedetecteerd. Installeer een JetBrains IDE en schakel AI Assistant in."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API-token niet geconfigureerd. Stel de omgevingsvariabele OPENROUTER_API_KEY in of configureer deze in Instellingen."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API-token niet gevonden. Stel apiKey in ~/.codexbar/config.json of Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Ontbrekende DeepSeek API-sleutel."; +"%@ is unavailable in the current environment." = "%@ is niet beschikbaar in de huidige omgeving."; +"All Systems Operational" = "Alle systemen operationeel"; +"Last 30 days" = "Laatste 30 dagen"; +"Last 30 days:" = "Afgelopen 30 dagen:"; +"This month" = "Deze maand"; +"Store multiple OpenAI API keys." = "Bewaar meerdere OpenAI API-sleutels."; +"Admin API key" = "Beheerder API-sleutel"; +"Open billing" = "Facturering openen"; +"Google accounts" = "Google-accounts"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Bewaar meerdere Antigravity Google OAuth-accounts voor snel schakelen."; +"Add Google Account" = "Google-account toevoegen"; +"Open Token Plan" = "Tokenplan openen"; +"Text Generation" = "Tekst genereren"; +"Text to Speech" = "Tekst naar spraak"; +"Music Generation" = "Muziek generatie"; +"Image Generation" = "Beeldgeneratie"; +"No local data found" = "Geen lokale gegevens gevonden"; +"Credits unavailable; keep Codex running to refresh." = "Tegoeden niet beschikbaar; laat Codex draaien om te vernieuwen."; +"No available fetch strategy for minimax." = "Geen beschikbare ophaalstrategie voor minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Geen Cursorsessie gevonden. Meld u aan bij cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX of Edge Canary. Als u Safari gebruikt, verleen CodexBar volledige schijftoegang in Systeeminstellingen ▸ Privacy en beveiliging. U kunt zich ook aanmelden bij Cursor via het CodexBar-menu (Account toevoegen/wisselen)."; +"No OpenCode session cookies found in browsers." = "Er zijn geen OpenCode-sessiecookies gevonden in browsers."; +"No available fetch strategy for %@." = "Geen beschikbare ophaalstrategie voor %@."; +"Today" = "Vandaag"; +"Today tokens" = "Vandaag tokens"; +"30d cost" = "30d kosten"; +"30d tokens" = "30d-tokens"; +"Latest tokens" = "Nieuwste tokens"; +"Top model" = "Topmodel"; +"Storage" = "Opslag"; +"Add Account..." = "Account toevoegen..."; +"Usage Dashboard" = "Gebruiksdashboard"; +"Status Page" = "Statuspagina"; +"Settings..." = "Instellingen..."; +"About CodexBar" = "Over CodexBar"; +"Quit" = "Stoppen"; +"Last %d day" = "Afgelopen %d dag"; +"Last %d days" = "Afgelopen %d dagen"; +"%@ tokens" = "%@ tokens"; +"Latest billing day" = "Laatste factuurdag"; +"Latest billing day (%@)" = "Laatste factuurdag (%@)"; +"%@ left" = "%@ over"; +"Resets %@" = "Reset %@"; +"Resets in %@" = "Resetten over %@"; +"Resets now" = "Wordt nu gereset"; +"Lasts until reset" = "Gaat mee tot reset"; +"Updated %@" = "Bijgewerkt %@"; +"Updated %@h ago" = "%@u geleden bijgewerkt"; +"Updated %@m ago" = "%@m geleden bijgewerkt"; +"Updated just now" = "Zojuist bijgewerkt"; +"Projected empty in %@" = "Geprojecteerd leeg in %@"; +"Runs out in %@" = "Loopt af over %@"; +"Pace: %@" = "Tempo: %@"; +"Pace: %@ · %@" = "Tempo: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% uitlooprisico"; +"%d%% in deficit" = "%d%% tekort"; +"%d%% in reserve" = "%d%% in reserve"; +"usage_percent_suffix_left" = "over"; +"usage_percent_suffix_used" = "gebruikt"; +"Store multiple DeepSeek API keys." = "Bewaar meerdere DeepSeek API-sleutels."; +"This week" = "Deze week"; +"Week" = "Week"; +"Month" = "Maand"; +"Models" = "Modellen"; +"24h tokens" = "24-uurs tokens"; +"Latest hour" = "Laatste uur"; +"Peak hour" = "Piekuur"; +"Top method" = "Topmethode"; +"30d cash" = "30d contant"; +"30d billing history from MiniMax web session" = "30d factuurgeschiedenis van MiniMax-websessie"; +"AWS Cost Explorer billing can lag." = "De facturering van AWS Cost Explorer kan vertraging oplopen."; +"Rate limit: %d / %@" = "Tarieflimiet: %d / %@"; +"Key remaining" = "Sleutel resterend"; +"No limit set for the API key" = "Er is geen limiet ingesteld voor de API-sleutel"; +"API key limit unavailable right now" = "API-sleutellimiet momenteel niet beschikbaar"; +"This month: %@ tokens" = "Deze maand: %@ tokens"; +"No utilization data yet." = "Nog geen gebruiksgegevens."; +"No %@ utilization data yet." = "Nog geen %@ gebruiksgegevens."; +"%@: %@%% used" = "%@: %@%% gebruikt"; +"%dd" = "%dd"; +"today" = "Vandaag"; +"just now" = "zojuist"; +"On pace" = "Op tempo"; +"Runs out now" = "Is nu op"; +"Projected empty now" = "Nu leeg geprojecteerd"; +"Switch Account..." = "Account wisselen..."; +"Update ready, restart now?" = "Update klaar, nu opnieuw opstarten?"; +"Daily" = "Dagelijks"; +"Hourly Tokens" = "Tokens per uur"; +"No data" = "Geen gegevens"; +"No usage breakdown data available." = "Er zijn geen gebruiksgegevens beschikbaar."; + +"Today: %@ · %@ tokens" = "Vandaag: %@ · %@ tokens"; +"Today: %@" = "Vandaag: %@"; +"Today: %@ tokens" = "Vandaag: %@ tokens"; +"Last 30 days: %@ · %@ tokens" = "Afgelopen 30 dagen: %@ · %@ tokens"; +"Last 30 days: %@" = "Afgelopen 30 dagen: %@"; +"Est. total (30d): %@" = "Geschat. totaal (30d): %@"; +"Est. total (%@): %@" = "Geschat. totaal (%@): %@"; +"Hover a bar for details" = "Beweeg een balk voor details"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"No providers selected for Overview." = "Geen aanbieders geselecteerd voor Overzicht."; +"No overview data available." = "Geen overzichtsgegevens beschikbaar."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto gebruikt eerst de lokale IDE API en vervolgens Google OAuth wanneer de IDE wordt gesloten."; +"Login with Google" = "Inloggen met Google"; + +/* Popup panels */ +"No usage configured." = "Geen gebruik geconfigureerd."; +"Quota" = "Quotum"; +"tokens" = "tokens"; +"requests" = "verzoeken"; +"Latest" = "Nieuwste"; +"Monthly" = "Maandelijks"; +"Sonnet" = "Sonnet"; +"Overages" = "Overschotten"; +"Activity" = "Activiteit"; +"Copied" = "Gekopieerd"; +"Copy error" = "Kopieerfout"; +"Copy path" = "Kopieer pad"; +"Extra usage spent" = "Extra gebruik besteed"; +"Credits remaining" = "Resterende tegoeden"; +"Using CLI fallback" = "CLI-fallback gebruiken"; +"Balance updates in near-real time (up to 5 min lag)" = "Saldo-updates in bijna realtime (tot 5 minuten vertraging)"; +"Daily billing data finalizes at 07:00 UTC" = "De dagelijkse factureringsgegevens worden afgerond om 07:00 UTC"; +"%@ of %@ credits left" = "%@ van %@ credits over"; +"%@ of %@ bonus credits left" = "Er zijn nog %@ van %@ bonuscredits over"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ resterend)"; +"%@/%@ left" = "%@/%@ over"; +"Gemini Flash" = "Tweeling flits"; +"Regenerates %@" = "Regenereert %@"; +"used after next regen" = "gebruikt na de volgende regen"; +"after next regen" = "na de volgende regen"; +"Near full" = "Bijna vol"; +"Full in ~1 regen" = "Volledig in ~1 regeneratie"; +"Full in ~%.0f regens" = "Volledig in ~%.0f regens"; +"Overage usage" = "Overmatig gebruik"; +"Overage cost" = "Overschrijdingskosten"; +"credits" = "tegoeden"; +"Zen balance" = "Zen-balans"; +"API spend" = "API-uitgaven"; +"Extra usage" = "Extra gebruik"; +"Quota usage" = "Quotumgebruik"; +"%.0f%% used" = "%.0f%% gebruikt"; +"Usage history (today)" = "Gebruiksgeschiedenis (vandaag)"; +"Usage history (%d days)" = "Gebruiksgeschiedenis (%d dagen)"; +"%d percent remaining" = "%d procent resterend"; +"Unknown" = "Onbekend"; +"stale data" = "verouderde gegevens"; +"No credits history data." = "Geen kredietgeschiedenisgegevens."; +"No credits history data available." = "Er zijn geen kredietgeschiedenisgegevens beschikbaar."; +"Credits history chart" = "Creditgeschiedenisgrafiek"; +"%d days of credits data" = "%d dagen aan kredietgegevens"; +"Usage breakdown chart" = "Uitsplitsingsschema voor gebruik"; +"%d days of usage data across %d services" = "%d dagen aan gebruiksgegevens voor %d services"; +"Cost history chart" = "Kostengeschiedenisgrafiek"; +"%d days of cost data" = "%d dagen aan kostengegevens"; +"Plan utilization chart" = "Plan gebruiksgrafiek"; +"%d utilization samples" = "%d gebruiksvoorbeelden"; +"Hourly Usage" = "Uurgebruik"; +"Usage remaining" = "Resterend gebruik"; +"Usage used" = "Gebruik gebruikt"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "API-sleutel geverifieerd. Ollama stelt geen Cloud-quotumlimieten bloot via de API."; +"Last 30 days: %@ tokens" = "Afgelopen 30 dagen: %@ tokens"; +"7d spend" = "7d uitgaven"; +"30d spend" = "30d uitgaven"; +"Cache read" = "Cache lezen"; +"Claude Admin API 30 day spend trend" = "Claude Admin API bestedingstrend van 30 dagen"; +"OpenRouter API key spend trend" = "Trend van uitgaven voor OpenRouter API-sleutels"; +"z.ai hourly token trend" = "z.ai tokentrend per uur"; +"MiniMax 30 day token usage trend" = "MiniMax 30 dagen tokengebruikstrend"; +"Today cash" = "Vandaag contant"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30 dagen tokengebruikstrend"; +"cache-hit input" = "cache-hit-invoer"; +"cache-miss input" = "cache-miss invoer"; +"output" = "uitgang"; +"Requests" = "Verzoeken"; +"Reported by OpenAI Admin API organization usage." = "Gerapporteerd door het gebruik van de OpenAI Admin API-organisatie."; +"Reported by Mistral billing usage." = "Gerapporteerd door Mistral-factureringsgebruik."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Voeg accounts toe via GitHub OAuth Device Flow op de geselecteerde host."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Slaat elk ingelogd Google-account op voor snel schakelen tussen anti-zwaartekracht. Gebruikt Antigravity.app OAuth indien beschikbaar, of ANTIGRAVITY_OAUTH_CLIENT_ID en ANTIGRAVITY_OAUTH_CLIENT_SECRET als overschrijving."; +"Manual cleanup: past sessions" = "Handmatig opschonen: afgelopen sessies"; +"Clearing removes past resume, continue, and rewind history." = "Door te wissen wordt de geschiedenis van het hervatten, doorgaan en terugspoelen uit het verleden verwijderd."; +"Manual cleanup: file checkpoints" = "Handmatig opschonen: bestandscontrolepunten"; +"Clearing removes checkpoint restore data for previous edits." = "Door het wissen worden de controlepuntherstelgegevens van eerdere bewerkingen verwijderd."; +"Manual cleanup: saved plans" = "Handmatig opschonen: opgeslagen plannen"; +"Clearing removes old plan-mode files." = "Door te wissen worden oude bestanden in de planmodus verwijderd."; +"Manual cleanup: debug logs" = "Handmatig opschonen: foutopsporingslogboeken"; +"Clearing removes past debug logs." = "Door te wissen worden eerdere foutopsporingslogboeken verwijderd."; +"Manual cleanup: attachment cache" = "Handmatig opschonen: bijlagecache"; +"Clearing removes cached large pastes or attached images." = "Door te wissen worden in de cache opgeslagen grote pasta's of bijgevoegde afbeeldingen verwijderd."; +"Manual cleanup: session metadata" = "Handmatig opschonen: sessiemetagegevens"; +"Clearing removes per-session environment metadata." = "Door te wissen worden de metagegevens van de omgeving per sessie verwijderd."; +"Manual cleanup: shell snapshots" = "Handmatig opschonen: shell-snapshots"; +"Clearing removes leftover runtime shell snapshot files." = "Door het wissen worden de overgebleven runtime shell-snapshotbestanden verwijderd."; +"Manual cleanup: legacy todos" = "Handmatig opschonen: oude taken"; +"Clearing removes legacy per-session task lists." = "Door het wissen worden verouderde takenlijsten per sessie verwijderd."; +"Manual cleanup: sessions" = "Handmatig opschonen: sessies"; +"Clearing removes past Codex session history." = "Door te wissen wordt de geschiedenis van de Codex-sessie verwijderd."; +"Manual cleanup: archived sessions" = "Handmatig opschonen: gearchiveerde sessies"; +"Clearing removes archived Codex session history." = "Door te wissen wordt de gearchiveerde Codex-sessiegeschiedenis verwijderd."; +"Manual cleanup: cache" = "Handmatig opschonen: cache"; +"Clearing removes provider-owned cached data." = "Door te wissen worden gegevens in de cache van de provider verwijderd."; +"Manual cleanup: logs" = "Handmatig opschonen: logboeken"; +"Clearing removes local diagnostic logs." = "Door te wissen worden lokale diagnostische logboeken verwijderd."; +"Manual cleanup: file history" = "Handmatig opschonen: bestandsgeschiedenis"; +"Clearing removes local edit checkpoint history." = "Door te wissen wordt de geschiedenis van de lokale bewerkingscontrolepunten verwijderd."; +"Manual cleanup: temporary data" = "Handmatig opschonen: tijdelijke gegevens"; +"Clearing removes local temporary provider data." = "Door te wissen worden lokale tijdelijke providergegevens verwijderd."; +"Total: %@" = "Totaal: %@"; +"%d more items" = "%d meer artikelen"; +"Cleanup ideas" = "Opruimideeën"; +"%d unreadable item(s) skipped" = "%d onleesbare item(s) overgeslagen"; + +"API key limit" = "API-sleutellimiet"; +"Auth" = "Aut"; +"Auto" = "Auto"; +"Disabled — no recent data" = "Uitgeschakeld — geen recente gegevens"; +"Limits not available" = "Limieten niet beschikbaar"; +"No usage yet" = "Nog geen gebruik"; +"Not fetched yet" = "Nog niet opgehaald"; +"Refreshing" = "Verfrissend"; +"Session" = "Sessie"; +"Source" = "Bron"; +"State" = "Staat"; +"Unavailable" = "Niet beschikbaar"; +"Weekly" = "Wekelijks"; +"not detected" = "niet gedetecteerd"; +"Estimated from local Codex logs for the selected account." = "Geschat op basis van lokale Codex-logboeken voor het geselecteerde account."; +"minimax_usage_amount_format" = "Gebruik: %@ / %@"; +"minimax_used_percent_format" = "Gebruikt %@"; +"minimax_service_text_generation" = "Tekst genereren"; +"minimax_service_text_to_speech" = "Tekst naar spraak"; +"minimax_service_music_generation" = "Muziek generatie"; +"minimax_service_image_generation" = "Beeldgeneratie"; +"minimax_service_lyrics_generation" = "Songtekst generatie"; +"minimax_service_coding_plan_vlm" = "Codeerplan VLM"; +"minimax_service_coding_plan_search" = "Coderingsplan zoeken"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ wacht op toestemming"; +"%@ requests" = "%@ verzoeken"; +"%@: %@ credits" = "%@: %@ tegoeden"; +"30d requests" = "30d verzoeken"; +"4 days" = "4 dagen"; +"5 days" = "5 dagen"; +"7 days" = "7 dagen"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API-sleutel verifieert Ollama Cloud-toegang; cookies stellen nog steeds quotumlimieten bloot."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS-toegangssleutel-ID. Kan ook worden ingesteld met AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "AWS-regio. Kan ook worden ingesteld met AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Geheime toegangssleutel van AWS. Kan ook worden ingesteld met AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "Toegangssleutel-ID"; +"Add Account" = "Account toevoegen"; +"Adding Account…" = "Account toevoegen…"; +"Antigravity login failed" = "Antigravity-aanmelding mislukt"; +"Antigravity login timed out" = "Er is een time-out opgetreden bij het inloggen op anti-zwaartekracht"; +"Auth source" = "Authenticatiebron"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importeert automatisch Chrome-browsercookies van Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatische import van windsurfsessiegegevens uit de Chromium-browser localStorage."; +"Automatic imports browser cookies from Bailian." = "Importeert automatisch browsercookies van Bailian."; +"Automatically imports browser cookies." = "Importeert automatisch browsercookies."; +"Automatically imports browser session cookies." = "Importeert automatisch browsersessiecookies."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI-implementatienaam. AZURE_OPENAI_DEPLOYMENT_NAME wordt ook ondersteund."; +"Azure OpenAI key" = "Azure OpenAI-sleutel"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI-resource-eindpunt. AZURE_OPENAI_ENDPOINT wordt ook ondersteund."; +"Base URL" = "Basis-URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Basis-URL voor de LLM-API-Key-Proxy-instantie."; +"Browser cookies" = "Browser-cookies"; +"Cap end" = "Dop uiteinde"; +"Cap start" = "Kap begin"; +"Capacity End" = "Einde capaciteit"; +"Capacity Start" = "Capaciteit begin"; +"Changelog" = "Wijzigingslog"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Kies de Moonshot/Kimi API-host voor internationale accounts of accounts op het vasteland van China."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar kan een systeemaccount dat is aangemeld met alleen een API-sleutelconfiguratie niet vervangen."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar kon de opgeslagen verificatie voor dat account niet vinden. Authenticeer het opnieuw en probeer het opnieuw."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar kan de beheerde accountopslag niet lezen. Herstel de winkel voordat u een ander account toevoegt."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar kan de opgeslagen verificatie voor dat account niet lezen. Authenticeer het opnieuw en probeer het opnieuw."; +"CodexBar could not read the current system account on this Mac." = "CodexBar kon het huidige systeemaccount op deze Mac niet lezen."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar kon de live Codex-authenticatie op deze Mac niet vervangen."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar kon het huidige systeemaccount niet veilig behouden voordat hij overschakelde."; +"CodexBar could not save the current system account before switching." = "CodexBar kon het huidige systeemaccount niet opslaan voordat er werd overgeschakeld."; +"CodexBar could not update managed account storage." = "CodexBar kan de beheerde accountopslag niet updaten."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar heeft een ander beheerd account gevonden dat al gebruikmaakt van het huidige systeemaccount. Los het dubbele account op voordat u overstapt."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar vraagt ​​macOS-sleutelhanger om “%@”, zodat browsercookies kunnen worden gedecodeerd en uw account kan worden geverifieerd. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar zal macOS Keychain om het Claude Code OAuth-token vragen, zodat het uw Claude-gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om je Amp-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw Augment-cookie-header vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw Claude-cookieheader vragen, zodat deze het webgebruik van Claude kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw Cursor-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw Factory-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw GitHub Copilot-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar vraagt ​​macOS Keychain om je Kimi K2 API-sleutel, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar vraagt ​​macOS Keychain om je Kimi-authenticatietoken, zodat het gebruik kan worden opgehaald. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw MiniMax API-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw MiniMax-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar zal macOS Keychain om uw OpenAI-cookieheader vragen, zodat deze extra's op het Codex-dashboard kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw OpenCode-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar vraagt ​​macOS-sleutelhanger om uw synthetische API-sleutel, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw z.ai API-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"Could not open Cursor login in your browser." = "Kan Cursor-login niet openen in uw browser."; +"Could not open browser for Antigravity" = "Kan browser voor Antigravity niet openen"; +"Credits used" = "Gebruikte tegoeden"; +"Day" = "Dag"; +"Deployment" = "Inzet"; +"Drag to reorder" = "Sleep om de volgorde te wijzigen"; +"Endpoint" = "Eindpunt"; +"Enterprise host" = "Enterprise-host"; +"Extra usage balance: %@" = "Extra gebruikssaldo: %@"; +"Keychain Access Required" = "Toegang tot sleutelhanger vereist"; +"Kiro menu bar value" = "Waarde van de Kiro-menubalk"; +"Label" = "Label"; +"No organizations loaded. Click Refresh after setting your API key." = "Er zijn geen organisaties geladen. Klik op Vernieuwen nadat u uw API-sleutel hebt ingesteld."; +"No output captured." = "Geen uitvoer vastgelegd."; +"No system account" = "Geen systeemaccount"; +"Oasis-Token" = "Oasis-token"; +"Open Augment (Log Out & Back In)" = "Augment openen (uitloggen en weer inloggen)"; +"Open Codebuff Dashboard" = "Open het Codebuff-dashboard"; +"Open Command Code Settings" = "Open de opdrachtcode-instellingen"; +"Open Crof dashboard" = "Open het Crof-dashboard"; +"Open Manus" = "Manus openen"; +"Open MiMo Balance" = "Open MiMo-saldo"; +"Open Moonshot Console" = "Open de Moonshot-console"; +"Open Ollama API Keys" = "Open Ollama API-sleutels"; +"Open StepFun Platform" = "Open het StepFun-platform"; +"Open T3 Chat Settings" = "Open T3 Chat-instellingen"; +"Open Volcengine Ark Console" = "Open de Volcengine Ark-console"; +"Open legacy provider docs" = "Open oude providerdocumenten"; +"Open projects" = "Openstaande projecten"; +"Open this URL manually to continue login:\n\n%@" = "Open deze URL handmatig om door te gaan met inloggen:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Optionele organisatie-ID voor accounts die zijn gekoppeld aan meerdere Anthropic-organisaties."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Optioneel. Is van toepassing op de geconfigureerde Admin API-sleutel; geselecteerde tokenaccounts nemen OPENAI_PROJECT_ID niet over."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Optioneel. Voer uw GitHub Enterprise-host in, bijvoorbeeld octocorp.ghe.com. Laat leeg voor github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Optioneel. Laat dit veld leeg om projecten te ontdekken en samen te voegen die zichtbaar zijn voor de API-sleutel."; +"Org ID (optional)" = "Organisatie-ID (optioneel)"; +"Organizations" = "Organisaties"; +"Password" = "Wachtwoord"; +"%@ authentication is disabled." = "%@-authenticatie is uitgeschakeld."; +"%@ cookies are disabled." = "%@ cookies zijn uitgeschakeld."; +"%@ web API access is disabled." = "%@ web-API-toegang is uitgeschakeld."; +"Disable %@ dashboard cookie usage." = "Schakel het gebruik van %@ dashboardcookies uit."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "De sleutelhangertoegang is uitgeschakeld in Geavanceerd, dus het importeren van browsercookies is niet beschikbaar."; +"Manually paste an %@ from a browser session." = "Plak handmatig een %@ uit een browsersessie."; +"Paste a Cookie header captured from %@." = "Plak een Cookie-header vastgelegd van %@."; +"Paste a Cookie header from %@." = "Plak een Cookie-header van %@."; +"Paste a Cookie header or cURL capture from %@." = "Plak een cookie-header of cURL-opname uit %@."; +"Paste a Cookie header or full cURL capture from %@." = "Plak een cookiekoptekst of volledige krulopname uit %@."; +"Paste a Cookie or Authorization header from %@." = "Plak een cookie- of autorisatiekop van %@."; +"Paste a full cookie header or the %@ value." = "Plak een volledige cookiekop of de waarde %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Plak een Cookie-header of volledige cURL-opname uit de T3 Chat-instellingen."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Plak de Cookie-header uit een verzoek naar admin.mistral.ai. Moet een ory_session_* cookie bevatten."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Plak de Oasis-Token uit een ingelogde browsersessie op platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Plak de %@ JSON-bundel uit %@."; +"Paste the %@ value or a full Cookie header." = "Plak de waarde %@ of een volledige Cookie-header."; +"Personal account" = "Persoonlijk account"; +"Project ID" = "Project-ID"; +"Re-auth" = "Opnieuw verifiëren"; +"Re-authenticating…" = "Opnieuw authenticeren…"; +"Refresh Session" = "Sessie vernieuwen"; +"Refresh organizations" = "Vernieuw organisaties"; +"Region" = "Regio"; +"Reload" = "Herladen"; +"Reorder" = "Opnieuw ordenen"; +"Secret access key" = "Geheime toegangssleutel"; +"Series" = "Serie"; +"Service" = "Dienst"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Toon of verberg Kiro-credits, percentages of beide naast het menubalkpictogram."; +"Show usage for organizations you belong to. Personal account is always shown." = "Toon gebruik voor organisaties waartoe u behoort. Persoonlijk account wordt altijd getoond."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Meld u aan bij cursor.com in uw browser en vernieuw vervolgens Cursor in CodexBar."; +"Simulated error text" = "Gesimuleerde fouttekst"; +"StepFun platform account (phone number or email)." = "StepFun-platformaccount (telefoonnummer of e-mailadres)."; +"Stored in ~/.codexbar/config.json." = "Opgeslagen in ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Opgeslagen in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY wordt ook ondersteund."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Opgeslagen in ~/.codexbar/config.json. Gebruik Moonshot / Kimi API voor de officiële Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Opgeslagen in ~/.codexbar/config.json. Haal uw API-sleutel op via de Volcengine Ark-console."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Opgeslagen in ~/.codexbar/config.json. Haal uw sleutel op via Ollama-instellingen."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Opgeslagen in ~/.codexbar/config.json. Haal uw sleutel op via console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Opgeslagen in ~/.codexbar/config.json. Haal uw sleutel op via elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Opgeslagen in ~/.codexbar/config.json. Haal uw sleutel op via openrouter.ai/settings/keys en stel daar een sleutelbestedingslimiet in om het bijhouden van API-sleutelquota in te schakelen."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Opgeslagen in ~/.codexbar/config.json. Open in Warp Instellingen > Platform > API-sleutels en maak er vervolgens een."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Opgeslagen in ~/.codexbar/config.json. Voor statistieken is toegang tot Groq Enterprise Prometheus vereist."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Opgeslagen in ~/.codexbar/config.json. OPENAI_ADMIN_KEY heeft de voorkeur; OPENAI_API_KEY werkt nog steeds."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Opgeslagen in ~/.codexbar/config.json. Vereist een Anthropic Admin API-sleutel."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Opgeslagen in ~/.codexbar/config.json. Gebruikt voor /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Opgeslagen in ~/.codexbar/config.json. Je kunt ook CODEBUFF_API_KEY opgeven of CodexBar ~/.config/manicode/credentials.json laten lezen (gemaakt door `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Opgeslagen in ~/.codexbar/config.json. U kunt ook CROF_API_KEY opgeven."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Opgeslagen in ~/.codexbar/config.json. U kunt ook KILO_API_KEY of ~/.local/share/kilo/auth.json (kilo.access) opgeven."; +"T3 Chat cookie" = "T3 Chat-cookie"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Dat account is niet langer beschikbaar in CodexBar. Vernieuw de accountlijst en probeer het opnieuw."; +"The browser login did not complete in time. Try Antigravity login again." = "De browseraanmelding is niet op tijd voltooid. Probeer Antigravity-login opnieuw."; +"Timed out waiting for Cursor login. %@" = "Er is een time-out opgetreden tijdens het wachten op cursoraanmelding. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Er is een time-out opgetreden tijdens het wachten op cursoraanmelding. %@ Laatste fout: %@"; +"Today requests" = "Vandaag verzoeken"; +"Total (30d): %@ credits" = "Totaal (30d): %@ credits"; +"Username" = "Gebruikersnaam"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Gebruikt gebruikersnaam + wachtwoord om in te loggen en automatisch een Oasis-Token te verkrijgen."; +"Uses username + password to login and obtain an %@ automatically." = "Gebruikt gebruikersnaam + wachtwoord om in te loggen en automatisch een %@ te verkrijgen."; +"Utilization End" = "Gebruik einde"; +"Utilization Start" = "Gebruik starten"; +"Verbosity" = "Breedsprakigheid"; +"Windsurf session JSON bundle" = "Windsurfsessie JSON-bundel"; +"Workspace ID" = "Werkruimte-ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "Uw StepFun-platformwachtwoord. Wordt gebruikt om in te loggen en een sessietoken te verkrijgen."; +"claude /login exited with status %d." = "claude /login afgesloten met status %d."; +"codex login exited with status %d." = "codex login afgesloten met status %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nof plak een cURL-opname vanuit het Abacus AI-dashboard"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nof plak de __Secure-next-auth.session-token-waarde"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nof plak de kimi-auth-tokenwaarde"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nof plak alleen de session_id-waarde"; +"Clear" = "Duidelijk"; +"No matching providers" = "Geen overeenkomende aanbieders"; +"Search providers" = "Zoekaanbieders"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 96786d8c5..0dad37eb9 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "Chinês simplificado"; "language_chinese_traditional" = "Chinês tradicional"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; "language_french" = "Francês"; "language_ukrainian" = "Ucraniano"; "start_at_login_title" = "Iniciar ao fazer login"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 90eeb5540..0ba7cff51 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -400,6 +400,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; "language_swedish" = "Svenska"; "language_french" = "Franska"; "language_ukrainian" = "Ukrainska"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 181d001c2..43905634c 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -406,6 +406,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; "language_french" = "法语"; "language_ukrainian" = "乌克兰语"; "start_at_login_title" = "开机启动"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 8f4acc26d..d9f0205ed 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -406,6 +406,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; "language_french" = "法語"; "language_ukrainian" = "烏克蘭語"; "start_at_login_title" = "登入時啟動"; From b44218352c43d45f8356407dfce185e9f4996c53 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:03:13 +0800 Subject: [PATCH 49/93] feat: add native Vietnamese localization support Adds Vietnamese localization and completes language-label catalog parity across localized resources. --- CHANGELOG.md | 2 + Sources/CodexBar/PreferencesGeneralPane.swift | 2 + .../Resources/ca.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 1067 +++++++++++++++++ .../zh-Hans.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 3 + .../LocalizationLanguageCatalogTests.swift | 38 + 14 files changed, 1133 insertions(+) create mode 100644 Sources/CodexBar/Resources/vi.lproj/Localizable.strings diff --git a/CHANGELOG.md b/CHANGELOG.md index e9bee6dba..f2b3280fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Localization: add French as a selectable app language (#1241). Thanks @Yuxin-Qiao! - Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! +- Localization: add Dutch as a selectable app language (#1252). Thanks @Yuxin-Qiao! +- Localization: add Vietnamese as a selectable app language (#1247). Thanks @Yuxin-Qiao! ### Fixed - Claude: remove transient ClaudeProbe session artifacts after CLI usage polls so background refreshes no longer fill Claude Code project history with CodexBar `/usage` sessions (#1301). Thanks @LPFchan and @matthewod11-stack! diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 335da5695..0854310f9 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -14,6 +14,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case french = "fr" case dutch = "nl" case ukrainian = "uk" + case vietnamese = "vi" var id: String { self.rawValue @@ -32,6 +33,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .french: L("language_french") case .dutch: L("language_dutch") case .ukrainian: L("language_ukrainian") + case .vietnamese: L("language_vietnamese") } } } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 3b195aa70..4004139a5 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Suec"; "language_dutch" = "Nederlands"; "language_french" = "Francès"; "language_ukrainian" = "Ucraïnès"; @@ -919,3 +920,5 @@ "Clear" = "Esborra"; "No matching providers" = "No hi ha proveïdors coincidents"; "Search providers" = "Cerca proveïdors"; + +"language_vietnamese" = "Vietnamita"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 97919f1d4..5fada87c8 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1063,3 +1063,5 @@ "Clear" = "Clear"; "No matching providers" = "No matching providers"; "Search providers" = "Search providers"; + +"language_vietnamese" = "Vietnamese"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index cfaad5d04..8da82dde5 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Sueco"; "language_dutch" = "Nederlands"; "language_french" = "Francés"; "language_ukrainian" = "Ucraniano"; @@ -919,3 +920,5 @@ "Clear" = "Borrar"; "No matching providers" = "No hay proveedores coincidentes"; "Search providers" = "Buscar proveedores"; + +"language_vietnamese" = "Vietnamita"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 8b487362f..8932f84a7 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -401,7 +401,9 @@ "language_portuguese_brazilian" = "Portugais (Brésil)"; "language_swedish" = "Suédois"; "language_french" = "Français"; +"language_dutch" = "Néerlandais"; "language_ukrainian" = "Ukrainien"; +"language_vietnamese" = "Vietnamien"; "start_at_login_title" = "Lancer à l'ouverture de session"; "start_at_login_subtitle" = "Ouvre automatiquement CodexBar au démarrage de votre Mac."; "show_cost_summary" = "Afficher le récapitulatif des coûts"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index f8ad29ba1..6034738e2 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -403,6 +403,7 @@ "language_french" = "Frans"; "language_ukrainian" = "Oekraïens"; "language_swedish" = "Zweeds"; +"language_vietnamese" = "Vietnamees"; "start_at_login_title" = "Begin bij Inloggen"; "start_at_login_subtitle" = "Opent automatisch CodexBar wanneer u uw Mac start."; "show_cost_summary" = "Kostenoverzicht weergeven"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0dad37eb9..93304a894 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_chinese_simplified" = "Chinês simplificado"; "language_chinese_traditional" = "Chinês tradicional"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Sueco"; "language_dutch" = "Nederlands"; "language_french" = "Francês"; "language_ukrainian" = "Ucraniano"; @@ -1062,3 +1063,5 @@ "Clear" = "Limpar"; "No matching providers" = "Nenhum provedor correspondente"; "Search providers" = "Buscar provedores"; + +"language_vietnamese" = "Vietnamita"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 0ba7cff51..b777ad418 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1061,3 +1061,5 @@ "Clear" = "Rensa"; "No matching providers" = "Inga matchande leverantörer"; "Search providers" = "Sök leverantörer"; + +"language_vietnamese" = "Vietnamesiska"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 21ec193d3..fbcadb84a 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -401,7 +401,9 @@ "language_portuguese_brazilian" = "Português (Brasil)"; "language_swedish" = "Svenska"; "language_french" = "Français"; +"language_dutch" = "Нідерландська"; "language_ukrainian" = "Українська"; +"language_vietnamese" = "В'єтнамська"; "start_at_login_title" = "Почніть із входу"; "start_at_login_subtitle" = "Автоматично відкриває CodexBar під час запуску Mac."; "show_cost_summary" = "Показати підсумок витрат"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings new file mode 100644 index 000000000..48749b29c --- /dev/null +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -0,0 +1,1067 @@ +/* English localization for CodexBar (base/fallback) */ + +" providers" = "nhà cung cấp"; +"(System)" = "(Hệ thống)"; +"30d" = "30d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình này hoàn tất trước khi thêm"; +"API key" = "API khóa"; +"API region" = "API khu vực"; +"API token" = "API token"; +"API tokens" = "API mã thông báo"; +"About" = "Giới thiệu về"; +"Account" = "Tài khoản"; +"Accounts" = "Tài khoản"; +"Accounts subtitle" = "Phụ đề tài khoản"; +"Active" = "Đang hoạt động"; +"Add" = "Thêm"; +"Add Workspace" = "Thêm không gian làm việc"; +"Advanced" = "Nâng cao"; +"All" = "Tất cả"; +"Always allow prompts" = "Luôn cho phép lời nhắc"; +"Animation pattern" = "Mẫu hoạt ảnh"; +"Antigravity login is managed in the app" = "Đăng nhập chống trọng lực được quản lý trong ứng dụng"; +"Applies only to the Security.framework OAuth keychain reader." = "Chỉ áp dụng cho trình đọc chuỗi khóa Security.framework OAuth."; +"Auto falls back to the next source if the preferred one fails." = "Tự động quay lại nguồn tiếp theo nếu nguồn ưa thích không thành công."; +"Auto uses API first, then falls back to CLI on auth failures." = "Tự động sử dụng API trước, sau đó quay lại CLI khi xác thực không thành công."; +"Auto-detect" = "Tự động phát hiện"; +"Auto-refresh is off; use the menu's Refresh command." = "Tự động làm mới bị tắt; sử dụng lệnh Làm mới của menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Tự động làm mới: hàng giờ · Thời gian chờ: 10 phút"; +"Automatic" = "Tự động"; +"Automatic imports browser cookies and WorkOS tokens." = "Tự động nhập cookie trình duyệt và mã thông báo WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Tự động nhập cookie trình duyệt và mã thông báo lưu trữ cục bộ."; +"Automatic imports browser cookies for dashboard extras." = "Tự động nhập cookie trình duyệt cho các tính năng bổ sung của trang tổng quan."; +"Automatic imports browser cookies for the web API." = "Tự động nhập cookie trình duyệt cho web API ."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Tự động nhập cookie trình duyệt từ Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Tự động nhập cookie trình duyệt từ admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Tự động nhập cookie trình duyệt từ opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Tự động nhập cookie trình duyệt hoặc các phiên được lưu trữ."; +"Automatic imports browser cookies." = "Tự động nhập cookie trình duyệt."; +"Automatically imports browser session cookie." = "Tự động nhập cookie phiên trình duyệt."; +"Automatically opens CodexBar when you start your Mac." = "Tự động mở CodexBar khi bạn khởi động máy Mac."; +"Automation" = "Tự động hóa"; +"Average (\\(label1) + \\(label2))" = "Trung bình (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Trung bình (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Tránh Keychain lời nhắc"; +"Balance" = "Số dư"; +"Battery Saver" = "Trình tiết kiệm pin"; +"Bordered" = "Có viền"; +"Build" = "Xây dựng"; +"Built \\(buildTimestamp)" = "Đã xây dựng \\(buildTimestamp)"; +"Buy Credits..." = "Mua tín dụng..."; +"Buy Credits…" = "Mua tín dụng... Đường dẫn"; +"CLI paths" = "CLI"; +"CLI sessions" = "CLI phiên"; +"Caches" = "Bộ nhớ đệm"; +"Cancel" = "Hủy"; +"Check for Updates…" = "Kiểm tra cập nhật…"; +"Check for updates automatically" = "Tự động kiểm tra cập nhật"; +"Check if you like your agents having some fun up there." = "Kiểm tra xem bạn có muốn nhân viên của mình vui vẻ ở đó không."; +"Check provider status" = "Kiểm tra Nhà cung cấp trạng thái"; +"Choose Codex workspace" = "Chọn không gian làm việc Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Chọn máy chủ MiniMax (toàn cầu .io hoặc Trung Quốc đại lục .com)."; +"Choose up to " = "Chọn tối đa"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Chọn tối đa nhà cung cấp \\(Self.maxOverviewProviders"; +"Choose up to \\(count) providers" = "Chọn tối đa \\(count) nhà cung cấp"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Chọn nội dung sẽ hiển thị trong thanh menu (Tốc độ hiển thị Mức sử dụng so với dự kiến)."; +"Choose which Codex account CodexBar should follow." = "Chọn tài khoản Codex mà CodexBar sẽ tuân theo."; +"Choose which window drives the menu bar percent." = "Chọn cửa sổ nào điều khiển phần trăm thanh menu."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI không tìm thấy"; +"Claude binary" = "Claude nhị phân"; +"Claude cookies" = "Claude cookie"; +"Claude login failed" = "Claude đăng nhập không thành công"; +"Claude login timed out" = "Claude hết thời gian đăng nhập"; +"Close" = "Đóng"; +"Code review" = "Xem xét mã"; +"Codex CLI not found" = "Không tìm thấy Codex CLI"; +"Codex account login already running" = "Đăng nhập tài khoản Codex đã chạy"; +"Codex binary" = "Codex nhị phân"; +"Codex login failed" = "Đăng nhập Codex không thành công"; +"Codex login timed out" = "Đăng nhập Codex đã hết thời gian chờ"; +"CodexBar Lifecycle Keepalive" = "CodexBar Lifecycle Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar không thể hiển thị biểu tượng thanh menu"; +"CodexBar could not read managed account storage. " = "CodexBar không thể đọc bộ nhớ tài khoản được quản lý."; +"Configure…" = "Định cấu hình…"; +"Connected" = "Đã kết nối"; +"Controls how much detail is logged." = "Kiểm soát lượng chi tiết được ghi lại."; +"Cookie header" = "Tiêu đề cookie"; +"Cookie source" = "Nguồn cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nhoặc dán bản chụp cURL từ bảng điều khiển Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nhoặc dán giá trị kimi-auth token"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Chi phí"; +"Could not add Codex account" = "Không thể thêm tài khoản Codex"; +"Could not open Terminal for Gemini" = "Không thể mở Terminal cho Gemini"; +"Could not start claude /login" = "Không thể bắt đầu claude /đăng nhập"; +"Could not start codex login" = "Không thể bắt đầu đăng nhập codex"; +"Could not switch system account" = "Không thể không chuyển đổi tài khoản hệ thống"; +"Credits" = "Tín dụng"; +"Credits history" = "Lịch sử tín dụng"; +"Cursor login failed" = "Đăng nhập con trỏ không thành công"; +"Custom" = "Tùy chỉnh"; +"Custom Path" = "Đường dẫn tùy chỉnh"; +"Daily Routines" = "Quy trình hàng ngày"; +"Debug" = "Gỡ lỗi"; +"Default" = "Mặc định"; +"Disable Keychain access" = "Tắt quyền truy cập Keychain"; +"Disabled" = "Đã tắt"; +"Dismiss" = "Loại bỏ"; +"Disconnected" = "Đã ngắt kết nối"; +"Display" = "Hiển thị"; +"Display mode" = "Chế độ hiển thị"; +"Display reset times as absolute clock values instead of countdowns." = "Hiển thị Đặt lại thời gian dưới dạng giá trị đồng hồ tuyệt đối thay vì đếm ngược."; +"Done" = "Xong"; +"Effective PATH" = "PATH hiệu quả"; +"Email" = "Email"; +"Enable Merge Icons to configure Overview tab providers." = "Bật Biểu tượng Hợp nhất để định cấu hình nhà cung cấp tab Tổng quan."; +"Enable file logging" = "Bật ghi nhật ký tệp"; +"Enabled" = "Đã bật"; +"Error" = "Lỗi"; +"Error simulation" = "Mô phỏng lỗi"; +"Expose troubleshooting tools in the Debug tab." = "Hiển thị các công cụ khắc phục sự cố trong tab Gỡ lỗi."; +"Failed" = "Không thành công"; +"False" = "Sai"; +"Fetch strategy attempts" = "Thử tìm nạp chiến lược"; +"Fetching" = "Đang tìm nạp"; +"Field" = "Trường"; +"Field subtitle" = "Tiêu đề phụ của trường"; +"Finish the current managed account change before switching the system account." = "Hoàn tất thay đổi tài khoản được quản lý hiện tại trước khi chuyển đổi tài khoản hệ thống."; +"Force animation on next refresh" = "Không tìm thấy hoạt ảnh bắt buộc trong lần làm mới tiếp theo"; +"Gateway region" = "Vùng cổng"; +"Gemini CLI not found" = "Gemini CLI"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini /Phản trọng lực, xuất hiện các sự cố trong biểu tượng và menu."; +"General" = "Chung"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Đăng nhập GitHub Copilot"; +"GitHub Login" = "Đăng nhập GitHub"; +"Hide details" = "Ẩn chi tiết"; +"Hide personal information" = "Ẩn thông tin cá nhân"; +"Historical tracking" = "Theo dõi lịch sử"; +"How often CodexBar polls providers in the background." = "Tần suất CodexBar thăm dò ý kiến ​​các nhà cung cấp trong nền."; +"Inactive" = "Không hoạt động"; +"Install CLI" = "Cài đặt CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Cài đặt Claude CLI (npm i -g @anthropic-ai/claude-code) và thử lại."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Cài đặt Codex CLI (npm i -g @openai/codex) và thử lại."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Cài đặt Gemini CLI (npm i -g @google/gemini-cli) và thử lại."; +"JetBrains AI is ready" = "JetBrains AI đã sẵn sàng"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Duy trì CLI phiên hoạt động"; +"Keyboard shortcut" = "Phím tắt"; +"Keychain access" = "Keychain truy cập"; +"Keychain prompt policy" = "Keychain chính sách nhắc"; +"Last \\(name) fetch failed:" = "Tìm nạp \\(name) lần cuối không thành công:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Lần tìm nạp \\(self.store.metadata(for: self. Nhà cung cấp ) .displayName) lần cuối không thành công:"; +"Last attempt" = "Lần thử cuối cùng"; +"Link" = "Liên kết"; +"Loading animations" = "Đang tải hình động"; +"Loading…" = "Đang tải…"; +"Local" = "Cục bộ"; +"Logging" = "Ghi nhật ký"; +"Login failed" = "Đăng nhập không thành công"; +"Login shell PATH (startup capture)" = "Shell đăng nhập PATH (chụp khởi động)"; +"Login timed out" = "Đã hết thời gian đăng nhập"; +"MCP details" = "Chi tiết MCP"; +"Managed Codex accounts unavailable" = "Tài khoản Codex được quản lý không khả dụng"; +"Managed account storage is unreadable. Live account access is still available, " = "Không thể đọc được bộ nhớ tài khoản được quản lý. Quyền truy cập tài khoản trực tiếp vẫn khả dụng,"; +"Manual" = "Thủ công"; +"May your tokens never run out—keep agent limits in view." = "Cầu mong mã thông báo của bạn không bao giờ hết—luôn theo dõi giới hạn đại lý."; +"Menu bar" = "thanh menu"; +"Menu bar auto-shows the provider closest to its rate limit." = "thanh menu tự động hiển thị Nhà cung cấp gần nhất với giới hạn tốc độ của nó."; +"Menu bar metric" = "thanh menu số liệu"; +"Menu bar shows percent" = "thanh menu hiển thị phần trăm"; +"Menu content" = "Nội dung menu"; +"Merge Icons" = "Hợp nhất các biểu tượng"; +"Never prompt" = "Không bao giờ nhắc"; +"No" = "Không có"; +"No Codex accounts detected yet." = "Chưa phát hiện thấy tài khoản Codex nào."; +"No JetBrains IDE detected" = "Không phát hiện thấy JetBrains IDE"; +"No cost history data." = "Không có dữ liệu lịch sử chi phí."; +"No data available" = "Không có dữ liệu"; +"No data yet" = "Chưa có dữ liệu"; +"No enabled providers available for Overview." = "Không có nhà cung cấp nào được bật cho Tổng quan."; +"No providers selected" = "Chưa có nhà cung cấp nào được chọn"; +"No token accounts yet." = "Chưa có tài khoản token."; +"No usage breakdown data." = "Không có dữ liệu phân tích Mức sử dụng."; +"None" = "Không có"; +"Notifications" = "Thông báo"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Thông báo khi phiên 5 giờ Hạn mức đạt 0% và khi nó trở thành"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Ẩn địa chỉ email trong thanh menu và giao diện người dùng menu."; +"Off" = "Tắt"; +"Offline" = "Ngoại tuyến"; +"On" = "Bật"; +"Online" = "Trực tuyến"; +"Only on user action" = "Chỉ khi hành động của người dùng"; +"Open" = "Mở"; +"Open API Keys" = "Mở API Phím"; +"Open Amp Settings" = "Mở Amp Cài đặt"; +"Open Antigravity to sign in, then refresh CodexBar." = "Mở Chống trọng lực để đăng nhập, sau đó làm mới CodexBar ."; +"Open Browser" = "Mở trình duyệt"; +"Open Coding Plan" = "Mở kế hoạch mã hóa"; +"Open Console" = "Mở bảng điều khiển"; +"Open Dashboard" = "Mở bảng điều khiển"; +"Open Mistral Admin" = "Mở quản trị viên Mistral"; +"Open Menu Bar Settings" = "Mở thanh menu Cài đặt"; +"Open Ollama Settings" = "Mở Ollama Cài đặt"; +"Open Terminal" = "Mở Terminal"; +"Open Usage Page" = "Mở Mức sử dụng Trang"; +"Open Warp API Key Guide" = "Hướng dẫn chính về Open Warp API"; +"Open menu" = "Mở menu"; +"Open token file" = "Mở token tệp"; +"OpenAI cookies" = "OpenAI cookie"; +"OpenAI web extras" = "OpenAI phần bổ sung web"; +"Option A" = "Tùy chọn A"; +"Option B" = "Tùy chọn B"; +"Optional override if workspace lookup fails." = "Ghi đè tùy chọn nếu tra cứu không gian làm việc không thành công."; +"Options" = "Tùy chọn"; +"Override auto-detection with a custom IDE base path" = "Ghi đè tính năng tự động phát hiện bằng đường dẫn cơ sở IDE tùy chỉnh"; +"Overview" = "Tổng quan"; +"Overview rows always follow provider order." = "Các hàng tổng quan luôn tuân theo thứ tự Nhà cung cấp."; +"Overview tab providers" = "Nhà cung cấp tab tổng quan"; +"Paste API key…" = "Dán API key…"; +"Paste API token…" = "Dán API token …"; +"Paste key…" = "Dán khóa…"; +"Paste sessionKey or OAuth token…" = "Dán sessionKey hoặc OAuth token …"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Dán tiêu đề Cookie từ yêu cầu tới admin.mistral.ai."; +"Paste token…" = "Dán token …"; +"Personal" = "Cá nhân"; +"Picker" = "Bộ chọn"; +"Picker subtitle" = "Tiêu đề phụ của bộ chọn"; +"Placeholder" = "Trình giữ chỗ"; +"Plan" = "Kế hoạch"; +"Play full-screen confetti when weekly usage resets." = "Phát hoa giấy toàn màn hình khi đặt lại Mức sử dụng hàng tuần."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Cuộc thăm dò ý kiến ​​OpenAI / Claude trang trạng thái và Google Không gian làm việc dành cho"; +"Prevents any Keychain access while enabled." = "Ngăn chặn mọi quyền truy cập Keychain khi được bật."; +"Primary (API key limit)" = "Chính ( API giới hạn khóa)"; +"Primary (\\(label))" = "Chính (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Chính (\\(metadata.sessionLabel))"; +"Probe logs" = "Nhật ký thăm dò"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Thanh tiến trình sẽ lấp đầy khi bạn sử dụng Hạn mức (thay vì hiển thị phần còn lại)."; +"Provider" = "Nhà cung cấp"; +"Providers" = "Nhà cung cấp"; +"Quit CodexBar" = "Thoát CodexBar"; +"Random (default)" = "Ngẫu nhiên (mặc định)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Đọc nhật ký Mức sử dụng cục bộ. Hiển thị hôm nay + cửa sổ lịch sử đã chọn trong menu."; +"Refresh" = "Làm mới"; +"Refresh cadence" = "Nhịp làm mới"; +"Remote" = "Từ xa"; +"Remove" = "Xóa"; +"Remove Codex account?" = "Xóa tài khoản Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Xóa \\(account.email) khỏi CodexBar ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Xóa \\(email) khỏi CodexBar ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; +"Remove selected account" = "Xóa tài khoản đã chọn"; +"Replace critter bars with provider branding icons and a percentage." = "Thay thế các thanh sinh vật bằng Nhà cung cấp biểu tượng nhãn hiệu và tỷ lệ phần trăm."; +"Replay selected animation" = "Phát lại hoạt ảnh đã chọn"; +"Requires authentication via GitHub Device Flow." = "Yêu cầu xác thực thông qua GitHub Device Flow."; +"Resets: \\(reset)" = "Đặt lại: \\( Đặt lại )"; +"Rolling five-hour limit" = "Giới hạn 5 giờ liên tục"; +"Search hourly" = "Tìm kiếm hàng giờ"; +"Secondary (\\(label))" = "Phụ (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Phụ (\\(metadata.weeklyLabel))"; +"Select a provider" = "Chọn một Nhà cung cấp"; +"Select the IDE to monitor" = "Chọn IDE để giám sát"; +"Session quota notifications" = "Thông báo phiên Hạn mức"; +"Session tokens" = "Mã thông báo phiên"; +"Settings" = "Cài đặt"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Hiển thị Tín dụng Codex và Claude Các phần Mức sử dụng bổ sung trong menu."; +"Show Debug Settings" = "Hiển thị gỡ lỗi Cài đặt"; +"Show all token accounts" = "Hiển thị tất cả token tài khoản"; +"Show cost summary" = "Hiển thị tóm tắt chi phí"; +"Show credits + extra usage" = "Hiển thị tín dụng + bổ sung Mức sử dụng"; +"Show details" = "Hiển thị chi tiết"; +"Show most-used provider" = "Hiển thị Nhà cung cấp"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "được sử dụng nhiều nhất Hiển thị các biểu tượng Nhà cung cấp trong trình chuyển đổi (nếu không thì hiển thị dòng tiến trình hàng tuần)."; +"Show reset time as clock" = "Hiển thị thời gian Đặt lại dưới dạng đồng hồ"; +"Show usage as used" = "Hiển thị Mức sử dụng như đã sử dụng"; +"Sign in via button below" = "Đăng nhập bằng nút bên dưới"; +"Skip teardown between probes (debug-only)." = "Bỏ qua việc phân tích giữa các thăm dò (chỉ dành cho gỡ lỗi)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Xếp chồng các tài khoản token trong menu (nếu không sẽ hiển thị thanh trình chuyển đổi tài khoản)."; +"Start at Login" = "Bắt đầu khi đăng nhập"; +"Status" = "Trạng thái"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Lưu trữ Claude cookie sessionKey hoặc OAuth mã thông báo truy cập."; +"Store multiple Abacus AI Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie Abacus AI."; +"Store multiple Augment Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie tăng cường."; +"Store multiple Cursor Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie con trỏ."; +"Store multiple Factory Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie gốc."; +"Store multiple MiniMax Cookie headers." = "Lưu trữ nhiều tiêu đề cookie MiniMax."; +"Store multiple Mistral Cookie headers." = "Lưu trữ nhiều tiêu đề Mistral Cookie."; +"Store multiple Ollama Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie Ollama."; +"Store multiple OpenCode Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie OpenCode Go."; +"Stored in the CodexBar config file." = "Được lưu trữ trong tệp cấu hình CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Được lưu trữ trong ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Được lưu trữ trong ~/.codexbar/config.json. Tạo một cái tại kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Được lưu trữ trong ~/.codexbar/config.json. Dán khóa từ bảng điều khiển Tổng hợp."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Được lưu trữ trong ~/.codexbar/config.json. Dán khóa Kế hoạch mã hóa API của bạn từ Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Được lưu trữ trong ~/.codexbar/config.json. Dán khóa MiniMax API của bạn."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Được lưu trữ trong ~/.codexbar/config.json. Bạn cũng có thể cung cấp lịch sử KILO_API_KEY hoặc"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stores Codex cục bộ Mức sử dụng (8 tuần) để cá nhân hóa dự đoán Pace."; +"Subscription Utilization" = "Gói đăng ký Mức sử dụng"; +"Surprise me" = "Làm tôi ngạc nhiên"; +"Switcher shows icons" = "Trình chuyển đổi hiển thị các biểu tượng"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI tới /usr/local/bin và /opt/homebrew/bin dưới dạng codexbar."; +"System" = "Hệ thống"; +"Temporarily shows the loading animation after the next refresh." = "Tạm thời hiển thị hoạt ảnh đang tải sau lần làm mới tiếp theo."; +"Tertiary (\\(label))" = "Cấp ba (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Cấp ba (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Tài khoản Codex mặc định trên máy Mac này."; +"Toggle" = "Chuyển đổi"; +"Toggle subtitle" = "Chuyển đổi phụ đề"; +"Token" = "token"; +"Trigger the menu bar menu from anywhere." = "Kích hoạt menu thanh menu từ mọi nơi."; +"True" = "Đúng"; +"Twitter" = "Twitter"; +"Unsupported" = "Không được hỗ trợ"; +"Update Channel" = "Kênh cập nhật"; +"Updated" = "Đã cập nhật"; +"Updates unavailable in this build." = "Các bản cập nhật không có sẵn trong bản dựng này."; +"Usage" = "Mức sử dụng"; +"Usage breakdown" = "Mức sử dụng sự cố"; +"Usage history (30 days)" = "Mức sử dụng lịch sử"; +"Usage source" = "Mức sử dụng nguồn"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Sử dụng BigModel cho các điểm cuối ở Trung Quốc đại lục (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Sử dụng một biểu tượng thanh menu duy nhất với trình chuyển đổi Nhà cung cấp."; +"Use international or China mainland console gateways for quota fetches." = "Sử dụng cổng bảng điều khiển quốc tế hoặc Trung Quốc đại lục để tìm nạp Hạn mức."; +"Version" = "Phiên bản"; +"Version \\(self.versionString)" = "Phiên bản \\(self.versionString)"; +"Version \\(version)" = "Phiên bản \\(version)"; +"Version \\(versionString)" = "Phiên bản \\(versionString)"; +"Vertex AI Login" = "Vertex AI Đăng nhập"; +"Wait for the current managed Codex login to finish before adding another account." = "Đợi quá trình đăng nhập Codex được quản lý hiện tại hoàn tất trước khi thêm tài khoản khác."; +"Waiting for Authentication..." = "Đang chờ xác thực..."; +"Website" = "Trang web"; +"Weekly limit confetti" = "Hoa giấy giới hạn hàng tuần"; +"Weekly token limit" = "token giới hạn"; +"Weekly usage" = "Hàng tuần Mức sử dụng"; +"Weekly usage unavailable for this account." = "Hàng tuần Mức sử dụng không khả dụng cho tài khoản này."; +"Window: \\(window)" = "Cửa sổ: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Ghi nhật ký vào \\(self.fileLogPath) để gỡ lỗi."; +"Yes" = "Có"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\( Mức sử dụng )"; +"\\(name): \\(truncated)" = "\\(name): \\(cắt ngắn)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): đang tìm nạp…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): lần thử cuối cùng \\(when)"; +"\\(name): no data yet" = "\\(name): chưa có dữ liệu"; +"\\(name): unsupported" = "\\(name): không được hỗ trợ"; +"all browsers" = "tất cả các trình duyệt"; +"available again." = "khả dụng trở lại."; +"built_format" = "Đã xây dựng %@"; +"copilot_complete_in_browser" = "Hoàn tất đăng nhập vào trình duyệt của bạn."; +"copilot_device_code" = "Mã thiết bị được sao chép vào bảng nhớ tạm: %1$@\n\nXác minh tại: %2$@"; +"copilot_device_code_copied" = "Đã sao chép mã thiết bị."; +"copilot_verify_at" = "Xác minh tại %@"; +"copilot_waiting_text" = "Hoàn tất đăng nhập vào trình duyệt của bạn.\nCửa sổ này tự động đóng khi quá trình đăng nhập hoàn tất."; +"copilot_window_closes_auto" = "Cửa sổ này tự động đóng khi quá trình đăng nhập hoàn tất."; +"cost_status_error" = "%1$@ : %2$@"; +"cost_status_fetching" = "%1$@ : đang tìm nạp… %2$@"; +"cost_status_last_attempt" = "%1$@ : lần thử cuối cùng %2$@"; +"cost_status_no_data" = "%@ : không có dữ liệu chưa"; +"cost_status_snapshot" = "%1$@ : %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@ : không được hỗ trợ"; +"credits_remaining" = "Tín dụng: %@"; +"cursor_on_demand" = "Theo yêu cầu: %@"; +"cursor_on_demand_with_limit" = "Theo yêu cầu: %1$@ / %2$@"; +"extra_usage_format" = "Mức sử dụng bổ sung : %1$@ / %2$@"; +"jetbrains_detected_generate" = "Đã phát hiện: %@ . Sử dụng trợ lý AI một lần để tạo dữ liệu Hạn mức, sau đó làm mới CodexBar ."; +"jetbrains_detected_select" = "Đã phát hiện: %@ . Chọn IDE ưa thích của bạn trong Cài đặt , sau đó làm mới CodexBar ."; +"last_fetch_failed_with_provider" = "Tìm nạp %@ lần cuối không thành công:"; +"last_spend" = "Chi tiêu lần cuối: %@"; +"mcp_model_usage" = "%1$@ : %2$@"; +"mcp_resets" = "Đặt lại: %@"; +"mcp_window" = "Cửa sổ: %@"; +"metric_average" = "Trung bình ( %1$@ + %2$@ )"; +"metric_primary" = "Sơ cấp ( %@ )"; +"metric_secondary" = "Trung học ( %@ )"; +"metric_tertiary" = "Cấp ba ( %@ )"; +"multiple_workspaces_found" = "CodexBar đã tìm thấy nhiều không gian làm việc cho %@ . Vui lòng chọn không gian làm việc để thêm."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Chọn tối đa %@ nhà cung cấp"; +"remove_account_message" = "Xóa %@ khỏi CodexBar ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; +"version_format" = "Phiên bản %@"; +"vertex_ai_login_instructions" = "Để theo dõi Vertex AI Mức sử dụng , hãy xác thực bằng Google Cloud.\n\n1. Mở Terminal\n2. Chạy: gcloud auth application-default login\n3. Làm theo lời nhắc của trình duyệt để đăng nhập\n4. Đặt dự án của bạn: gcloud config set project PROJECT_ID\n\nMở Thiết bị đầu cuối bây giờ?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "ID không gian làm việc được đặt nhưng chỉ có mã mở, opencodego và deepgram hỗ trợ ID không gian làm việc."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Giấy phép MIT."; + +/* General Pane */ +"section_system" = "Hệ thống"; +"section_usage" = "Mức sử dụng"; +"section_automation" = "Tự động hóa"; +"language_title" = "Ngôn ngữ"; +"language_subtitle" = "Thay đổi ngôn ngữ hiển thị. Yêu cầu khởi động lại ứng dụng để có hiệu lực đầy đủ."; +"language_system" = "Hệ thống"; +"language_english" = "Tiếng Anh"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Svenska"; +"language_french" = "Tiếng Pháp"; +"language_dutch" = "Tiếng Hà Lan"; +"language_ukrainian" = "Tiếng Ukraina"; +"start_at_login_title" = "Bắt đầu khi đăng nhập"; +"start_at_login_subtitle" = "Tự động mở CodexBar khi bạn khởi động máy Mac."; +"show_cost_summary" = "Hiển thị tóm tắt chi phí"; +"show_cost_summary_subtitle" = "Đọc nhật ký Mức sử dụng cục bộ. Hiển thị hôm nay + cửa sổ lịch sử đã chọn trong menu."; +"cost_history_days_title" = "Cửa sổ lịch sử: %d ngày"; +"cost_auto_refresh_info" = "Tự động làm mới: hàng giờ · Thời gian chờ: 10 phút"; +"refresh_cadence_title" = "Nhịp làm mới"; +"refresh_cadence_subtitle" = "Tần suất CodexBar thăm dò ý kiến ​​các nhà cung cấp trong nền."; +"manual_refresh_hint" = "Tính năng tự động làm mới bị tắt; sử dụng lệnh Làm mới của menu."; +"check_provider_status_title" = "Kiểm tra Nhà cung cấp trạng thái"; +"check_provider_status_subtitle" = "Thăm dò ý kiến ​​OpenAI / Claude các trang trạng thái và Google Không gian làm việc dành cho Gemini /AntiGravity, phát hiện các sự cố trong biểu tượng và menu."; +"session_quota_notifications_title" = "Thông báo về phiên Hạn mức"; +"session_quota_notifications_subtitle" = "Thông báo khi phiên 5 giờ Hạn mức đạt 0% và khi phiên này khả dụng trở lại."; +"quota_warning_notifications_title" = "Hạn mức thông báo cảnh báo"; +"quota_warning_notifications_subtitle" = "Cảnh báo khi phiên hoặc Hạn mức còn lại hàng tuần vượt qua ngưỡng được định cấu hình."; +"quota_warnings_title" = "Hạn mức cảnh báo"; +"quota_warning_session" = "phiên"; +"quota_warning_session_capitalized" = "Phiên"; +"quota_warning_weekly" = "hàng tuần"; +"quota_warning_weekly_capitalized" = "Hàng tuần"; +"quota_warning_notification_title" = "%1$@ %2$@ Hạn mức thấp"; +"quota_warning_notification_body" = "%1$@ left. Reached your %2$d%% %3$@ warning threshold."; +"quota_warning_notification_body_with_account" = "Tài khoản %1$@ . Còn lại %2$@. Đã đạt đến ngưỡng cảnh báo %3$d %% %4$@ của bạn."; +"session_depleted_notification_title" = "%@ phiên đã hết"; +"session_depleted_notification_body" = "còn lại 0%. Sẽ thông báo khi có lại."; +"session_restored_notification_title" = "%@ phiên đã được khôi phục"; +"session_restored_notification_body" = "Phiên Hạn mức đã có sẵn trở lại."; +"quota_warning_warn_at" = "Cảnh báo ở"; +"quota_warning_global_threshold_subtitle" = "Tỷ lệ phần trăm còn lại cho phiên và thời lượng hàng tuần trừ khi Nhà cung cấp ghi đè chúng."; +"quota_warning_sound" = "Phát âm thanh thông báo"; +"quota_warning_provider_inherits" = "Sử dụng cảnh báo Hạn mức toàn cầu Cài đặt trừ khi một cửa sổ được tùy chỉnh tại đây."; +"quota_warning_customize_thresholds" = "Tùy chỉnh %@ ngưỡng"; +"quota_warning_enable_warnings" = "Bật %@ cảnh báo"; +"quota_warning_window_warn_at" = "%@ cảnh báo lúc"; +"quota_warning_off" = "Tắt"; +"quota_warning_inherited" = "Đã kế thừa: %@"; +"quota_warning_depleted_only" = "chỉ đã cạn"; +"quota_warning_upper" = "Thượng"; +"quota_warning_lower" = "Hạ"; +"apply" = "Áp dụng"; +"quit_app" = "Thoát CodexBar"; + +/* Tab titles */ +"tab_general" = "Chung"; +"tab_providers" = "Nhà cung cấp"; +"tab_display" = "Hiển thị"; +"tab_advanced" = "Nâng cao"; +"tab_about" = "Giới thiệu về"; +"tab_debug" = "Gỡ lỗi"; + +/* Providers Pane */ +"select_a_provider" = "Chọn một Nhà cung cấp"; +"cancel" = "Hủy"; +"last_fetch_failed" = "lần tìm nạp cuối cùng không thành công"; +"usage_not_fetched_yet" = "Mức sử dụng chưa được tìm nạp"; +"managed_account_storage_unreadable" = "Bộ nhớ tài khoản được quản lý không thể đọc được. Quyền truy cập tài khoản trực tiếp vẫn khả dụng nhưng các hành động thêm, xác thực lại và xóa được quản lý sẽ bị vô hiệu hóa cho đến khi có thể khôi phục được cửa hàng."; +"remove_codex_account_title" = "Xóa tài khoản Codex?"; +"remove" = "Xóa"; +"managed_login_already_running" = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình hoàn tất trước khi thêm hoặc xác thực lại tài khoản khác."; +"managed_login_failed" = "Đăng nhập Codex được quản lý không hoàn tất. Xác minh rằng `codex --version` hoạt động trong Terminal. Nếu macOS đã chặn hoặc di chuyển `codex` vào Thùng rác, hãy xóa các bản cài đặt trùng lặp cũ, chạy `npm install -g --include=Optional @openai/codex@latest`, sau đó thử lại."; +"codex_login_output" = "đầu ra đăng nhập codex:"; +"managed_login_missing_email" = "Đăng nhập Codex đã hoàn tất nhưng không có email tài khoản. Hãy thử lại sau khi xác nhận tài khoản đã đăng nhập đầy đủ."; +"login_success_notification_title" = "%@ đăng nhập thành công"; +"login_success_notification_body" = "Bạn có thể quay lại ứng dụng; xác thực xong."; +"workspace_selection_cancelled" = "CodexBar đã tìm thấy nhiều không gian làm việc nhưng không có không gian làm việc nào được chọn."; +"unsafe_managed_home" = "CodexBar từ chối sửa đổi đường dẫn chính được quản lý không mong muốn: %@"; +"menu_bar_metric_title" = "thanh menu chỉ số"; +"menu_bar_metric_subtitle" = "Chọn cửa sổ nào thúc đẩy phần trăm thanh menu."; +"menu_bar_metric_subtitle_deepseek" = "Hiển thị số dư DeepSeek trong thanh menu ."; +"menu_bar_metric_subtitle_moonshot" = "Hiển thị số dư Moonshot / Kimi API trong thanh menu ."; +"menu_bar_metric_subtitle_mistral" = "Hiển thị mức chi tiêu API của Mistral trong tháng hiện tại trong thanh menu ."; +"menu_bar_metric_subtitle_kimik2" = "Hiển thị Kimi K2 API -các khoản tín dụng chính trong thanh menu ."; +"automatic" = "Tự động"; +"primary_api_key_limit" = "Chính ( API giới hạn khóa)"; + +/* Display Pane */ +"section_menu_bar" = "thanh menu"; +"merge_icons_title" = "Hợp nhất các biểu tượng"; +"merge_icons_subtitle" = "Sử dụng một biểu tượng thanh menu duy nhất với trình chuyển đổi Nhà cung cấp."; +"switcher_shows_icons_title" = "Trình chuyển đổi hiển thị các biểu tượng"; +"switcher_shows_icons_subtitle" = "Hiển thị các biểu tượng Nhà cung cấp trong trình chuyển đổi (nếu không thì hiển thị dòng tiến trình hàng tuần)."; +"show_most_used_provider_title" = "Hiển thị Nhà cung cấp"; +"show_most_used_provider_subtitle" = "thanh menu được sử dụng nhiều nhất tự động hiển thị Nhà cung cấp gần nhất với giới hạn tốc độ của nó."; +"menu_bar_shows_percent_title" = "thanh menu hiển thị phần trăm"; +"menu_bar_shows_percent_subtitle" = "Thay thế thanh sinh vật bằng Nhà cung cấp biểu tượng thương hiệu và tỷ lệ phần trăm."; +"display_mode_title" = "Chế độ hiển thị"; +"display_mode_subtitle" = "Chọn nội dung sẽ hiển thị trong thanh menu (Tốc độ hiển thị Mức sử dụng so với dự kiến)."; +"section_menu_content" = "Nội dung menu"; +"show_usage_as_used_title" = "Hiển thị Mức sử dụng dưới dạng đã sử dụng"; +"show_usage_as_used_subtitle" = "Thanh tiến trình sẽ lấp đầy khi bạn sử dụng Hạn mức (thay vì hiển thị phần còn lại)."; +"show_quota_warning_markers_title" = "Hiển thị Hạn mức dấu cảnh báo"; +"show_quota_warning_markers_subtitle" = "Vẽ dấu kiểm ngưỡng trên thanh Mức sử dụng khi cảnh báo Hạn mức được định cấu hình."; +"weekly_progress_work_days_title" = "Tiến độ ngày làm việc hàng tuần"; +"weekly_progress_work_days_subtitle" = "Vẽ các dấu kiểm ranh giới ngày trên các thanh Mức sử dụng hàng tuần."; +"show_reset_time_as_clock_title" = "Hiển thị Đặt lại thời gian dưới dạng đồng hồ"; +"show_reset_time_as_clock_subtitle" = "Hiển thị Đặt lại thời gian dưới dạng giá trị đồng hồ tuyệt đối thay vì đếm ngược."; +"show_provider_changelog_links_title" = "Hiển thị Nhà cung cấp liên kết nhật ký thay đổi"; +"show_provider_changelog_links_subtitle" = "Thêm liên kết ghi chú phát hành cho các nhà cung cấp được hỗ trợ CLI vào menu."; +"show_credits_extra_usage_title" = "Hiển thị tín dụng + phần Mức sử dụng"; +"show_credits_extra_usage_subtitle" = "Hiển thị tín dụng Codex và Claude Các phần Mức sử dụng bổ sung trong menu."; +"show_all_token_accounts_title" = "Hiển thị tất cả các tài khoản token"; +"show_all_token_accounts_subtitle" = "Xếp chồng các tài khoản token trong menu (nếu không thì hiển thị thanh trình chuyển đổi tài khoản)."; +"multi_account_layout_title" = "Bố cục nhiều tài khoản"; +"multi_account_layout_subtitle" = "Chọn thẻ tài khoản chuyển đổi phân đoạn hoặc thẻ tài khoản xếp chồng."; +"multi_account_layout_segmented" = "Được phân đoạn"; +"multi_account_layout_stacked" = "Xếp chồng"; +"overview_tab_providers_title" = "Nhà cung cấp tab tổng quan"; +"configure" = "Định cấu hình…"; +"overview_enable_merge_icons_hint" = "Bật Hợp nhất Biểu tượng để định cấu hình nhà cung cấp tab Tổng quan."; +"overview_no_providers_hint" = "Không có nhà cung cấp nào được bật cho phần Tổng quan."; +"overview_rows_follow_order" = "Các hàng tổng quan luôn tuân theo thứ tự Nhà cung cấp."; +"overview_no_providers_selected" = "Không có nhà cung cấp nào được chọn"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Phím tắt"; +"open_menu_shortcut_title" = "Mở menu"; +"open_menu_shortcut_subtitle" = "Kích hoạt menu thanh menu từ mọi nơi."; +"install_cli" = "Cài đặt CLI"; +"install_cli_subtitle" = "Liên kết tượng trưng CodexBarCLI tới /usr/local/bin và /opt/homebrew/bin dưới dạng codexbar."; +"cli_not_found" = "Không tìm thấy CodexBarCLI trong gói ứng dụng."; +"no_writable_bin_dirs" = "Không tìm thấy thư mục bin có thể ghi."; +"show_debug_settings_title" = "Hiển thị gỡ lỗi Cài đặt"; +"show_debug_settings_subtitle" = "Hiển thị các công cụ khắc phục sự cố trong tab Gỡ lỗi."; +"surprise_me_title" = "Làm tôi ngạc nhiên"; +"surprise_me_subtitle" = "Kiểm tra xem bạn có thích các đại lý của mình vui vẻ ở đó không."; +"weekly_limit_confetti_title" = "Hoa giấy giới hạn hàng tuần"; +"weekly_limit_confetti_subtitle" = "Phát hoa giấy toàn màn hình khi đặt lại Mức sử dụng hàng tuần."; +"hide_personal_info_title" = "Ẩn thông tin cá nhân"; +"hide_personal_info_subtitle" = "Địa chỉ email tối nghĩa trong thanh menu và giao diện người dùng menu."; +"show_provider_storage_usage_title" = "Hiển thị Nhà cung cấp bộ nhớ Mức sử dụng"; +"show_provider_storage_usage_subtitle" = "Hiển thị ổ đĩa cục bộ Mức sử dụng trong menu. Quét các đường dẫn thuộc quyền sở hữu của Nhà cung cấp đã biết ở chế độ nền."; +"section_keychain_access" = "Keychain quyền truy cập"; +"keychain_access_caption" = "Tắt tất cả Keychain đọc và ghi. Hãy sử dụng tùy chọn này nếu macOS liên tục nhắc về ' Chrome /Brave/Edge Safe Storage' ngay cả sau khi nhấp vào Luôn cho phép. Nhập cookie trình duyệt không khả dụng khi được bật; dán tiêu đề Cookie theo cách thủ công vào Nhà cung cấp. Claude /Codex OAuth thông qua CLI vẫn hoạt động."; +"disable_keychain_access_title" = "Vô hiệu hóa quyền truy cập Keychain"; +"disable_keychain_access_subtitle" = "Ngăn chặn mọi quyền truy cập Keychain khi được bật."; + +/* About Pane */ +"about_tagline" = "Cầu mong mã thông báo của bạn không bao giờ hết—giữ giới hạn đại lý trong tầm mắt."; +"link_github" = "GitHub"; +"link_website" = "Trang web"; +"link_twitter" = "Twitter"; +"link_email" = "Email"; +"check_updates_auto" = "Tự động kiểm tra các bản cập nhật"; +"update_channel" = "Kênh cập nhật"; +"check_for_updates" = "Kiểm tra các bản cập nhật…"; +"updates_unavailable" = "Các bản cập nhật không có sẵn trong bản dựng này."; +"copyright" = "© 2026 Peter Steinberger. Giấy phép MIT."; + +/* Debug Pane */ +"section_logging" = "Ghi nhật ký"; +"enable_file_logging" = "Cho phép ghi nhật ký tệp"; +"enable_file_logging_subtitle" = "Ghi nhật ký vào %@ để gỡ lỗi."; +"verbosity_title" = "Độ chi tiết"; +"verbosity_subtitle" = "Kiểm soát lượng chi tiết được ghi lại."; +"open_log_file" = "Mở tệp nhật ký"; +"force_animation_next_refresh" = "Buộc hoạt ảnh vào lần làm mới tiếp theo"; +"force_animation_next_refresh_subtitle" = "Tạm thời hiển thị hoạt ảnh đang tải sau lần làm mới tiếp theo."; +"section_loading_animations" = "Đang tải hình động"; +"loading_animations_caption" = "Chọn một mẫu và phát lại nó trong thanh menu . \" Ngẫu nhiên \" giữ nguyên hành vi hiện có."; +"animation_random_default" = "Ngẫu nhiên (mặc định)"; +"replay_selected_animation" = "Phát lại hoạt ảnh đã chọn"; +"blink_now" = "Nhấp nháy ngay"; +"section_probe_logs" = "Nhật ký thăm dò"; +"probe_logs_caption" = "Tìm nạp đầu ra thăm dò mới nhất để gỡ lỗi; Sao chép giữ toàn bộ văn bản."; +"fetch_log" = "Nhật ký tìm nạp"; +"copy" = "Sao chép"; +"save_to_file" = "Lưu vào tệp"; +"load_parse_dump" = "Tải kết xuất phân tích cú pháp"; +"rerun_provider_autodetect" = "Chạy lại Nhà cung cấp tự động phát hiện"; +"loading" = "Đang tải…"; +"no_log_yet_fetch" = "Chưa có nhật ký nào. Tìm nạp để tải."; +"section_fetch_strategy" = "Lần thử chiến lược tìm nạp"; +"fetch_strategy_caption" = "Tìm nạp lần cuối các quyết định và lỗi về đường dẫn cho Nhà cung cấp ."; +"section_openai_cookies" = "OpenAI cookie"; +"openai_cookies_caption" = "Nhập cookie + nhật ký trích xuất WebKit từ lần thử cookie OpenAI gần đây nhất."; +"no_log_yet" = "Chưa có nhật ký nào. Cập nhật cookie OpenAI trong Nhà cung cấp → Codex để chạy quá trình nhập."; +"section_caches" = "Bộ nhớ đệm"; +"caches_caption" = "Xóa kết quả quét chi phí được lưu trong bộ nhớ đệm hoặc bộ nhớ đệm cookie của trình duyệt."; +"clear_cookie_cache" = "Xóa bộ nhớ đệm cookie"; +"clear_cost_cache" = "Xóa bộ nhớ đệm chi phí"; +"section_notifications" = "Thông báo"; +"notifications_caption" = "Kích hoạt thông báo kiểm tra cho khoảng thời gian phiên 5 giờ (đã cạn/được khôi phục)."; +"post_depleted" = "Đã hết bài đăng"; +"post_restored" = "Đã khôi phục bài đăng"; +"section_cli_sessions" = "CLI phiên"; +"cli_sessions_caption" = "Giữ cho các phiên Codex/ Claude CLI vẫn tồn tại sau khi thăm dò. Thoát mặc định sau khi dữ liệu được ghi lại."; +"keep_cli_sessions_alive" = "Duy trì CLI phiên"; +"keep_cli_sessions_alive_subtitle" = "Bỏ qua việc phân tích giữa các lần thăm dò (chỉ gỡ lỗi)."; +"reset_cli_sessions" = "Đặt lại CLI phiên"; +"section_error_simulation" = "Mô phỏng lỗi"; +"error_simulation_caption" = "Đưa thông báo lỗi giả vào thẻ menu để kiểm tra bố cục."; +"set_menu_error" = "Đặt lỗi menu"; +"clear_menu_error" = "Xóa lỗi menu"; +"set_cost_error" = "Lỗi đặt chi phí"; +"clear_cost_error" = "Xóa lỗi chi phí"; +"section_cli_paths" = "CLI đường dẫn"; +"cli_paths_caption" = "Đã giải quyết các lớp nhị phân Codex và PATH; chụp PATH đăng nhập khởi động (thời gian chờ ngắn)."; +"codex_binary" = "Codex nhị phân"; +"claude_binary" = "Claude nhị phân"; +"effective_path" = "PATH hiệu quả"; +"unavailable" = "Không khả dụng"; +"login_shell_path" = "Shell đăng nhập PATH (chụp khởi động)"; +"cleared" = "Đã xóa."; +"no_fetch_attempts" = "Chưa có lần tìm nạp nào."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe có thể chặn thanh menu ứng dụng trong Hệ thống Cài đặt → thanh menu → Cho phép trong thanh menu . CodexBar đang chạy nhưng macOS có thể đang ẩn biểu tượng của nó. Mở thanh menu Cài đặt và bật CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Tự động"; +"metric_pref_primary" = "Chính"; +"metric_pref_secondary" = "Trung học"; +"metric_pref_tertiary" = "Đại học"; +"metric_pref_extra_usage" = "Bổ sung Mức sử dụng"; +"metric_pref_average" = "Trung bình"; + +/* Display modes */ +"display_mode_percent" = "Phần trăm"; +"display_mode_pace" = "Tốc độ"; +"display_mode_both" = "Cả hai"; +"display_mode_percent_desc" = "Hiển thị phần trăm còn lại/đã sử dụng (ví dụ: 45%)"; +"display_mode_pace_desc" = "Hiển thị chỉ báo tốc độ (ví dụ: +5%)"; +"display_mode_both_desc" = "Hiển thị cả phần trăm và tốc độ (ví dụ: 45% · +5%)"; + +/* Provider status */ +"status_operational" = "Hoạt động"; +"status_partial_outage" = "Mất điện một phần"; +"status_major_outage" = "Mất điện lớn"; +"status_critical_issue" = "Sự cố nghiêm trọng"; +"status_maintenance" = "Bảo trì"; +"status_unknown" = "Trạng thái không xác định"; + +/* Refresh frequency */ +"refresh_manual" = "Thủ công"; +"refresh_1min" = "1 phút"; +"refresh_2min" = "2 phút"; +"refresh_5min" = "5 phút"; +"refresh_15min" = "15 phút"; +"refresh_30min" = "30 phút"; + +/* Additional keys */ +"not_found" = "Không tìm thấy"; + +/* Cost estimation */ +"cost_header_estimated" = "Chi phí (ước tính)"; +"cost_estimate_hint" = "Ước tính từ nhật ký cục bộ · có thể khác với hóa đơn của bạn"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Không phát hiện thấy IDE JetBrains nào có Trợ lý AI. Cài đặt JetBrains IDE và bật Trợ lý AI."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API token chưa được định cấu hình. Đặt biến môi trường OPENROUTER_API_KEY hoặc định cấu hình trong Cài đặt ."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "không tìm thấy z.ai API token. Đặt apiKey trong ~/.codexbar/config.json hoặc Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Thiếu khóa DeepSeek API."; +"%@ is unavailable in the current environment." = "%@ không khả dụng trong môi trường hiện tại."; +"All Systems Operational" = "Tất cả hệ thống đều hoạt động"; +"Last 30 days" = "30 ngày qua"; +"Last 30 days:" = "30 ngày qua:"; +"This month" = "Tháng này"; +"Store multiple OpenAI API keys." = "Lưu trữ nhiều khóa OpenAI API."; +"Admin API key" = "Khóa quản trị API"; +"Open billing" = "Mở thanh toán"; +"Google accounts" = "Google tài khoản"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Lưu trữ nhiều tài khoản AntiGravity Google OAuth để chuyển đổi nhanh chóng."; +"Add Google Account" = "Thêm Google Tài khoản"; +"Open Token Plan" = "Mở token Kế hoạch"; +"Text Generation" = "Tạo văn bản"; +"Text to Speech" = "Chuyển văn bản thành giọng nói"; +"Music Generation" = "Tạo nhạc"; +"Image Generation" = "Tạo hình ảnh"; +"No local data found" = "Không tìm thấy dữ liệu cục bộ"; +"Credits unavailable; keep Codex running to refresh." = "Không có tín dụng; giữ Codex chạy để làm mới."; +"No available fetch strategy for minimax." = "Không có chiến lược tìm nạp nào cho minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Không tìm thấy phiên Con trỏ. Vui lòng đăng nhập vào con trỏ.com bằng Safari , Chrome , Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chrome, Helium, Vivaldi, Yandex Browser, Firefox , Zen, Colibri, Sidekick, Opera, Opera GX hoặc Edge Canary. Nếu bạn sử dụng Safari , hãy cấp cho CodexBar Quyền truy cập toàn bộ đĩa trong Hệ thống Cài đặt ▸ Quyền riêng tư & Bảo mật. Bạn cũng có thể đăng nhập vào Cursor từ menu CodexBar (Thêm/chuyển đổi tài khoản)."; +"No OpenCode session cookies found in browsers." = "Không tìm thấy cookie phiên OpenCode trong trình duyệt."; +"No available fetch strategy for %@." = "Không có chiến lược tìm nạp nào cho %@ ."; +"Today" = "Hôm nay"; +"Today tokens" = "Mã thông báo hôm nay"; +"30d cost" = "giá 30d"; +"30d tokens" = "mã thông báo 30d"; +"Latest tokens" = "Mã thông báo mới nhất"; +"Top model" = "Mô hình hàng đầu"; +"Storage" = "Bộ nhớ"; +"Add Account..." = "Thêm tài khoản..."; +"Usage Dashboard" = "Mức sử dụng Trang tổng quan"; +"Status Page" = "Trang trạng thái"; +"Settings..." = "Cài đặt ..."; +"About CodexBar" = "Giới thiệu về CodexBar"; +"Quit" = "Thoát"; +"Last %d day" = "Ngày %d cuối cùng"; +"Last %d days" = "%d ngày cuối cùng"; +"%@ tokens" = "%@ mã thông báo"; +"Latest billing day" = "Ngày thanh toán muộn nhất"; +"Latest billing day (%@)" = "Ngày thanh toán muộn nhất ( %@ )"; +"%@ left" = "còn lại %@"; +"Resets %@" = "Đặt lại %@"; +"Resets in %@" = "Đặt lại sau %@"; +"Resets now" = "Đặt lại ngay"; +"Lasts until reset" = "Kéo dài cho đến Đặt lại"; +"Updated %@" = "Đã cập nhật %@"; +"Updated %@h ago" = "Đã cập nhật %@ h trước"; +"Updated %@m ago" = "Đã cập nhật %@ tháng trước"; +"Updated just now" = "Vừa cập nhật"; +"Projected empty in %@" = "Dự kiến trống trong %@"; +"Runs out in %@" = "Hết trong %@"; +"Pace: %@" = "Tốc độ: %@"; +"Pace: %@ · %@" = "Pace: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d %% rủi ro cạn kiệt"; +"%d%% in deficit" = "%d %% thâm hụt"; +"%d%% in reserve" = "%d %% dự trữ"; +"usage_percent_suffix_left" = "left"; +"usage_percent_suffix_used" = "đã sử dụng"; +"Store multiple DeepSeek API keys." = "Lưu trữ nhiều khóa DeepSeek API."; +"This week" = "Tuần này"; +"Week" = "Tuần"; +"Month" = "Tháng"; +"Models" = "Mô hình"; +"24h tokens" = "Token 24 giờ"; +"Latest hour" = "Giờ mới nhất"; +"Peak hour" = "Giờ cao điểm"; +"Top method" = "Phương thức hàng đầu"; +"30d cash" = "tiền mặt 30d"; +"30d billing history from MiniMax web session" = "Thanh toán 30 ngày lịch sử từ MiniMax phiên web"; +"AWS Cost Explorer billing can lag." = "Việc thanh toán AWS Cost Explorer có thể bị trễ."; +"Rate limit: %d / %@" = "Giới hạn tốc độ: %d / %@"; +"Key remaining" = "Khóa còn lại"; +"No limit set for the API key" = "Không có giới hạn nào được đặt cho khóa API"; +"API key limit unavailable right now" = "Giới hạn khóa API hiện không khả dụng"; +"This month: %@ tokens" = "Tháng này: mã thông báo %@"; +"No utilization data yet." = "Chưa có dữ liệu sử dụng."; +"No %@ utilization data yet." = "Chưa có dữ liệu sử dụng %@."; +"%@: %@%% used" = "%@ : %@ %% đã sử dụng"; +"%dd" = "%d d"; +"today" = "hôm nay"; +"just now" = "vừa rồi"; +"On pace" = "Đang tiến hành"; +"Runs out now" = "Sắp hết"; +"Projected empty now" = "Dự kiến trống"; +"Switch Account..." = "Chuyển tài khoản..."; +"Update ready, restart now?" = "Cập nhật đã sẵn sàng, khởi động lại ngay bây giờ?"; +"Daily" = "Hàng ngày"; +"Hourly Tokens" = "Mã thông báo hàng giờ"; +"No data" = "Không có dữ liệu"; +"No usage breakdown data available." = "Không có dữ liệu phân tích Mức sử dụng."; + +"Today: %@ · %@ tokens" = "Hôm nay: %@ · %@ mã thông báo"; +"Today: %@" = "Hôm nay: %@"; +"Today: %@ tokens" = "Hôm nay: %@ mã thông báo"; +"Last 30 days: %@ · %@ tokens" = "30 ngày qua: %@ · %@ mã thông báo"; +"Last 30 days: %@" = "30 ngày qua: %@"; +"Est. total (30d): %@" = "Ước tính tổng cộng (30 ngày): %@"; +"Est. total (%@): %@" = "Ước tính tổng ( %@ ): %@"; +"Hover a bar for details" = "Di chuột qua thanh để biết thông tin chi tiết"; +"%@: %@ · %@ tokens" = "%@ : %@ · %@ mã thông báo"; +"No providers selected for Overview." = "Không có nhà cung cấp nào được chọn cho Tổng quan."; +"No overview data available." = "Không có dữ liệu tổng quan."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Tự động sử dụng IDE cục bộ API trước, sau đó là Google OAuth khi IDE đóng."; +"Login with Google" = "Đăng nhập bằng Google"; + +/* Popup panels */ +"No usage configured." = "Chưa định cấu hình Mức sử dụng."; +"Quota" = "Hạn mức"; +"tokens" = "mã thông báo"; +"requests" = "yêu cầu"; +"Latest" = ""; +"Monthly" = "Mới nhất"; +"Sonnet" = "Sonnet"; +"Overages" = "Quá tải"; +"Activity" = "Hoạt động"; +"Copied" = "Đã sao chép"; +"Copy error" = "Lỗi sao chép"; +"Copy path" = "Sao chép đường dẫn"; +"Extra usage spent" = "Thêm Mức sử dụng đã chi tiêu"; +"Credits remaining" = "Tín dụng còn lại"; +"Using CLI fallback" = "Sử dụng CLI dự phòng"; +"Balance updates in near-real time (up to 5 min lag)" = "Cập nhật số dư trong thời gian gần như thực (độ trễ tối đa 5 phút)"; +"Daily billing data finalizes at 07:00 UTC" = "Dữ liệu thanh toán hàng ngày sẽ hoàn tất lúc 07:00 UTC"; +"%@ of %@ credits left" = "%@ trong số %@ tín dụng còn lại"; +"%@ of %@ bonus credits left" = "%@ trong số %@ tín dụng thưởng còn lại"; +"%@ / %@ (%@ remaining)" = "%@ / %@ ( %@ còn lại)"; +"%@/%@ left" = "%@ / %@ left"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Tái tạo %@"; +"used after next regen" = "được sử dụng sau lần tái sinh tiếp theo"; +"after next regen" = "sau đợt regen tiếp theo"; +"Near full" = "Gần đầy"; +"Full in ~1 regen" = "Đầy đủ trong ~1 regen"; +"Full in ~%.0f regens" = "Đầy đủ trong ~%.0f regens"; +"Overage usage" = "Quá mức Mức sử dụng"; +"Overage cost" = "Chi phí quá mức"; +"credits" = "tín dụng"; +"Zen balance" = "Số dư Zen"; +"API spend" = "API chi tiêu"; +"Extra usage" = "Thêm Mức sử dụng"; +"Quota usage" = "Hạn mức Mức sử dụng"; +"%.0f%% used" = "%.0f%% đã sử dụng"; +"Usage history (today)" = "Mức sử dụng lịch sử (hôm nay)"; +"Usage history (%d days)" = "Mức sử dụng lịch sử ( %d ngày)"; +"%d percent remaining" = "%d phần trăm còn lại"; +"Unknown" = "Không xác định"; +"stale data" = "dữ liệu cũ"; +"No credits history data." = "Không có dữ liệu lịch sử tín dụng."; +"No credits history data available." = "Không có dữ liệu lịch sử tín dụng."; +"Credits history chart" = "Biểu đồ lịch sử tín dụng"; +"%d days of credits data" = "%d ngày dữ liệu tín dụng"; +"Usage breakdown chart" = "Mức sử dụng biểu đồ phân tích"; +"%d days of usage data across %d services" = "%d ngày của dữ liệu Mức sử dụng trên %d dịch vụ"; +"Cost history chart" = "Biểu đồ lịch sử chi phí"; +"%d days of cost data" = "%d ngày của dữ liệu chi phí"; +"Plan utilization chart" = "Biểu đồ sử dụng kế hoạch"; +"%d utilization samples" = "%d mẫu sử dụng"; +"Hourly Usage" = "Hàng giờ Mức sử dụng"; +"Usage remaining" = "Mức sử dụng"; +"Usage used" = "Mức sử dụng đã sử dụng khóa"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "API đã được xác minh. Ollama không đưa ra các giới hạn Hạn mức của Đám mây thông qua API ."; +"Last 30 days: %@ tokens" = "30 ngày qua: %@ mã thông báo"; +"7d spend" = "chi tiêu 7 ngày"; +"30d spend" = "chi tiêu 30 ngày"; +"Cache read" = "Đọc bộ nhớ đệm"; +"Claude Admin API 30 day spend trend" = "Claude Quản trị viên API Xu hướng chi tiêu 30 ngày"; +"OpenRouter API key spend trend" = "Xu hướng chi tiêu khóa API OpenRouter"; +"z.ai hourly token trend" = "z.ai hàng giờ token xu hướng"; +"MiniMax 30 day token usage trend" = "MiniMax 30 ngày token Mức sử dụng xu hướng"; +"Today cash" = "Tiền mặt hôm nay"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30 ngày token Mức sử dụng xu hướng"; +"cache-hit input" = "đầu vào truy cập bộ đệm"; +"cache-miss input" = "đầu vào bỏ lỡ bộ đệm"; +"output" = "đầu ra"; +"Requests" = "Yêu cầu"; +"Reported by OpenAI Admin API organization usage." = "Được báo cáo bởi OpenAI Quản trị viên API tổ chức Mức sử dụng ."; +"Reported by Mistral billing usage." = "Được báo cáo bởi thanh toán Mistral Mức sử dụng ."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Thêm tài khoản qua GitHub OAuth Luồng thiết bị trên máy chủ đã chọn."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Lưu trữ từng tài khoản Google đã đăng nhập để chuyển đổi Chống hấp dẫn nhanh chóng. Sử dụng AntiGravity.app OAuth khi có sẵn hoặc ANTIGRAVITY_OAUTH_CLIENT_ID và ANTIGRAVITY_OAUTH_CLIENT_SECRET làm ghi đè."; +"Manual cleanup: past sessions" = "Dọn dẹp thủ công: các phiên trước đây"; +"Clearing removes past resume, continue, and rewind history." = "Việc xóa sẽ xóa lịch sử tiếp tục, tiếp tục và tua lại trong quá khứ."; +"Manual cleanup: file checkpoints" = "Dọn dẹp thủ công: điểm kiểm tra tệp"; +"Clearing removes checkpoint restore data for previous edits." = "Việc xóa sẽ xóa dữ liệu khôi phục điểm kiểm tra cho các chỉnh sửa trước đó."; +"Manual cleanup: saved plans" = "Dọn dẹp thủ công: các gói đã lưu"; +"Clearing removes old plan-mode files." = "Việc xóa sẽ xóa các tệp chế độ gói cũ."; +"Manual cleanup: debug logs" = "Dọn dẹp thủ công: nhật ký gỡ lỗi"; +"Clearing removes past debug logs." = "Việc xóa sẽ xóa nhật ký gỡ lỗi trước đây."; +"Manual cleanup: attachment cache" = "Dọn dẹp thủ công: bộ đệm đính kèm"; +"Clearing removes cached large pastes or attached images." = "Việc xóa sẽ xóa các miếng dán lớn hoặc hình ảnh đính kèm được lưu trong bộ nhớ đệm."; +"Manual cleanup: session metadata" = "Dọn dẹp thủ công: siêu dữ liệu phiên"; +"Clearing removes per-session environment metadata." = "Việc xóa sẽ xóa siêu dữ liệu môi trường mỗi phiên."; +"Manual cleanup: shell snapshots" = "Dọn dẹp thủ công: ảnh chụp nhanh shell"; +"Clearing removes leftover runtime shell snapshot files." = "Việc xóa sẽ xóa các tệp ảnh chụp nhanh shell thời gian chạy còn sót lại."; +"Manual cleanup: legacy todos" = "Dọn dẹp thủ công: việc cần làm cũ"; +"Clearing removes legacy per-session task lists." = "Việc xóa sẽ xóa danh sách nhiệm vụ cũ mỗi phiên."; +"Manual cleanup: sessions" = "Dọn dẹp thủ công: phiên"; +"Clearing removes past Codex session history." = "Việc xóa sẽ xóa lịch sử phiên Codex trước đây."; +"Manual cleanup: archived sessions" = "Dọn dẹp thủ công: các phiên đã lưu trữ"; +"Clearing removes archived Codex session history." = "Việc xóa sẽ xóa lịch sử phiên Codex đã lưu trữ."; +"Manual cleanup: cache" = "Dọn dẹp thủ công: bộ đệm"; +"Clearing removes provider-owned cached data." = "Việc xóa sẽ xóa dữ liệu được lưu trong bộ nhớ đệm thuộc quyền sở hữu của Nhà cung cấp."; +"Manual cleanup: logs" = "Dọn dẹp thủ công: nhật ký"; +"Clearing removes local diagnostic logs." = "Việc xóa sẽ xóa nhật ký chẩn đoán cục bộ."; +"Manual cleanup: file history" = "Dọn dẹp thủ công: lịch sử tệp"; +"Clearing removes local edit checkpoint history." = "Việc xóa sẽ xóa lịch sử điểm kiểm tra chỉnh sửa cục bộ."; +"Manual cleanup: temporary data" = "Dọn dẹp thủ công: dữ liệu tạm thời"; +"Clearing removes local temporary provider data." = "Việc xóa sẽ xóa dữ liệu Nhà cung cấp tạm thời cục bộ."; +"Total: %@" = "Tổng cộng: %@"; +"%d more items" = "%d mục khác"; +"Cleanup ideas" = "Ý tưởng dọn dẹp"; +"%d unreadable item(s) skipped" = "%d (các) mục không thể đọc được đã bỏ qua"; + +"API key limit" = "API giới hạn khóa"; +"Auth" = "Xác thực"; +"Auto" = "Tự động"; +"Disabled — no recent data" = "Đã tắt — không có dữ liệu gần đây"; +"Limits not available" = "Không có giới hạn"; +"No usage yet" = "Chưa có Mức sử dụng"; +"Not fetched yet" = "Chưa được tìm nạp"; +"Refreshing" = "Đang làm mới"; +"Session" = "Phiên"; +"Source" = "Nguồn"; +"State" = "Trạng thái"; +"Unavailable" = "Không có sẵn"; +"Weekly" = "Không phát hiện được"; +"not detected" = "hàng tuần"; +"Estimated from local Codex logs for the selected account." = "Được ước tính từ nhật ký Codex cục bộ cho tài khoản đã chọn."; +"minimax_usage_amount_format" = "Mức sử dụng : %@ / %@"; +"minimax_used_percent_format" = "Đã sử dụng %@"; +"minimax_service_text_generation" = "Tạo văn bản"; +"minimax_service_text_to_speech" = "Chuyển văn bản thành giọng nói"; +"minimax_service_music_generation" = "Tạo nhạc"; +"minimax_service_image_generation" = "Tạo hình ảnh"; +"minimax_service_lyrics_generation" = "Tạo lời bài hát"; +"minimax_service_coding_plan_vlm" = "Kế hoạch mã hóa VLM"; +"minimax_service_coding_plan_search" = "Tìm kiếm kế hoạch mã hóa"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ đang chờ cấp phép"; +"%@ requests" = "%@ yêu cầu"; +"%@: %@ credits" = "%@: %@ credits"; +"30d requests" = "yêu cầu 30 ngày"; +"4 days" = "4 ngày"; +"5 days" = "5 ngày"; +"7 days" = "7 ngày"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API khóa xác minh quyền truy cập vào Đám mây Ollama; cookie vẫn hiển thị giới hạn Hạn mức."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID khóa truy cập AWS. Cũng có thể được đặt bằng AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Khu vực AWS. Cũng có thể được đặt bằng AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Khóa truy cập bí mật AWS. Cũng có thể được đặt bằng AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID khóa truy cập"; +"Add Account" = "Thêm tài khoản"; +"Adding Account…" = "Đang thêm tài khoản…"; +"Antigravity login failed" = "Đăng nhập chống trọng lực không thành công"; +"Antigravity login timed out" = "Đã hết thời gian đăng nhập chống trọng lực"; +"Auth source" = "Nguồn xác thực"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Tự động nhập cookie trình duyệt Chrome từ Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Tự động nhập dữ liệu phiên Windsurf từ localStorage của trình duyệt Chrome."; +"Automatic imports browser cookies from Bailian." = "Tự động nhập cookie trình duyệt từ Bailian."; +"Automatically imports browser cookies." = "Tự động nhập cookie trình duyệt."; +"Automatically imports browser session cookies." = "Tự động nhập cookie phiên trình duyệt."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "tên triển khai Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME cũng được hỗ trợ."; +"Azure OpenAI key" = "Khóa Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Điểm cuối tài nguyên Azure OpenAI. AZURE_OPENAI_ENDPOINT cũng được hỗ trợ."; +"Base URL" = "Base URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Base URL cho phiên bản LLM- API -Key-Proxy."; +"Browser cookies" = "Cookie trình duyệt"; +"Cap end" = "Cap end"; +"Cap start" = "Cap start"; +"Capacity End" = "Dung lượng End"; +"Capacity Start" = "Dung lượng Start"; +"Changelog" = "Changelog"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Chọn máy chủ Moonshot/Kimi API cho các tài khoản quốc tế hoặc Trung Quốc đại lục."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar không thể thay thế tài khoản hệ thống được đăng nhập bằng thiết lập chỉ khóa API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar không thể tìm thấy xác thực đã lưu cho tài khoản đó. Xác thực lại nó và thử lại."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar không thể đọc bộ nhớ tài khoản được quản lý. Khôi phục cửa hàng trước khi thêm tài khoản khác."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar không thể đọc xác thực đã lưu cho tài khoản đó. Xác thực lại nó và thử lại."; +"CodexBar could not read the current system account on this Mac." = "CodexBar không thể đọc tài khoản hệ thống hiện tại trên máy Mac này."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar không thể thay thế xác thực Codex trực tiếp trên máy Mac này."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar không thể bảo toàn tài khoản hệ thống hiện tại một cách an toàn trước khi chuyển đổi."; +"CodexBar could not save the current system account before switching." = "CodexBar không thể lưu tài khoản hệ thống hiện tại trước khi chuyển đổi."; +"CodexBar could not update managed account storage." = "CodexBar không thể cập nhật bộ nhớ tài khoản được quản lý."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar đã tìm thấy một tài khoản được quản lý khác đã sử dụng tài khoản hệ thống hiện tại. Giải quyết tài khoản trùng lặp trước khi chuyển đổi."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho “ %@ ” để nó có thể giải mã cookie của trình duyệt và xác thực tài khoản của bạn. Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp Claude Mã OAuth token để nó có thể tìm nạp Claude Mức sử dụng của bạn. Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie Amp của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie Augment của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain về tiêu đề cookie Claude của bạn để nó có thể tìm nạp Claude web Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie Con trỏ của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie Factory của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho GitHub Copilot token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp khóa Kimi K2 API của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho Kimi auth token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp MiniMax API token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie MiniMax của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie OpenAI của bạn để nó có thể tìm nạp các tính năng bổ sung của trang tổng quan Codex. Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie OpenCode của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp khóa Tổng hợp API của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp z.ai API token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"Could not open Cursor login in your browser." = "Không thể mở đăng nhập Con trỏ trong trình duyệt của bạn."; +"Could not open browser for Antigravity" = "Không thể mở trình duyệt cho AntiGravity"; +"Credits used" = "Tín dụng đã sử dụng"; +"Day" = "Ngày"; +"Deployment" = "Triển khai"; +"Drag to reorder" = "Kéo để sắp xếp lại"; +"Endpoint" = "Điểm cuối"; +"Enterprise host" = "Máy chủ doanh nghiệp"; +"Extra usage balance: %@" = "Extra usage balance: %@"; +"Keychain Access Required" = "Keychain Yêu cầu quyền truy cập"; +"Kiro menu bar value" = "Kiro thanh menu value"; +"Label" = "Nhãn"; +"No organizations loaded. Click Refresh after setting your API key." = "Chưa có tổ chức nào được tải. Nhấp vào Làm mới sau khi đặt khóa API của bạn."; +"No output captured." = "Không ghi được đầu ra nào."; +"No system account" = "Không có tài khoản hệ thống"; +"Oasis-Token" = "Oasis- token"; +"Open Augment (Log Out & Back In)" = "Mở phần mở rộng (Đăng xuất và quay lại)"; +"Open Codebuff Dashboard" = "Mở bảng điều khiển Codebuff"; +"Open Command Code Settings" = "Mở mã lệnh Cài đặt"; +"Open Crof dashboard" = "Mở bảng điều khiển Crof"; +"Open Manus" = "Mở Manus"; +"Open MiMo Balance" = "Mở MiMo Balance"; +"Open Moonshot Console" = "Mở Bảng điều khiển Moonshot"; +"Open Ollama API Keys" = "Mở Ollama API Phím"; +"Open StepFun Platform" = "Mở Nền tảng StepFun"; +"Open T3 Chat Settings" = "Mở Trò chuyện T3 Cài đặt"; +"Open Volcengine Ark Console" = "Mở Bảng điều khiển Volcengine Ark"; +"Open legacy provider docs" = "Mở di sản Nhà cung cấp docs"; +"Open projects" = "Mở dự án"; +"Open this URL manually to continue login:\n\n%@" = "Open this URL manually to continue login:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID tổ chức tùy chọn cho các tài khoản được liên kết với nhiều tổ chức Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Tùy chọn. Áp dụng cho khóa Quản trị viên API đã định cấu hình; tài khoản token đã chọn không kế thừa OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Tùy chọn. Nhập máy chủ GitHub Enterprise của bạn, ví dụ: octocorp.ghe.com. Để trống cho github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Tùy chọn. Để trống để khám phá và tổng hợp các dự án hiển thị với khóa API."; +"Org ID (optional)" = "ID tổ chức (tùy chọn)"; +"Organizations" = "Tổ chức"; +"Password" = "Mật khẩu"; +"%@ authentication is disabled." = "%@ xác thực bị tắt. Cookie"; +"%@ cookies are disabled." = "%@ bị tắt. Quyền truy cập"; +"%@ web API access is disabled." = "%@ web API bị vô hiệu hóa."; +"Disable %@ dashboard cookie usage." = "Tắt %@ cookie trang tổng quan Mức sử dụng . Quyền truy cập"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Keychain bị vô hiệu hóa trong Nâng cao, do đó, tính năng nhập cookie trình duyệt không khả dụng."; +"Manually paste an %@ from a browser session." = "Dán %@ theo cách thủ công từ phiên trình duyệt."; +"Paste a Cookie header captured from %@." = "Dán tiêu đề Cookie được lấy từ %@ ."; +"Paste a Cookie header from %@." = "Dán tiêu đề Cookie từ %@ ."; +"Paste a Cookie header or cURL capture from %@." = "Dán tiêu đề Cookie hoặc chụp cURL từ %@ ."; +"Paste a Cookie header or full cURL capture from %@." = "Dán tiêu đề Cookie hoặc chụp cURL đầy đủ từ %@ ."; +"Paste a Cookie or Authorization header from %@." = "Dán tiêu đề Cookie hoặc Ủy quyền từ %@ ."; +"Paste a full cookie header or the %@ value." = "Dán tiêu đề cookie đầy đủ hoặc giá trị %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Dán tiêu đề Cookie hoặc chụp cURL đầy đủ từ Trò chuyện T3 Cài đặt ."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Dán tiêu đề Cookie từ yêu cầu tới admin.mistral.ai. Phải chứa cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Dán Oasis- token từ phiên trình duyệt đã đăng nhập trên platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Dán gói %@ JSON từ %@ ."; +"Paste the %@ value or a full Cookie header." = "Dán giá trị %@ hoặc tiêu đề Cookie đầy đủ."; +"Personal account" = "Tài khoản cá nhân"; +"Project ID" = "ID dự án"; +"Re-auth" = "Xác thực lại"; +"Re-authenticating…" = "Xác thực lại…"; +"Refresh Session" = "Làm mới phiên"; +"Refresh organizations" = "Làm mới tổ chức"; +"Region" = "Khu vực"; +"Reload" = "Tải lại"; +"Reorder" = "Sắp xếp lại"; +"Secret access key" = "Khóa truy cập bí mật"; +"Series" = "Chuỗi"; +"Service" = "Dịch vụ"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Hiển thị hoặc ẩn tín dụng Kiro, phần trăm hoặc cả hai bên cạnh biểu tượng thanh menu."; +"Show usage for organizations you belong to. Personal account is always shown." = "Hiển thị Mức sử dụng cho các tổ chức mà bạn là thành viên. Tài khoản cá nhân luôn được hiển thị."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Đăng nhập vào con trỏ.com trong trình duyệt của bạn, sau đó làm mới Con trỏ trong CodexBar ."; +"Simulated error text" = "Văn bản lỗi mô phỏng"; +"StepFun platform account (phone number or email)." = "Tài khoản nền tảng StepFun (số điện thoại hoặc email)."; +"Stored in ~/.codexbar/config.json." = "Được lưu trữ trong ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Được lưu trữ trong ~/.codexbar/config.json. AZURE_OPENAI_API_KEY cũng được hỗ trợ."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Được lưu trữ trong ~/.codexbar/config.json. Đối với Kimi API chính thức, hãy sử dụng Moonshot / Kimi API ."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận khóa API của bạn từ bảng điều khiển Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận chìa khóa của bạn từ Ollama Cài đặt ."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận chìa khóa của bạn từ console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận khóa của bạn từ Elevenlabs.io/app/ Cài đặt /api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận khóa của bạn từ openrouter.ai/ Cài đặt /keys và đặt giới hạn chi tiêu cho khóa ở đó để cho phép theo dõi API khóa Hạn mức."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Được lưu trữ trong ~/.codexbar/config.json. Trong Warp, hãy mở Khóa Cài đặt > Nền tảng > API, sau đó tạo một khóa."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Được lưu trữ trong ~/.codexbar/config.json. Các số liệu yêu cầu quyền truy cập Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Được lưu trữ trong ~/.codexbar/config.json. OPENAI_ADMIN_KEY được ưu tiên; OPENAI_API_KEY vẫn hoạt động."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Được lưu trữ trong ~/.codexbar/config.json. Yêu cầu khóa Anthropic Quản trị viên API."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Được lưu trữ trong ~/.codexbar/config.json. Được sử dụng cho /v1/ Hạn mức -stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Được lưu trữ trong ~/.codexbar/config.json. Bạn cũng có thể cung cấp CODEBUFF_API_KEY hoặc để CodexBar đọc ~/.config/manicode/credentials.json (được tạo bởi `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Được lưu trữ trong ~/.codexbar/config.json. Bạn cũng có thể cung cấp CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Được lưu trữ trong ~/.codexbar/config.json. Bạn cũng có thể cung cấp KILO_API_KEY hoặc ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie trò chuyện T3"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Tài khoản đó không còn khả dụng trong CodexBar . Hãy làm mới danh sách tài khoản và thử lại."; +"The browser login did not complete in time. Try Antigravity login again." = "Quá trình đăng nhập trình duyệt không hoàn tất kịp thời. Hãy thử đăng nhập lại bằng AntiGravity."; +"Timed out waiting for Cursor login. %@" = "Đã hết thời gian chờ đăng nhập Con trỏ. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Đã hết thời gian chờ đăng nhập Con trỏ. %@ Lỗi cuối cùng: %@"; +"Today requests" = "Hôm nay yêu cầu"; +"Total (30d): %@ credits" = "Tổng cộng (30d): %@ tín dụng"; +"Username" = "Tên người dùng"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Sử dụng tên người dùng + mật khẩu để đăng nhập và nhận Oasis- token một cách tự động."; +"Uses username + password to login and obtain an %@ automatically." = "Sử dụng tên người dùng + mật khẩu để đăng nhập và tự động nhận được %@."; +"Utilization End" = "Kết thúc sử dụng"; +"Utilization Start" = "Bắt đầu sử dụng"; +"Verbosity" = "Độ chi tiết"; +"Windsurf session JSON bundle" = "Phiên lướt ván buồm JSON gói"; +"Workspace ID" = "ID không gian làm việc"; +"Your StepFun platform password. Used to login and obtain a session token." = "Mật khẩu nền tảng StepFun của bạn. Được sử dụng để đăng nhập và nhận phiên token ."; +"claude /login exited with status %d." = "claude /đăng nhập đã thoát với trạng thái %d ."; +"codex login exited with status %d." = "đăng nhập codex đã thoát với trạng thái %d ."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nhoặc dán bản chụp cURL từ bảng thông tin Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nhoặc dán giá trị __Secure-next-auth.session- token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nhoặc dán giá trị kimi-auth token"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nhoặc chỉ dán giá trị session_id"; +"Clear" = "Xóa"; +"No matching providers" = "Không có nhà cung cấp phù hợp"; +"Search providers" = "Tìm kiếm nhà cung cấp"; + +"language_vietnamese" = "Tiếng Việt"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 43905634c..c500c7149 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -406,6 +406,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "瑞典语"; "language_dutch" = "Nederlands"; "language_french" = "法语"; "language_ukrainian" = "乌克兰语"; @@ -1036,3 +1037,5 @@ "Clear" = "清除"; "No matching providers" = "没有匹配的提供商"; "Search providers" = "搜索提供商"; + +"language_vietnamese" = "越南语"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index d9f0205ed..efe33c21d 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -406,6 +406,7 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "瑞典語"; "language_dutch" = "Nederlands"; "language_french" = "法語"; "language_ukrainian" = "烏克蘭語"; @@ -932,3 +933,5 @@ "Clear" = "清除"; "No matching providers" = "沒有相符的提供者"; "Search providers" = "搜尋提供者"; + +"language_vietnamese" = "越南語"; diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index a71eb81d1..32eeff2be 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -3,12 +3,50 @@ import Testing @testable import CodexBar struct LocalizationLanguageCatalogTests { + private let languageKeys = [ + "language_system", + "language_english", + "language_spanish", + "language_catalan", + "language_chinese_simplified", + "language_chinese_traditional", + "language_portuguese_brazilian", + "language_swedish", + "language_french", + "language_dutch", + "language_ukrainian", + "language_vietnamese", + ] + @Test func `app language catalog includes Ukrainian`() { #expect(AppLanguage.allCases.contains(.ukrainian)) #expect(AppLanguage.ukrainian.rawValue == "uk") } + @Test + func `localized catalogs include every app language label`() throws { + #expect(self.languageKeys.count == AppLanguage.allCases.count) + + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let contents = try String(contentsOf: stringsURL, encoding: .utf8) + for key in self.languageKeys { + #expect(contents.contains("\"\(key)\""), "Missing \(key) in \(catalogURL.lastPathComponent)") + } + } + } + @Test func `ukrainian localization bundle exists and contains key UI labels`() throws { let root = URL(fileURLWithPath: #filePath) From fca42e99d0ec3c9d55f3a64d156def23d3bf1a4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 08:32:57 +0100 Subject: [PATCH 50/93] fix: scope Codex reset cache to auth identity --- .../Codex/UsageStore+CodexAccountState.swift | 48 ++++++++- Sources/CodexBar/UsageStore+Refresh.swift | 5 +- .../CodexBar/UsageStore+TokenAccounts.swift | 44 +++++++- ...exAccountVisibleHistoryBackfillTests.swift | 102 ++++++++++++++++++ 4 files changed, 193 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index dd2184e58..ff8470c39 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -13,6 +13,19 @@ struct CodexAccountScopedRefreshGuard: Equatable { let source: CodexActiveSource let identity: CodexIdentity let accountKey: String? + let authFingerprint: String? + + init( + source: CodexActiveSource, + identity: CodexIdentity, + accountKey: String?, + authFingerprint: String? = nil) + { + self.source = source + self.identity = identity + self.accountKey = accountKey + self.authFingerprint = CodexAuthFingerprint.normalize(authFingerprint) + } } @MainActor @@ -106,7 +119,8 @@ extension UsageStore { self.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( source: resolvedSource, identity: resolvedIdentity, - accountKey: accountKey) + accountKey: accountKey, + authFingerprint: self.currentCodexAuthFingerprint(source: resolvedSource)) } func currentCodexAccountScopedRefreshGuard( @@ -121,7 +135,8 @@ extension UsageStore { allowLastKnownLiveFallback: allowLastKnownLiveFallback), accountKey: self.codexAccountScopedRefreshKey( preferCurrentSnapshot: preferCurrentSnapshot, - allowLastKnownLiveFallback: allowLastKnownLiveFallback)) + allowLastKnownLiveFallback: allowLastKnownLiveFallback), + authFingerprint: self.currentCodexAuthFingerprint(source: self.settings.codexResolvedActiveSource)) } func currentCodexOpenAIWebRefreshGuard() -> CodexAccountScopedRefreshGuard { @@ -137,7 +152,8 @@ extension UsageStore { return CodexAccountScopedRefreshGuard( source: source, identity: self.currentCodexOpenAIWebIdentity(source: source), - accountKey: accountKey) + accountKey: accountKey, + authFingerprint: self.currentCodexAuthFingerprint(source: source)) } func shouldApplyCodexUsageResult( @@ -146,6 +162,7 @@ extension UsageStore { { let currentGuard = self.currentCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -167,6 +184,7 @@ extension UsageStore { func shouldApplyCodexScopedFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { let currentGuard = self.currentCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -178,6 +196,7 @@ extension UsageStore { func shouldApplyCodexScopedNonUsageResult(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { let currentGuard = self.currentCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } guard expectedGuard.identity != .unresolved else { return false } return currentGuard.identity == expectedGuard.identity } @@ -189,6 +208,7 @@ extension UsageStore { let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) let currentGuard = self.currentCodexOpenAIWebRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -277,6 +297,28 @@ extension UsageStore { self.lastKnownLiveSystemCodexEmail = normalized } + static func codexGuardAuthFingerprintMatches( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + if lhsFingerprint != nil || rhsFingerprint != nil { + return lhsFingerprint == rhsFingerprint + } + return true + } + + func currentCodexAuthFingerprint(source: CodexActiveSource) -> String? { + let snapshot = self.settings.codexAccountReconciliationSnapshot + switch source { + case .liveSystem: + return CodexAuthFingerprint.normalize(snapshot.liveSystemAccount?.authFingerprint) + case let .managedAccount(id): + return CodexAuthFingerprint.normalize(snapshot.storedAccounts.first { $0.id == id }?.authFingerprint) + } + } + func codexAccountScopedRefreshKey( preferCurrentSnapshot: Bool = true, allowLastKnownLiveFallback: Bool = true) -> String? diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 22c5b19f4..8f70bb7a2 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -105,7 +105,10 @@ extension UsageStore { if claudeCredentialsChanged { self.clearClaudeCredentialDerivedStateForCredentialSwapNow() } - let backfilled = scoped.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + let resetBackfillSource = provider == .codex + ? self.codexLastKnownResetSnapshot(matching: codexExpectedGuard) + : self.lastKnownResetSnapshots[provider] + let backfilled = scoped.backfillingResetTimes(from: resetBackfillSource) self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) self.lastKnownResetSnapshots[provider] = backfilled diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index b4213dbb4..4f71a82cf 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -83,6 +83,7 @@ extension UsageStore { let priorByAccountID = Dictionary(uniqueKeysWithValues: self.codexAccountSnapshots.map { ($0.id, $0) }) var snapshots: [CodexAccountUsageSnapshot] = [] var selectedOutcome: ProviderFetchOutcome? + var selectedAccount: CodexVisibleAccount? var selectedSnapshot: UsageSnapshot? var selectedSourceLabel: String? var sawAnyNonCancellationOutcome = false @@ -108,6 +109,7 @@ extension UsageStore { } if account.id == originalVisibleAccountID { selectedOutcome = outcome + selectedAccount = account selectedSnapshot = resolved.usage selectedSourceLabel = resolved.sourceLabel } @@ -123,9 +125,10 @@ extension UsageStore { let selectionStillMatches = self.codexVisibleSelectionStillMatches( originalVisibleAccountID: originalVisibleAccountID, originalSelectionSource: originalSelectionSource) - if let selectedOutcome, selectionStillMatches { + if let selectedOutcome, let selectedAccount, selectionStillMatches { await self.applySelectedCodexVisibleAccountOutcome( selectedOutcome, + account: selectedAccount, snapshot: selectedSnapshot, sourceLabel: selectedSourceLabel) } @@ -473,12 +476,22 @@ extension UsageStore { { snapshots.append(lastKnown) } - if let history = self.codexPlanHistoryResetBackfillSnapshot(for: account) { + if self.codexCanUseHistoricalResetBackfill(for: account), + let history = self.codexPlanHistoryResetBackfillSnapshot(for: account) + { snapshots.append(history) } return snapshots } + private func codexCanUseHistoricalResetBackfill(for account: CodexVisibleAccount) -> Bool { + let authFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + let workspaceAccountID = Self.normalizedCodexVisibleAccountText(account.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + guard authFingerprint != nil, workspaceAccountID == nil else { return true } + return Self.codexScopedGuard(self.lastCodexAccountScopedRefreshGuard, matches: account) + } + private func codexPlanHistoryResetBackfillSnapshot(for account: CodexVisibleAccount) -> UsageSnapshot? { let histories = self.codexPlanUtilizationHistories(forVisibleAccount: account) guard !histories.isEmpty @@ -522,6 +535,15 @@ extension UsageStore { return snapshot } + func codexLastKnownResetSnapshot(matching guardValue: CodexAccountScopedRefreshGuard?) -> UsageSnapshot? { + guard let guardValue, + self.lastCodexAccountScopedRefreshGuard == guardValue + else { + return nil + } + return self.lastKnownResetSnapshots[.codex] + } + private nonisolated static func codexVisibleAccountEmailMatches( snapshot: UsageSnapshot, account: CodexVisibleAccount) -> Bool @@ -579,6 +601,11 @@ extension UsageStore { matches account: CodexVisibleAccount) -> Bool { guard let guardValue, guardValue.source == account.selectionSource else { return false } + let guardAuthFingerprint = CodexAuthFingerprint.normalize(guardValue.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if guardAuthFingerprint != nil || accountAuthFingerprint != nil { + guard guardAuthFingerprint == accountAuthFingerprint else { return false } + } let identity = self.codexVisibleAccountIdentity(for: account) if identity != .unresolved { return guardValue.identity == identity @@ -587,6 +614,17 @@ extension UsageStore { return guardValue.accountKey == accountKey } + private nonisolated static func codexScopedRefreshGuard(for account: CodexVisibleAccount) + -> CodexAccountScopedRefreshGuard + { + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email) + return CodexAccountScopedRefreshGuard( + source: account.selectionSource, + identity: self.codexVisibleAccountIdentity(for: account), + accountKey: accountEmail, + authFingerprint: account.authFingerprint) + } + private nonisolated static func codexVisibleAccountIdentity(for account: CodexVisibleAccount) -> CodexIdentity { if let workspaceAccountID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) { return .providerAccount(id: CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID(workspaceAccountID)) @@ -853,6 +891,7 @@ extension UsageStore { func applySelectedCodexVisibleAccountOutcome( _ outcome: ProviderFetchOutcome, + account: CodexVisibleAccount, snapshot: UsageSnapshot?, sourceLabel: String?) async { @@ -862,6 +901,7 @@ extension UsageStore { guard let snapshot else { return } self.handleSessionQuotaTransition(provider: .codex, snapshot: snapshot) self.lastKnownResetSnapshots[.codex] = snapshot + self.lastCodexAccountScopedRefreshGuard = Self.codexScopedRefreshGuard(for: account) self.snapshots[.codex] = snapshot if let sourceLabel { self.lastSourceLabels[.codex] = sourceLabel diff --git a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift index e7bf93a81..9fda508d7 100644 --- a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift +++ b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift @@ -931,4 +931,106 @@ extension CodexAccountScopedRefreshTests { #expect(liveSnapshot.primary?.windowMinutes == 0) #expect(liveSnapshot.primary?.resetsAt == nil) } + + @Test + func `ignores active reset cache and email history after live auth fingerprint changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-active-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-333333333333")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-active-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-active-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-active-auth@example.com", + authFingerprint: "current-live-active-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-active-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-active-auth@example.com", + providerAccountID: "acct-managed-active-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-active-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleSessionReset = now.addingTimeInterval(2 * 60 * 60) + let staleWeeklyReset = now.addingTimeInterval(2 * 24 * 60 * 60) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .emailOnly(normalizedEmail: "live-active-auth@example.com"), + accountKey: "live-active-auth@example.com", + authFingerprint: "stale-live-active-auth") + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: staleSessionReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "live-active-auth@example.com", + accountOrganization: nil, + loginMethod: nil)) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "live-active-auth@example.com") + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 44, resetsAt: staleSessionReset), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 55, resetsAt: staleWeeklyReset), + ]), + ], + ]) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 0) + #expect(liveSnapshot.primary?.resetsAt == nil) + #expect(liveSnapshot.secondary == nil) + } } From 0401a5c8713989e2fa2b685b71edfce6ccf08cc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 08:38:33 +0100 Subject: [PATCH 51/93] fix: tolerate Codex token refresh fingerprint churn --- .../Codex/UsageStore+CodexAccountState.swift | 19 ++++++++--- .../CodexAccountScopedRefreshTests.swift | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index ff8470c39..f79b032a6 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -162,7 +162,7 @@ extension UsageStore { { let currentGuard = self.currentCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -184,7 +184,7 @@ extension UsageStore { func shouldApplyCodexScopedFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { let currentGuard = self.currentCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -196,7 +196,7 @@ extension UsageStore { func shouldApplyCodexScopedNonUsageResult(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { let currentGuard = self.currentCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } guard expectedGuard.identity != .unresolved else { return false } return currentGuard.identity == expectedGuard.identity } @@ -208,7 +208,7 @@ extension UsageStore { let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) let currentGuard = self.currentCodexOpenAIWebRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -309,6 +309,17 @@ extension UsageStore { return true } + private static func codexGuardAuthFingerprintAllowsApply( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + if self.codexGuardAuthFingerprintMatches(lhs, rhs) { + return true + } + guard rhs.identity != .unresolved else { return false } + return lhs.identity == rhs.identity + } + func currentCodexAuthFingerprint(source: CodexActiveSource) -> String? { let snapshot = self.settings.codexAccountReconciliationSnapshot switch source { diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 91f0b5da1..a32e2d2b9 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -126,6 +126,38 @@ struct CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } + @Test + func `same account token refresh fingerprint change keeps codex usage success`() async { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-change") + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + #expect(store.errors[.codex] == nil) + } + @Test func `stale codex usage failure does not clear newer account snapshot`() async { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-stale-failure") From e086ffbe21ce0690f5a537d60b7e067e18ed0dc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 08:42:37 +0100 Subject: [PATCH 52/93] fix: keep email-only Codex auth switches isolated --- .../Codex/UsageStore+CodexAccountState.swift | 2 +- .../CodexAccountScopedRefreshTests.swift | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index f79b032a6..53a674bb5 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -316,7 +316,7 @@ extension UsageStore { if self.codexGuardAuthFingerprintMatches(lhs, rhs) { return true } - guard rhs.identity != .unresolved else { return false } + guard case .providerAccount = rhs.identity else { return false } return lhs.identity == rhs.identity } diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index a32e2d2b9..4d7fc6a97 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -158,6 +158,37 @@ struct CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } + @Test + func `same email email-only auth fingerprint switch discards stale codex usage success`() async { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-email-only-fingerprint-switch") + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + @Test func `stale codex usage failure does not clear newer account snapshot`() async { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-stale-failure") From fff91e20910b2565e30a50c5e69c12e0aaa4eb5b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 08:47:42 +0100 Subject: [PATCH 53/93] test: move Codex auth fingerprint apply coverage --- ...odexAccountAuthFingerprintApplyTests.swift | 70 +++++++++++++++++++ .../CodexAccountScopedRefreshTests.swift | 63 ----------------- 2 files changed, 70 insertions(+), 63 deletions(-) create mode 100644 Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift new file mode 100644 index 000000000..2f1fb9a29 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -0,0 +1,70 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `same account token refresh fingerprint change keeps codex usage success`() async { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-change") + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + #expect(store.errors[.codex] == nil) + } + + @Test + func `same email email-only auth fingerprint switch discards stale codex usage success`() async { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-email-only-fingerprint-switch") + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } +} diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 4d7fc6a97..91f0b5da1 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -126,69 +126,6 @@ struct CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } - @Test - func `same account token refresh fingerprint change keeps codex usage success`() async { - let settings = self.makeSettingsStore( - suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-change") - settings.refreshFrequency = .manual - settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( - email: "alpha@example.com", - authFingerprint: "old-token-material", - codexHomePath: "/Users/test/.codex", - observedAt: Date(), - identity: .providerAccount(id: "acct-alpha")) - - let store = self.makeUsageStore(settings: settings) - let blocker = BlockingCodexFetchStrategy() - self.installBlockingCodexProvider(on: store, blocker: blocker) - - let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } - await blocker.waitUntilStarted() - settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( - email: "alpha@example.com", - authFingerprint: "new-token-material", - codexHomePath: "/Users/test/.codex", - observedAt: Date(), - identity: .providerAccount(id: "acct-alpha")) - await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) - await refreshTask.value - - #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) - #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") - #expect(store.errors[.codex] == nil) - } - - @Test - func `same email email-only auth fingerprint switch discards stale codex usage success`() async { - let settings = self.makeSettingsStore( - suite: "CodexAccountScopedRefreshTests-email-only-fingerprint-switch") - settings.refreshFrequency = .manual - settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( - email: "alpha@example.com", - authFingerprint: "old-email-only-auth", - codexHomePath: "/Users/test/.codex", - observedAt: Date(), - identity: .emailOnly(normalizedEmail: "alpha@example.com")) - - let store = self.makeUsageStore(settings: settings) - let blocker = BlockingCodexFetchStrategy() - self.installBlockingCodexProvider(on: store, blocker: blocker) - - let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } - await blocker.waitUntilStarted() - settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( - email: "alpha@example.com", - authFingerprint: "new-email-only-auth", - codexHomePath: "/Users/test/.codex", - observedAt: Date(), - identity: .emailOnly(normalizedEmail: "alpha@example.com")) - await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) - await refreshTask.value - - #expect(store.snapshots[.codex] == nil) - #expect(store.errors[.codex] == nil) - } - @Test func `stale codex usage failure does not clear newer account snapshot`() async { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-stale-failure") From e0a99372f5ad1aeab6612fc2bc2b952c9808109f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 10:01:53 +0100 Subject: [PATCH 54/93] fix: guard stacked Codex account refresh apply --- .../CodexBar/UsageStore+TokenAccounts.swift | 161 +++++- ...exAccountVisibleHistoryBackfillTests.swift | 504 ++++++++++++++++++ 2 files changed, 649 insertions(+), 16 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4f71a82cf..b548122cc 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -80,6 +80,9 @@ extension UsageStore { let originalSelectionSource = originalVisibleAccountID.flatMap { projection.source(forVisibleAccountID: $0) } + let originalVisibleAccount = originalVisibleAccountID.flatMap { id in + accounts.first { $0.id == id } + } let priorByAccountID = Dictionary(uniqueKeysWithValues: self.codexAccountSnapshots.map { ($0.id, $0) }) var snapshots: [CodexAccountUsageSnapshot] = [] var selectedOutcome: ProviderFetchOutcome? @@ -115,35 +118,161 @@ extension UsageStore { } } + let currentProjection = self.freshCodexVisibleAccountProjectionForStaleResultGuard() + let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in + guard let currentAccount = Self.currentCodexVisibleAccount( + matching: snapshot.account, + projection: currentProjection) + else { + return nil + } + guard currentAccount != snapshot.account else { return snapshot } + return CodexAccountUsageSnapshot( + account: currentAccount, + snapshot: Self.codexVisibleAccountSnapshotRelabeledForCurrentProjection( + snapshot.snapshot, + account: currentAccount), + error: snapshot.error, + sourceLabel: snapshot.sourceLabel) + } let shouldPreservePriorState = !sawAnyNonCancellationOutcome && - snapshots.allSatisfy { $0.snapshot == nil } + currentSnapshots.allSatisfy { $0.snapshot == nil } if !shouldPreservePriorState { - self.codexAccountSnapshots = snapshots - self.codexAccountUsageSnapshotStore?.store(snapshots) + self.codexAccountSnapshots = currentSnapshots + self.codexAccountUsageSnapshotStore?.store(currentSnapshots) } let selectionStillMatches = self.codexVisibleSelectionStillMatches( originalVisibleAccountID: originalVisibleAccountID, - originalSelectionSource: originalSelectionSource) - if let selectedOutcome, let selectedAccount, selectionStillMatches { - await self.applySelectedCodexVisibleAccountOutcome( + originalSelectionSource: originalSelectionSource, + originalAccount: originalVisibleAccount, + currentProjection: currentProjection) + guard let selectedOutcome, let selectedAccount else { return } + guard selectionStillMatches else { + _ = self.prepareCodexAccountScopedRefreshIfNeeded() + return + } + + let currentSelectedAccount = Self.currentCodexVisibleAccount( + matching: selectedAccount, + projection: currentProjection) + if let currentSelectedAccount { + let currentSelectedSnapshot = Self.codexVisibleAccountSnapshotRelabeledForCurrentProjection( + selectedSnapshot, + account: currentSelectedAccount) + if self.shouldApplySelectedCodexVisibleAccountOutcome( selectedOutcome, - account: selectedAccount, - snapshot: selectedSnapshot, - sourceLabel: selectedSourceLabel) + snapshot: currentSelectedSnapshot) + { + await self.applySelectedCodexVisibleAccountOutcome( + selectedOutcome, + account: currentSelectedAccount, + snapshot: currentSelectedSnapshot, + sourceLabel: selectedSourceLabel) + } + } else { + _ = self.prepareCodexAccountScopedRefreshIfNeeded() } } func codexVisibleSelectionStillMatches( originalVisibleAccountID: String?, - originalSelectionSource: CodexActiveSource?) -> Bool + originalSelectionSource: CodexActiveSource?, + originalAccount: CodexVisibleAccount? = nil, + currentProjection: CodexVisibleAccountProjection? = nil) -> Bool { - let currentProjection = self.settings.codexVisibleAccountProjection - let currentSelectionSource = originalVisibleAccountID.flatMap { - currentProjection.source(forVisibleAccountID: $0) + let currentProjection = currentProjection ?? self.settings.codexVisibleAccountProjection + let currentActiveAccount = currentProjection.activeVisibleAccountID.flatMap { id in + currentProjection.visibleAccounts.first { $0.id == id } + } + let currentSelectionSource = currentActiveAccount?.selectionSource + if currentProjection.activeVisibleAccountID == originalVisibleAccountID, + currentSelectionSource == originalSelectionSource + { + return true + } + guard let originalAccount, let currentActiveAccount, currentSelectionSource == originalSelectionSource else { + return false + } + return Self.codexVisibleAccountMatchesCurrentProjection(originalAccount, account: currentActiveAccount) + } + + private func freshCodexVisibleAccountProjectionForStaleResultGuard() -> CodexVisibleAccountProjection { + // Auth files can change while account fetches are in flight, so stale-result guards must bypass the + // short-lived reconciliation cache used for normal menu rendering. + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + return self.settings.codexVisibleAccountProjection + } + + private static func currentCodexVisibleAccount( + matching account: CodexVisibleAccount, + projection: CodexVisibleAccountProjection) -> CodexVisibleAccount? + { + if let currentAccount = projection.visibleAccounts.first(where: { $0.id == account.id }), + self.codexVisibleAccountMatchesCurrentProjection(account, account: currentAccount) + { + return currentAccount + } + return projection.visibleAccounts.first { + self.codexVisibleAccountMatchesCurrentProjection(account, account: $0) + } + } + + private static func codexVisibleAccountSnapshotRelabeledForCurrentProjection( + _ snapshot: UsageSnapshot?, + account: CodexVisibleAccount) -> UsageSnapshot? + { + guard let snapshot else { return nil } + let existing = snapshot.identity(for: .codex) + return snapshot.withIdentity(ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: existing?.accountOrganization, + loginMethod: existing?.loginMethod ?? account.workspaceLabel)) + } + + private static func codexVisibleAccountMatchesCurrentProjection( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount) -> Bool + { + guard prior.selectionSource == account.selectionSource else { return false } + + let priorEmail = CodexIdentityResolver.normalizeEmail(prior.email) + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email) + + let priorWorkspaceID = self.normalizedCodexVisibleAccountText(prior.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let accountWorkspaceID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + if priorWorkspaceID != nil || accountWorkspaceID != nil { + guard priorWorkspaceID == accountWorkspaceID else { return false } + switch account.selectionSource { + case .managedAccount: + return true + case .liveSystem: + return priorEmail != nil && priorEmail == accountEmail + } + } + + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + guard priorAuthFingerprint == accountAuthFingerprint else { return false } + } + + return priorEmail != nil && priorEmail == accountEmail + } + + func shouldApplySelectedCodexVisibleAccountOutcome( + _ outcome: ProviderFetchOutcome, + snapshot: UsageSnapshot?) -> Bool + { + switch outcome.result { + case .success: + snapshot != nil + case .failure: + true } - return currentProjection.activeVisibleAccountID == originalVisibleAccountID && - currentSelectionSource == originalSelectionSource } func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { @@ -909,7 +1038,7 @@ extension UsageStore { self.errors[.codex] = nil self.failureGates[.codex]?.recordSuccess() self.rememberLiveSystemCodexEmailIfNeeded(snapshot.accountEmail(for: .codex)) - self.seedCodexAccountScopedRefreshGuard(accountEmail: snapshot.accountEmail(for: .codex)) + self.seedCodexAccountScopedRefreshGuard(accountEmail: account.email) await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: snapshot) self.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) case let .failure(error): diff --git a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift index 9fda508d7..b97059d18 100644 --- a/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift +++ b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift @@ -1033,4 +1033,508 @@ extension CodexAccountScopedRefreshTests { #expect(liveSnapshot.primary?.resetsAt == nil) #expect(liveSnapshot.secondary == nil) } + + @Test + func `stacked visible refresh skips selected apply after live auth fingerprint changes`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-444444444444")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-auth@example.com", + authFingerprint: "old-live-selected-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "selected-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-auth@example.com", + providerAccountID: "acct-managed-selected-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let priorDisplayedSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-auth@example.com", + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(priorDisplayedSnapshot, provider: .codex) + store.lastKnownResetSnapshots[.codex] = priorDisplayedSnapshot + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-auth@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-auth@example.com", + authFingerprint: "new-live-selected-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "selected-auth@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-auth@example.com", + accountOrganization: nil, + loginMethod: nil)))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + } + + @Test + func `stacked visible refresh keeps selected apply after live token fingerprint rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-token-rotation") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-888888888888")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-token-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-token-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-token@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-token", + authFingerprint: "old-live-selected-token", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-token")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-token@example.com", + providerAccountID: "acct-managed-selected-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-token", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + let now = Date() + let reset = now.addingTimeInterval(2 * 60 * 60) + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-token@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-token@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-token", + authFingerprint: "new-live-selected-token", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-token")) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-token@example.com", + accountOrganization: nil, + loginMethod: "Pro")))) + await refreshTask.value + + let selectedSnapshot = try #require(store.snapshots[.codex]) + #expect(selectedSnapshot.primary?.usedPercent == 77) + #expect(selectedSnapshot.accountEmail(for: .codex) == "selected-token@example.com") + #expect(selectedSnapshot.loginMethod(for: .codex) == "Pro") + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-live-selected-token") + + let liveRow = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }) + #expect(liveRow.account.authFingerprint == "new-live-selected-token") + #expect(liveRow.snapshot?.primary?.usedPercent == 77) + + let persistedLive = try #require(snapshotStore.storedSnapshots.first { + $0.account.selectionSource == .liveSystem + }) + #expect(persistedLive.account.authFingerprint == "new-live-selected-token") + #expect(persistedLive.snapshot?.primary?.usedPercent == 77) + } + + @Test + func `stacked visible refresh clears selected state after live account email changes`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-email-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-777777777777")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-email-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-email-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "old-selected@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-email", + authFingerprint: "old-live-selected-email", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-email")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-email@example.com", + providerAccountID: "acct-managed-selected-email", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-email", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let priorDisplayedSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-selected@example.com", + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(priorDisplayedSnapshot, provider: .codex) + store.lastKnownResetSnapshots[.codex] = priorDisplayedSnapshot + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-email@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "new-selected@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-email", + authFingerprint: "new-live-selected-email", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-email")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-selected@example.com", + accountOrganization: nil, + loginMethod: nil)))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.codexAccountSnapshots.isEmpty) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + } + + @Test + func `stacked visible refresh keeps selected apply after provider account email changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-provider-email-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-555555555555")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-666666666666")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-provider-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-provider-email-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let originalTarget = ManagedCodexAccount( + id: targetID, + email: "old-provider@example.com", + providerAccountID: "acct-provider-email", + workspaceLabel: "Provider Team", + workspaceAccountID: "acct-provider-email", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let updatedTarget = ManagedCodexAccount( + id: targetID, + email: "new-provider@example.com", + providerAccountID: "acct-provider-email", + workspaceLabel: "Provider Team", + workspaceAccountID: "acct-provider-email", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 3, + lastAuthenticatedAt: 3) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling-provider@example.com", + providerAccountID: "acct-provider-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-provider-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [originalTarget, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + let reset = now.addingTimeInterval(90 * 60) + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "sibling-provider@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [updatedTarget, siblingAccount])) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-provider@example.com", + accountOrganization: nil, + loginMethod: "Pro")))) + await refreshTask.value + + let selectedSnapshot = try #require(store.snapshots[.codex]) + #expect(selectedSnapshot.primary?.usedPercent == 64) + #expect(selectedSnapshot.accountEmail(for: .codex) == "new-provider@example.com") + #expect(selectedSnapshot.loginMethod(for: .codex) == "Pro") + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == reset) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "new-provider@example.com") + + let targetRow = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-provider-email" + }) + #expect(targetRow.account.email == "new-provider@example.com") + #expect(targetRow.snapshot?.primary?.usedPercent == 64) + #expect(targetRow.snapshot?.accountEmail(for: .codex) == "new-provider@example.com") + #expect(targetRow.snapshot?.loginMethod(for: .codex) == "Pro") + + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-provider-email" + }) + #expect(persistedTarget.account.email == "new-provider@example.com") + #expect(persistedTarget.snapshot?.primary?.usedPercent == 64) + #expect(persistedTarget.snapshot?.accountEmail(for: .codex) == "new-provider@example.com") + #expect(persistedTarget.snapshot?.loginMethod(for: .codex) == "Pro") + } } From 1a1ef6dbb08eda0d6c9f91d242c404caf466a8bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 10:05:22 +0100 Subject: [PATCH 55/93] test: isolate Codex account info coverage --- Tests/CodexBarTests/UsageStoreCoverageTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index c0d6bbba3..9ffe6dcf9 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -80,6 +80,8 @@ struct UsageStoreCoverageTests { try Self.writeCodexAuthFile(homeURL: home, email: "first@example.com", plan: "plus") let env = ["CODEX_HOME": home.path] + settings._test_codexReconciliationEnvironment = env + defer { settings._test_codexReconciliationEnvironment = nil } let store = UsageStore( fetcher: UsageFetcher(environment: env), browserDetection: BrowserDetection(cacheTTL: 0), From 3dcb72891fdff4e7ab2af57ce55ae2d35567b5a5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 10:22:24 +0100 Subject: [PATCH 56/93] fix: harden Codex refresh guard cache invalidation --- .../Codex/UsageStore+CodexAccountState.swift | 32 ++++-- .../Codex/UsageStore+CodexRefresh.swift | 6 +- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 4 +- Sources/CodexBar/UsageStore+Refresh.swift | 2 +- Sources/CodexBar/UsageStore.swift | 2 +- ...odexAccountAuthFingerprintApplyTests.swift | 103 ++++++++++++++++++ 6 files changed, 134 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 53a674bb5..410b2d64a 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -47,7 +47,7 @@ extension UsageStore { phaseDidChange?(.credits) if self.settings.codexCookieSource.isEnabled { - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( force: true, expectedGuard: expectedGuard, @@ -68,7 +68,7 @@ extension UsageStore { @discardableResult func prepareCodexAccountScopedRefreshIfNeeded() -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard( + let currentGuard = self.freshCodexAccountScopedRefreshGuard( preferCurrentSnapshot: false, allowLastKnownLiveFallback: false) let previousGuard = self.lastCodexAccountScopedRefreshGuard @@ -156,11 +156,26 @@ extension UsageStore { authFingerprint: self.currentCodexAuthFingerprint(source: source)) } + func freshCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: Bool = true, + allowLastKnownLiveFallback: Bool = true) -> CodexAccountScopedRefreshGuard + { + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + return self.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: preferCurrentSnapshot, + allowLastKnownLiveFallback: allowLastKnownLiveFallback) + } + + func freshCodexOpenAIWebRefreshGuard() -> CodexAccountScopedRefreshGuard { + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + return self.currentCodexOpenAIWebRefreshGuard() + } + func shouldApplyCodexUsageResult( expectedGuard: CodexAccountScopedRefreshGuard, usage: UsageSnapshot) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } @@ -182,7 +197,7 @@ extension UsageStore { } func shouldApplyCodexScopedFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } @@ -194,7 +209,7 @@ extension UsageStore { } func shouldApplyCodexScopedNonUsageResult(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } guard expectedGuard.identity != .unresolved else { return false } @@ -206,7 +221,7 @@ extension UsageStore { routingTargetEmail: String?) -> Bool { let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) - let currentGuard = self.currentCodexOpenAIWebRefreshGuard() + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } @@ -316,8 +331,9 @@ extension UsageStore { if self.codexGuardAuthFingerprintMatches(lhs, rhs) { return true } - guard case .providerAccount = rhs.identity else { return false } - return lhs.identity == rhs.identity + guard case .providerAccount = rhs.identity, lhs.identity == rhs.identity else { return false } + guard case .liveSystem = lhs.source else { return true } + return lhs.accountKey != nil && lhs.accountKey == rhs.accountKey } func currentCodexAuthFingerprint(source: CodexActiveSource) -> String? { diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index dc0608017..1d9731eab 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -15,7 +15,7 @@ extension UsageStore { func scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) { let refreshKey = self.codexCreditsRefreshKey( - expectedGuard: self.currentCodexAccountScopedRefreshGuard()) + expectedGuard: self.freshCodexAccountScopedRefreshGuard()) if let existing = self.creditsRefreshTask, !existing.isCancelled, self.creditsRefreshTaskKey == refreshKey @@ -76,13 +76,13 @@ extension UsageStore { func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async { guard self.isEnabled(.codex) else { return } - var expectedGuard = self.currentCodexAccountScopedRefreshGuard() + var expectedGuard = self.freshCodexAccountScopedRefreshGuard() if expectedGuard.identity == .unresolved, let minimumSnapshotUpdatedAt, case .liveSystem = expectedGuard.source { _ = await self.waitForCodexSnapshotOrRefreshCompletion(minimumUpdatedAt: minimumSnapshotUpdatedAt) - expectedGuard = self.currentCodexAccountScopedRefreshGuard() + expectedGuard = self.freshCodexAccountScopedRefreshGuard() } guard expectedGuard.identity != .unresolved, expectedGuard.accountKey != nil diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 8effa6c3b..6ca436479 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -108,7 +108,7 @@ extension UsageStore { "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", ]) - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } @@ -832,7 +832,7 @@ extension UsageStore { allowCurrentSnapshotFallback: true, allowLastKnownLiveFallback: false) _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( force: true, expectedGuard: expectedGuard, diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 8f70bb7a2..eabe08c78 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -23,7 +23,7 @@ extension UsageStore { func refreshProvider(_ provider: UsageProvider, allowDisabled: Bool = false) async { self.prepareRefreshState(for: provider) guard let spec = await self.providerRefreshSpec(provider) else { return } - let codexExpectedGuard = provider == .codex ? self.currentCodexAccountScopedRefreshGuard() : nil + let codexExpectedGuard = provider == .codex ? self.freshCodexAccountScopedRefreshGuard() : nil if !spec.isEnabled(), !allowDisabled { await self.clearDisabledProviderRefreshState(provider) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5f62b0f95..fad66559b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -620,7 +620,7 @@ final class UsageStore { "phase": openAIWebRefreshPhase == .startup ? "startup" : "regular", ]) if shouldRefreshOpenAIWeb { - let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() + let codexDashboardGuard = self.freshCodexOpenAIWebRefreshGuard() if forceTokenUsage { await self.refreshOpenAIDashboardIfNeeded( force: true, diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 2f1fb9a29..67c88b683 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -7,8 +7,13 @@ import Testing extension CodexAccountScopedRefreshTests { @Test func `same account token refresh fingerprint change keeps codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 let settings = self.makeSettingsStore( suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-change") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } settings.refreshFrequency = .manual settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( email: "alpha@example.com", @@ -16,6 +21,7 @@ extension CodexAccountScopedRefreshTests { codexHomePath: "/Users/test/.codex", observedAt: Date(), identity: .providerAccount(id: "acct-alpha")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot let store = self.makeUsageStore(settings: settings) let blocker = BlockingCodexFetchStrategy() @@ -29,6 +35,10 @@ extension CodexAccountScopedRefreshTests { codexHomePath: "/Users/test/.codex", observedAt: Date(), identity: .providerAccount(id: "acct-alpha")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) await refreshTask.value @@ -37,10 +47,98 @@ extension CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } + @Test + func `stale auth fingerprint cache at refresh start keeps current codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-stale-start-cache-current-auth") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 33))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 33) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-email-only-auth") + #expect(store.errors[.codex] == nil) + } + + @Test + func `same provider account live email change discards stale codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-provider-email-change") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "old@example.com", + authFingerprint: "old-provider-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-shared")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "new@example.com", + authFingerprint: "new-provider-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-shared")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(self.codexSnapshot(email: "old@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + @Test func `same email email-only auth fingerprint switch discards stale codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 let settings = self.makeSettingsStore( suite: "CodexAccountScopedRefreshTests-email-only-fingerprint-switch") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } settings.refreshFrequency = .manual settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( email: "alpha@example.com", @@ -48,6 +146,7 @@ extension CodexAccountScopedRefreshTests { codexHomePath: "/Users/test/.codex", observedAt: Date(), identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot let store = self.makeUsageStore(settings: settings) let blocker = BlockingCodexFetchStrategy() @@ -61,6 +160,10 @@ extension CodexAccountScopedRefreshTests { codexHomePath: "/Users/test/.codex", observedAt: Date(), identity: .emailOnly(normalizedEmail: "alpha@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) await refreshTask.value From ccca33bdb416cef47d353d349ea0fcddc2a82821 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 10:37:00 +0100 Subject: [PATCH 57/93] fix: discard stale Codex auth failures --- .../Codex/UsageStore+CodexAccountState.swift | 25 ++- .../Codex/UsageStore+CodexRefresh.swift | 10 +- .../CodexBar/UsageStore+TokenAccounts.swift | 43 +++- ...odexAccountAuthFingerprintApplyTests.swift | 183 ++++++++++++++++++ 4 files changed, 245 insertions(+), 16 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 410b2d64a..37e0e84ac 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -177,7 +177,7 @@ extension UsageStore { { let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -199,7 +199,7 @@ extension UsageStore { func shouldApplyCodexScopedFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -208,10 +208,25 @@ extension UsageStore { return currentGuard.identity == .unresolved } + func codexScopedNonUsageSuccessApplyGuard( + expectedGuard: CodexAccountScopedRefreshGuard) -> CodexAccountScopedRefreshGuard? + { + let currentGuard = self.freshCodexAccountScopedRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return nil } + guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return nil } + guard expectedGuard.identity != .unresolved else { return nil } + guard currentGuard.identity == expectedGuard.identity else { return nil } + return currentGuard + } + func shouldApplyCodexScopedNonUsageResult(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { + self.codexScopedNonUsageSuccessApplyGuard(expectedGuard: expectedGuard) != nil + } + + func shouldApplyCodexScopedNonUsageFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } guard expectedGuard.identity != .unresolved else { return false } return currentGuard.identity == expectedGuard.identity } @@ -223,7 +238,7 @@ extension UsageStore { let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) let currentGuard = self.freshCodexOpenAIWebRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintAllowsApply(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -324,7 +339,7 @@ extension UsageStore { return true } - private static func codexGuardAuthFingerprintAllowsApply( + private static func codexGuardAuthFingerprintAllowsUsageApply( _ lhs: CodexAccountScopedRefreshGuard, _ rhs: CodexAccountScopedRefreshGuard) -> Bool { diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index 1d9731eab..aa7ce2c5a 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -92,15 +92,15 @@ extension UsageStore { do { let credits = try await self.loadLatestCodexCredits() guard !Task.isCancelled else { return } - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard let applyGuard = self.codexScopedNonUsageSuccessApplyGuard(expectedGuard: expectedGuard) else { return } await MainActor.run { self.credits = credits self.lastCreditsError = nil self.lastCreditsSnapshot = credits - self.lastCreditsSnapshotAccountKey = expectedGuard.accountKey + self.lastCreditsSnapshotAccountKey = applyGuard.accountKey self.lastCreditsSource = .api self.creditsFailureStreak = 0 - self.lastCodexAccountScopedRefreshGuard = expectedGuard + self.lastCodexAccountScopedRefreshGuard = applyGuard } let codexSnapshot = await MainActor.run { self.snapshots[.codex] @@ -123,7 +123,7 @@ extension UsageStore { guard !Task.isCancelled else { return } let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard self.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard) else { return } await MainActor.run { if let cached = self.lastCreditsSnapshot, self.lastCreditsSnapshotAccountKey == expectedGuard.accountKey @@ -140,7 +140,7 @@ extension UsageStore { return } - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard self.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard) else { return } await MainActor.run { self.creditsFailureStreak += 1 if let cached = self.lastCreditsSnapshot, diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index b548122cc..68fcde40b 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -122,7 +122,8 @@ extension UsageStore { let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in guard let currentAccount = Self.currentCodexVisibleAccount( matching: snapshot.account, - projection: currentProjection) + projection: currentProjection, + allowProviderAccountAuthFingerprintMismatch: snapshot.error == nil) else { return nil } @@ -153,9 +154,16 @@ extension UsageStore { return } + let allowSelectedAuthFingerprintMismatch = switch selectedOutcome.result { + case .success: + true + case .failure: + false + } let currentSelectedAccount = Self.currentCodexVisibleAccount( matching: selectedAccount, - projection: currentProjection) + projection: currentProjection, + allowProviderAccountAuthFingerprintMismatch: allowSelectedAuthFingerprintMismatch) if let currentSelectedAccount { let currentSelectedSnapshot = Self.codexVisibleAccountSnapshotRelabeledForCurrentProjection( selectedSnapshot, @@ -206,15 +214,22 @@ extension UsageStore { private static func currentCodexVisibleAccount( matching account: CodexVisibleAccount, - projection: CodexVisibleAccountProjection) -> CodexVisibleAccount? + projection: CodexVisibleAccountProjection, + allowProviderAccountAuthFingerprintMismatch: Bool = true) -> CodexVisibleAccount? { if let currentAccount = projection.visibleAccounts.first(where: { $0.id == account.id }), - self.codexVisibleAccountMatchesCurrentProjection(account, account: currentAccount) + self.codexVisibleAccountMatchesCurrentProjection( + account, + account: currentAccount, + allowProviderAccountAuthFingerprintMismatch: allowProviderAccountAuthFingerprintMismatch) { return currentAccount } return projection.visibleAccounts.first { - self.codexVisibleAccountMatchesCurrentProjection(account, account: $0) + self.codexVisibleAccountMatchesCurrentProjection( + account, + account: $0, + allowProviderAccountAuthFingerprintMismatch: allowProviderAccountAuthFingerprintMismatch) } } @@ -233,7 +248,8 @@ extension UsageStore { private static func codexVisibleAccountMatchesCurrentProjection( _ prior: CodexVisibleAccount, - account: CodexVisibleAccount) -> Bool + account: CodexVisibleAccount, + allowProviderAccountAuthFingerprintMismatch: Bool = true) -> Bool { guard prior.selectionSource == account.selectionSource else { return false } @@ -246,6 +262,9 @@ extension UsageStore { .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) if priorWorkspaceID != nil || accountWorkspaceID != nil { guard priorWorkspaceID == accountWorkspaceID else { return false } + if !allowProviderAccountAuthFingerprintMismatch { + guard self.codexVisibleAccountAuthFingerprintMatches(prior, account: account) else { return false } + } switch account.selectionSource { case .managedAccount: return true @@ -263,6 +282,18 @@ extension UsageStore { return priorEmail != nil && priorEmail == accountEmail } + private static func codexVisibleAccountAuthFingerprintMatches( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount) -> Bool + { + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + return priorAuthFingerprint == accountAuthFingerprint + } + return true + } + func shouldApplySelectedCodexVisibleAccountOutcome( _ outcome: ProviderFetchOutcome, snapshot: UsageSnapshot?) -> Bool diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 67c88b683..7bbb864d6 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -47,6 +47,189 @@ extension CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } + @Test + func `same account token refresh fingerprint change discards codex usage failure`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-failure") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .failure(TestRefreshError(message: "old token failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same account token refresh fingerprint change keeps codex credits success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-credits-success") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + let store = self.makeUsageStore(settings: settings) + store._test_codexCreditsLoaderOverride = { + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + return CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refreshCreditsIfNeeded() + + #expect(store.credits?.remaining == 42) + #expect(store.lastCreditsSnapshotAccountKey == "alpha@example.com") + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + #expect(store.lastCreditsError == nil) + } + + @Test + func `stacked visible refresh discards selected failure after managed token fingerprint rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-token-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-444444444444")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-333333333333")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-token-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-token-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-token@example.com", + providerAccountID: "acct-managed-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-token", + authFingerprint: "old-managed-token", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let updatedTarget = ManagedCodexAccount( + id: targetID, + email: "managed-token@example.com", + providerAccountID: "acct-managed-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-token", + authFingerprint: "new-managed-token", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 3, + lastAuthenticatedAt: 3) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-token-sibling@example.com", + providerAccountID: "acct-managed-token-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-token-sibling", + authFingerprint: "sibling-managed-token", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-token-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [updatedTarget, siblingAccount])) + await blocker.resume(with: .failure(TestRefreshError(message: "old managed token failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-token" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-token" + }) + } + @Test func `stale auth fingerprint cache at refresh start keeps current codex usage success`() async { SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 From 0cc9d7c7eb7e5fbcc2373e9340881a8a1aecb2bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 10:50:25 +0100 Subject: [PATCH 58/93] fix: separate Codex refresh keys by auth fingerprint --- .../Codex/UsageStore+CodexAccountState.swift | 20 ++- .../Codex/UsageStore+CodexRefresh.swift | 4 +- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 3 +- ...odexAccountAuthFingerprintApplyTests.swift | 162 ++++++++++++++++++ 4 files changed, 183 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 37e0e84ac..5bb1bc20e 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -238,7 +238,7 @@ extension UsageStore { let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) let currentGuard = self.freshCodexOpenAIWebRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { return currentGuard.identity == expectedGuard.identity @@ -256,9 +256,21 @@ extension UsageStore { expectedGuard: CodexAccountScopedRefreshGuard, routingTargetEmail: String?) -> Bool { - self.shouldApplyOpenAIDashboardRefreshGuard( - expectedGuard: expectedGuard, - routingTargetEmail: routingTargetEmail) + let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + + if expectedGuard.identity != .unresolved { + return currentGuard.identity == expectedGuard.identity + } + + guard case .liveSystem = expectedGuard.source else { return false } + guard currentGuard.identity == .unresolved else { return false } + return CodexIdentityResolver.normalizeEmail( + self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: true, + allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail } func codexDashboardKnownOwnerCandidates() -> [CodexDashboardKnownOwnerCandidate] { diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index aa7ce2c5a..540adf782 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -71,6 +71,7 @@ extension UsageStore { sourceKey, identityKey, expectedGuard.accountKey ?? "account:nil", + "auth:\(expectedGuard.authFingerprint ?? "nil")", ].joined(separator: "|") } @@ -92,7 +93,8 @@ extension UsageStore { do { let credits = try await self.loadLatestCodexCredits() guard !Task.isCancelled else { return } - guard let applyGuard = self.codexScopedNonUsageSuccessApplyGuard(expectedGuard: expectedGuard) else { return } + guard let applyGuard = self.codexScopedNonUsageSuccessApplyGuard( + expectedGuard: expectedGuard) else { return } await MainActor.run { self.credits = credits self.lastCreditsError = nil diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 6ca436479..ee95e76ec 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -878,7 +878,8 @@ extension UsageStore { let source = String(describing: expectedGuard?.source ?? self.settings.codexResolvedActiveSource) let identityKey = Self.codexIdentityGuardKey(expectedGuard?.identity ?? .unresolved) ?? "unresolved" let accountKey = Self.normalizeCodexAccountScopedKey(targetEmail) ?? "unknown" - return "\(source)|\(identityKey)|\(accountKey)" + let authFingerprint = CodexAuthFingerprint.normalize(expectedGuard?.authFingerprint) ?? "nil" + return "\(source)|\(identityKey)|\(accountKey)|auth:\(authFingerprint)" } private func actionableOpenAIDashboardImportFailure(targetEmail: String?) -> String? { diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 7bbb864d6..8e128243d 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -125,6 +125,168 @@ extension CodexAccountScopedRefreshTests { #expect(store.lastCreditsError == nil) } + @Test + func `credits refresh key separates same account auth fingerprints`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-credits-key-auth-fingerprint") + let store = self.makeUsageStore(settings: settings) + let oldGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .providerAccount(id: "acct-alpha"), + accountKey: "alpha@example.com", + authFingerprint: "old-token-material") + let newGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .providerAccount(id: "acct-alpha"), + accountKey: "alpha@example.com", + authFingerprint: "new-token-material") + + #expect(store.codexCreditsRefreshKey(expectedGuard: oldGuard) != + store.codexCreditsRefreshKey(expectedGuard: newGuard)) + } + + @Test + func `same account token refresh fingerprint change keeps dashboard success`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-success") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + return self.dashboard(email: "alpha@example.com", creditsRemaining: 64, usedPercent: 27) + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + + #expect(store.openAIDashboard?.creditsRemaining == 64) + #expect(store.openAIDashboard?.signedInEmail == "alpha@example.com") + #expect(store.lastOpenAIDashboardError == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + + @Test + func `dashboard refresh key separates same account auth fingerprints`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-key-auth-fingerprint") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let oldGuard = store.freshCodexOpenAIWebRefreshGuard() + let oldRefreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: oldGuard) + } + await blocker.waitUntilStarted(count: 1) + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let newGuard = store.freshCodexOpenAIWebRefreshGuard() + let newRefreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: newGuard) + } + + let didStartFreshRefresh = await blocker.waitUntilStartedWithin(count: 2) + #expect(didStartFreshRefresh) + guard didStartFreshRefresh else { + await blocker.resumeNext(with: .failure(TestRefreshError(message: "stale dashboard failure"))) + await oldRefreshTask.value + await newRefreshTask.value + return + } + await blocker.resumeNext(with: .failure(TestRefreshError(message: "old dashboard failure"))) + await blocker.resumeNext(with: .success(self.dashboard( + email: "alpha@example.com", + creditsRemaining: 64, + usedPercent: 27))) + await oldRefreshTask.value + await newRefreshTask.value + + #expect(store.openAIDashboard?.creditsRemaining == 64) + #expect(store.lastOpenAIDashboardError == nil) + } + + @Test + func `same account token refresh fingerprint change discards dashboard failure`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-failure") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + throw TestRefreshError(message: "old dashboard failure") + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + @Test func `stacked visible refresh discards selected failure after managed token fingerprint rotates`() async throws { let settings = self.makeSettingsStore( From 37d7721c048c1f854282312178785f1a3840129a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 10:58:52 +0100 Subject: [PATCH 59/93] fix: allow Codex usage success after auth material appears --- .../Codex/UsageStore+CodexAccountState.swift | 25 +++++-- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 22 +++--- ...odexAccountAuthFingerprintApplyTests.swift | 67 +++++++++++++++++++ 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 5bb1bc20e..d8dbde373 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -177,20 +177,35 @@ extension UsageStore { { let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } - guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return false } + let fingerprintsAllowApply = Self.codexGuardAuthFingerprintAllowsUsageApply( + currentGuard, + expectedGuard) + let expectedAuthFingerprint = CodexAuthFingerprint.normalize(expectedGuard.authFingerprint) + let currentAuthFingerprint = CodexAuthFingerprint.normalize(currentGuard.authFingerprint) + let canProveNilToCurrentAuth = expectedAuthFingerprint == nil && currentAuthFingerprint != nil + let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex)) + let resultAccountKey = Self.normalizeCodexAccountScopedKey(usage.accountEmail(for: .codex)) if expectedGuard.identity != .unresolved { - return currentGuard.identity == expectedGuard.identity + guard currentGuard.identity == expectedGuard.identity else { return false } + if fingerprintsAllowApply { return true } + guard canProveNilToCurrentAuth else { return false } + return resultIdentity == currentGuard.identity || + (resultAccountKey != nil && resultAccountKey == currentGuard.accountKey) } - let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex)) if currentGuard.identity != .unresolved { - return resultIdentity == currentGuard.identity + guard resultIdentity == currentGuard.identity else { return false } + return fingerprintsAllowApply || canProveNilToCurrentAuth } switch currentGuard.source { case .liveSystem: - return resultIdentity != .unresolved + guard resultIdentity != .unresolved else { return false } + if fingerprintsAllowApply { return true } + guard canProveNilToCurrentAuth else { return false } + guard let currentAccountKey = currentGuard.accountKey else { return true } + return resultAccountKey == currentAccountKey case .managedAccount: return false } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index ee95e76ec..9db3cb136 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -120,18 +120,24 @@ extension UsageStore { allowCodexUsageBackfill: Bool = true) async { guard self.shouldApplyOpenAIDashboardRefreshTask(token: refreshTaskToken) else { return } - if let expectedGuard, - !self.shouldApplyOpenAIDashboardRefreshGuard( - expectedGuard: expectedGuard, - routingTargetEmail: targetEmail) - { - return - } - let authority = self.evaluateCodexDashboardAuthority( dashboard: dash, sourceKind: .liveWeb, routingTargetEmail: targetEmail) + if let expectedGuard { + let shouldApply = switch authority.decision.disposition { + case .attach: + self.shouldApplyOpenAIDashboardRefreshGuard( + expectedGuard: expectedGuard, + routingTargetEmail: targetEmail) + case .displayOnly, .failClosed: + self.shouldApplyOpenAIWebNonSuccessResult( + expectedGuard: expectedGuard, + routingTargetEmail: targetEmail) + } + guard shouldApply else { return } + } + let attachedAccountEmail = self.codexDashboardAttachmentEmail(from: authority.input) await self.applyOpenAIDashboardAuthorityDecision( diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 8e128243d..461b08ab6 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -47,6 +47,35 @@ extension CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } + @Test + func `usage success applies when auth fingerprint appears after refresh starts`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-auth-fingerprint-appears") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: nil, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexAccountScopedRefreshGuard() + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + + #expect(store.shouldApplyCodexUsageResult( + expectedGuard: expectedGuard, + usage: self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + } + @Test func `same account token refresh fingerprint change discards codex usage failure`() async { SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 @@ -287,6 +316,44 @@ extension CodexAccountScopedRefreshTests { #expect(store.openAIDashboardRequiresLogin == false) } + @Test + func `same account token refresh fingerprint change discards dashboard policy failure`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-policy-failure") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + await store.applyOpenAIDashboard( + self.dashboard(email: "other@example.com", creditsRemaining: 64, usedPercent: 27), + targetEmail: "alpha@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + @Test func `stacked visible refresh discards selected failure after managed token fingerprint rotates`() async throws { let settings = self.makeSettingsStore( From 6b9588852b9692d051c9629f9be4c5d7d37a241c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 11:03:26 +0100 Subject: [PATCH 60/93] fix: refresh Codex dashboard authority cache --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 9db3cb136..4459f9aa6 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -120,6 +120,7 @@ extension UsageStore { allowCodexUsageBackfill: Bool = true) async { guard self.shouldApplyOpenAIDashboardRefreshTask(token: refreshTaskToken) else { return } + self.settings.invalidateCodexAccountReconciliationSnapshotCache() let authority = self.evaluateCodexDashboardAuthority( dashboard: dash, sourceKind: .liveWeb, From 959384626f65b4e16d0a9866d8f8277cfdf2c46d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 11:07:23 +0100 Subject: [PATCH 61/93] fix: read managed Codex auth fingerprint from home --- .../Codex/UsageStore+CodexAccountState.swift | 3 +- ...odexAccountAuthFingerprintApplyTests.swift | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index d8dbde373..b5a6aaf83 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -384,7 +384,8 @@ extension UsageStore { case .liveSystem: return CodexAuthFingerprint.normalize(snapshot.liveSystemAccount?.authFingerprint) case let .managedAccount(id): - return CodexAuthFingerprint.normalize(snapshot.storedAccounts.first { $0.id == id }?.authFingerprint) + guard let account = snapshot.storedAccounts.first(where: { $0.id == id }) else { return nil } + return CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) } } diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 461b08ab6..e25690947 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -459,6 +459,57 @@ extension CodexAccountScopedRefreshTests { }) } + @Test + func `managed failure guard reads current auth file fingerprint`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-auth-file-fingerprint") + settings.refreshFrequency = .manual + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-555555555555")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-auth-file-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-auth@example.com", + plan: "Pro", + accountId: "acct-managed-auth") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "managed-auth@example.com", + providerAccountID: "acct-managed-auth", + workspaceLabel: "Managed Auth", + workspaceAccountID: "acct-managed-auth", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexAccountScopedRefreshGuard() + #expect(expectedGuard.authFingerprint == oldFingerprint) + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-auth@example.com", + plan: "Team", + accountId: "acct-managed-auth") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + + #expect(store.freshCodexAccountScopedRefreshGuard().authFingerprint == newFingerprint) + #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) + } + @Test func `stale auth fingerprint cache at refresh start keeps current codex usage success`() async { SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 From d8bec3113ec1cf9521ebb603df7ff324450631ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 11:14:25 +0100 Subject: [PATCH 62/93] fix: preserve Codex reset cache across token rotation --- .../Codex/UsageStore+CodexAccountState.swift | 4 +- .../CodexBar/UsageStore+TokenAccounts.swift | 18 ++++++- ...odexAccountAuthFingerprintApplyTests.swift | 48 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index b5a6aaf83..07ba82d99 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -354,7 +354,7 @@ extension UsageStore { self.lastKnownLiveSystemCodexEmail = normalized } - static func codexGuardAuthFingerprintMatches( + nonisolated static func codexGuardAuthFingerprintMatches( _ lhs: CodexAccountScopedRefreshGuard, _ rhs: CodexAccountScopedRefreshGuard) -> Bool { @@ -366,7 +366,7 @@ extension UsageStore { return true } - private static func codexGuardAuthFingerprintAllowsUsageApply( + nonisolated static func codexGuardAuthFingerprintAllowsUsageApply( _ lhs: CodexAccountScopedRefreshGuard, _ rhs: CodexAccountScopedRefreshGuard) -> Bool { diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 68fcde40b..44858a1f8 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -697,7 +697,8 @@ extension UsageStore { func codexLastKnownResetSnapshot(matching guardValue: CodexAccountScopedRefreshGuard?) -> UsageSnapshot? { guard let guardValue, - self.lastCodexAccountScopedRefreshGuard == guardValue + let lastGuard = self.lastCodexAccountScopedRefreshGuard, + Self.codexScopedRefreshGuardAllowsResetBackfill(lastGuard, matching: guardValue) else { return nil } @@ -774,6 +775,21 @@ extension UsageStore { return guardValue.accountKey == accountKey } + private nonisolated static func codexScopedRefreshGuardAllowsResetBackfill( + _ lastGuard: CodexAccountScopedRefreshGuard, + matching expectedGuard: CodexAccountScopedRefreshGuard) -> Bool + { + guard lastGuard.source == expectedGuard.source else { return false } + if lastGuard == expectedGuard { return true } + guard lastGuard.identity != .unresolved, + lastGuard.identity == expectedGuard.identity, + lastGuard.accountKey == expectedGuard.accountKey + else { + return false + } + return self.codexGuardAuthFingerprintAllowsUsageApply(lastGuard, expectedGuard) + } + private nonisolated static func codexScopedRefreshGuard(for account: CodexVisibleAccount) -> CodexAccountScopedRefreshGuard { diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index e25690947..37bc1c831 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -47,6 +47,54 @@ extension CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } + @Test + func `same account token refresh fingerprint change keeps reset backfill`() async { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-reset-backfill") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let store = self.makeUsageStore(settings: settings) + let resetsAt = Date().addingTimeInterval(45 * 60) + store.lastCodexAccountScopedRefreshGuard = store.freshCodexAccountScopedRefreshGuard() + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: "resets soon"), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "alpha@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + self.installImmediateCodexProvider( + on: store, + snapshot: self.codexSnapshot(email: "alpha@example.com", usedPercent: 25)) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.snapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + } + @Test func `usage success applies when auth fingerprint appears after refresh starts`() { let settings = self.makeSettingsStore( From bf795eca9dc3c6fa33a40563145a54a09b8b662d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 11:33:36 +0100 Subject: [PATCH 63/93] fix: refresh managed Codex visible auth guards --- .../Codex/UsageStore+CodexAccountState.swift | 19 +- .../CodexBar/UsageStore+TokenAccounts.swift | 74 +++- ...odexAccountAuthFingerprintApplyTests.swift | 369 ++++++++++++++++++ 3 files changed, 446 insertions(+), 16 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 07ba82d99..60c294941 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -74,7 +74,9 @@ extension UsageStore { let previousGuard = self.lastCodexAccountScopedRefreshGuard self.lastCodexAccountScopedRefreshGuard = currentGuard - guard previousGuard != nil, previousGuard != currentGuard else { return false } + guard let previousGuard, + !Self.codexScopedRefreshGuardsMatchAccount(previousGuard, currentGuard) + else { return false } self.snapshots.removeValue(forKey: .codex) self.errors[.codex] = nil @@ -378,6 +380,21 @@ extension UsageStore { return lhs.accountKey != nil && lhs.accountKey == rhs.accountKey } + nonisolated static func codexScopedRefreshGuardsMatchAccount( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + guard lhs.source == rhs.source else { return false } + if lhs == rhs { return true } + guard lhs.identity != .unresolved, + lhs.identity == rhs.identity, + lhs.accountKey == rhs.accountKey + else { + return false + } + return self.codexGuardAuthFingerprintAllowsUsageApply(lhs, rhs) + } + func currentCodexAuthFingerprint(source: CodexActiveSource) -> String? { let snapshot = self.settings.codexAccountReconciliationSnapshot switch source { diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 44858a1f8..d6bb454d6 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -45,6 +45,11 @@ private struct CodexAccountFetchResult { let outcome: ProviderFetchOutcome } +private struct CodexManagedVisibleAccountRuntimeState { + let authFingerprint: String? + let workspaceAccountID: String? +} + extension UsageStore { static let tokenAccountMenuSnapshotLimit = 6 private static let codexSessionWindowMinutes = 5 * 60 @@ -66,7 +71,7 @@ extension UsageStore { } func refreshCodexVisibleAccountsForMenu() async { - let projection = self.settings.codexVisibleAccountProjection + let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() let accounts = self.limitedCodexVisibleAccounts( projection.visibleAccounts, snapshots: self.codexAccountSnapshots, @@ -118,7 +123,7 @@ extension UsageStore { } } - let currentProjection = self.freshCodexVisibleAccountProjectionForStaleResultGuard() + let currentProjection = self.freshCodexVisibleAccountProjectionForAccountRefresh() let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in guard let currentAccount = Self.currentCodexVisibleAccount( matching: snapshot.account, @@ -205,11 +210,58 @@ extension UsageStore { return Self.codexVisibleAccountMatchesCurrentProjection(originalAccount, account: currentActiveAccount) } - private func freshCodexVisibleAccountProjectionForStaleResultGuard() -> CodexVisibleAccountProjection { - // Auth files can change while account fetches are in flight, so stale-result guards must bypass the - // short-lived reconciliation cache used for normal menu rendering. + private func freshCodexVisibleAccountProjectionForAccountRefresh() -> CodexVisibleAccountProjection { + // Auth files can change while account fetches are in flight, so account refreshes bypass the + // short-lived reconciliation cache used for normal menu rendering and stale-result guards. self.settings.invalidateCodexAccountReconciliationSnapshotCache() - return self.settings.codexVisibleAccountProjection + let snapshot = self.settings.codexAccountReconciliationSnapshot + return Self.codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( + CodexVisibleAccountProjection.make(from: snapshot), + snapshot: snapshot) + } + + private nonisolated static func codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( + _ projection: CodexVisibleAccountProjection, + snapshot: CodexAccountReconciliationSnapshot) -> CodexVisibleAccountProjection + { + let managedRuntimeStates = Dictionary( + uniqueKeysWithValues: snapshot.storedAccounts.map { account in + let workspaceAccountID: String? = switch snapshot.runtimeIdentity(for: account) { + case let .providerAccount(id): + id + case .emailOnly, .unresolved: + nil + } + return (account.id, CodexManagedVisibleAccountRuntimeState( + authFingerprint: CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath), + workspaceAccountID: workspaceAccountID)) + }) + let visibleAccounts = projection.visibleAccounts.map { account in + guard case let .managedAccount(id) = account.selectionSource else { return account } + guard let runtimeState = managedRuntimeStates[id], + let authFingerprint = runtimeState.authFingerprint, + authFingerprint != account.authFingerprint + else { + return account + } + return CodexVisibleAccount( + id: account.id, + email: account.email, + workspaceLabel: account.workspaceLabel, + workspaceAccountID: runtimeState.workspaceAccountID, + authFingerprint: authFingerprint, + storedAccountID: account.storedAccountID, + selectionSource: account.selectionSource, + isActive: account.isActive, + isLive: account.isLive, + canReauthenticate: account.canReauthenticate, + canRemove: account.canRemove) + } + return CodexVisibleAccountProjection( + visibleAccounts: visibleAccounts, + activeVisibleAccountID: projection.activeVisibleAccountID, + liveVisibleAccountID: projection.liveVisibleAccountID, + hasUnreadableAddedAccountStore: projection.hasUnreadableAddedAccountStore) } private static func currentCodexVisibleAccount( @@ -779,15 +831,7 @@ extension UsageStore { _ lastGuard: CodexAccountScopedRefreshGuard, matching expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { - guard lastGuard.source == expectedGuard.source else { return false } - if lastGuard == expectedGuard { return true } - guard lastGuard.identity != .unresolved, - lastGuard.identity == expectedGuard.identity, - lastGuard.accountKey == expectedGuard.accountKey - else { - return false - } - return self.codexGuardAuthFingerprintAllowsUsageApply(lastGuard, expectedGuard) + self.codexScopedRefreshGuardsMatchAccount(lastGuard, expectedGuard) } private nonisolated static func codexScopedRefreshGuard(for account: CodexVisibleAccount) diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 37bc1c831..3db33bc87 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -95,6 +95,55 @@ extension CodexAccountScopedRefreshTests { #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") } + @Test + func `same account token refresh fingerprint change keeps scoped state during prepare`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-prepare") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let store = self.makeUsageStore(settings: settings) + let resetsAt = Date().addingTimeInterval(45 * 60) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: "resets soon"), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "alpha@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + store.snapshots[.codex] = cached + store.lastKnownResetSnapshots[.codex] = cached + store.credits = self.credits(remaining: 42) + store.lastCodexAccountScopedRefreshGuard = store.freshCodexAccountScopedRefreshGuard() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + let invalidated = store.prepareCodexAccountScopedRefreshIfNeeded() + + #expect(!invalidated) + #expect(store.snapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.credits?.remaining == 42) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + } + @Test func `usage success applies when auth fingerprint appears after refresh starts`() { let settings = self.makeSettingsStore( @@ -507,6 +556,326 @@ extension CodexAccountScopedRefreshTests { }) } + @Test + func `stacked visible refresh discards selected failure after managed auth file rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-121212121212")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-131313131313")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-file-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-file-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-file-token@example.com", + plan: "Pro", + accountId: "acct-managed-file-token") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-file-token@example.com", + providerAccountID: "acct-managed-file-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-file-token", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-file-token-sibling@example.com", + providerAccountID: "acct-managed-file-token-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-file-token-sibling", + authFingerprint: "sibling-managed-file-token", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-file-token-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-file-token@example.com", + plan: "Team", + accountId: "acct-managed-file-token") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .failure(TestRefreshError(message: "old managed auth file failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-file-token" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-file-token" + }) + } + + @Test + func `stacked visible refresh keeps selected failure when managed auth file rotated before start`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-current-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-161616161616")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-current-failure-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-current-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-current-failure@example.com", + plan: "Pro", + accountId: "acct-managed-current-failure") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-current-failure@example.com", + providerAccountID: "acct-managed-current-failure", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-current-failure", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-current-sibling@example.com", + providerAccountID: "acct-managed-current-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-current-sibling", + authFingerprint: "sibling-managed-current", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-current-failure@example.com", + plan: "Team", + accountId: "acct-managed-current-failure") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + throw TestRefreshError(message: "current managed auth file failure") + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-current-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + await store.refreshCodexVisibleAccountsForMenu() + + #expect(store.errors[.codex] == "current managed auth file failure") + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-managed-current-failure" + }) + #expect(targetSnapshot.error == "current managed auth file failure") + #expect(targetSnapshot.account.authFingerprint == newFingerprint) + let persistedTargetSnapshot = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-managed-current-failure" + }) + #expect(persistedTargetSnapshot.error == "current managed auth file failure") + #expect(persistedTargetSnapshot.account.authFingerprint == newFingerprint) + } + + @Test + func `stacked visible refresh discards selected success after managed auth file switches accounts`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-success") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-141414141414")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-151515151515")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-success-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-success-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-old-success@example.com", + plan: "Pro", + accountId: "acct-managed-old-success") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-old-success@example.com", + providerAccountID: "acct-managed-old-success", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-old-success", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-success-sibling@example.com", + providerAccountID: "acct-managed-success-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-success-sibling", + authFingerprint: "sibling-managed-success", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-success-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-new-success@example.com", + plan: "Pro", + accountId: "acct-managed-new-success") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-old-success@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-old-success" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-old-success" + }) + } + @Test func `managed failure guard reads current auth file fingerprint`() throws { let settings = self.makeSettingsStore( From 6039cb2f4113ec8ce931b9984e96875a4b65daaa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 11:51:34 +0100 Subject: [PATCH 64/93] fix: reject stale managed Codex email results --- .../Codex/UsageStore+CodexAccountState.swift | 17 ++- .../CodexBar/UsageStore+TokenAccounts.swift | 2 +- ...odexAccountAuthFingerprintApplyTests.swift | 55 ++++++++ ...AccountEmailOnlyHistoryBackfillTests.swift | 120 ++++++++++++++++++ 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 60c294941..53e22041e 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -187,11 +187,18 @@ extension UsageStore { let canProveNilToCurrentAuth = expectedAuthFingerprint == nil && currentAuthFingerprint != nil let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex)) let resultAccountKey = Self.normalizeCodexAccountScopedKey(usage.accountEmail(for: .codex)) + let resultMatchesCurrentAccountKey = Self.codexUsageResultAccountKeyMatchesCurrentGuard( + resultAccountKey, + currentGuard: currentGuard) if expectedGuard.identity != .unresolved { guard currentGuard.identity == expectedGuard.identity else { return false } - if fingerprintsAllowApply { return true } + if fingerprintsAllowApply { + guard case .managedAccount = currentGuard.source else { return true } + return resultMatchesCurrentAccountKey + } guard canProveNilToCurrentAuth else { return false } + guard resultMatchesCurrentAccountKey else { return false } return resultIdentity == currentGuard.identity || (resultAccountKey != nil && resultAccountKey == currentGuard.accountKey) } @@ -395,6 +402,14 @@ extension UsageStore { return self.codexGuardAuthFingerprintAllowsUsageApply(lhs, rhs) } + private nonisolated static func codexUsageResultAccountKeyMatchesCurrentGuard( + _ resultAccountKey: String?, + currentGuard: CodexAccountScopedRefreshGuard) -> Bool + { + guard let currentAccountKey = currentGuard.accountKey else { return true } + return resultAccountKey == currentAccountKey + } + func currentCodexAuthFingerprint(source: CodexActiveSource) -> String? { let snapshot = self.settings.codexAccountReconciliationSnapshot switch source { diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index d6bb454d6..d940b32df 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -688,7 +688,7 @@ extension UsageStore { { snapshots.append(lastKnown) } - if self.codexCanUseHistoricalResetBackfill(for: account), + if account.id != activeVisibleAccountID || self.codexCanUseHistoricalResetBackfill(for: account), let history = self.codexPlanHistoryResetBackfillSnapshot(for: account) { snapshots.append(history) diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 3db33bc87..217e5a25a 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -1010,6 +1010,61 @@ extension CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } + @Test + func `same provider account managed email change discards stale codex usage success`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-provider-email-change") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-161616161616")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-provider-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "old-managed@example.com", + plan: "Pro", + accountId: "acct-managed-shared") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "old-managed@example.com", + providerAccountID: "acct-managed-shared", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-shared", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "new-managed@example.com", + plan: "Pro", + accountId: "acct-managed-shared") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(self.codexSnapshot(email: "old-managed@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + @Test func `same email email-only auth fingerprint switch discards stale codex usage success`() async { SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 diff --git a/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift new file mode 100644 index 000000000..1b149c576 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift @@ -0,0 +1,120 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `backfills non active email only codex row from own history`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-non-active-email-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let activeID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-333333333333")) + let siblingID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-444444444444")) + let activeHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-active-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-sibling-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: activeHome, + email: "active-email-history@example.com", + plan: "Pro") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "sibling-email-history@example.com", + plan: "Pro") + let activeFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: activeHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let activeAccount = ManagedCodexAccount( + id: activeID, + email: "active-email-history@example.com", + workspaceLabel: "Active Team", + authFingerprint: activeFingerprint, + managedHomePath: activeHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling-email-history@example.com", + workspaceLabel: "Sibling Team", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [activeAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: activeHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: activeID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let sessionReset = now.addingTimeInterval(2 * 60 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + let siblingHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "sibling-email-history@example.com") + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + siblingHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 6, resetsAt: sessionReset), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 36, resetsAt: weeklyReset), + ]), + ], + ]) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: activeID), + identity: .emailOnly(normalizedEmail: "active-email-history@example.com"), + accountKey: "active-email-history@example.com", + authFingerprint: activeFingerprint) + self.installContextualCodexProvider(on: store) { context in + let isActive = context.env["CODEX_HOME"] == activeHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isActive ? 3 : 6, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let activeSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.email == "active-email-history@example.com" + }?.snapshot) + #expect(activeSnapshot.primary?.usedPercent == 3) + #expect(activeSnapshot.primary?.windowMinutes == 0) + #expect(activeSnapshot.primary?.resetsAt == nil) + + let siblingSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.email == "sibling-email-history@example.com" + }?.snapshot) + #expect(siblingSnapshot.primary?.usedPercent == 6) + #expect(siblingSnapshot.primary?.windowMinutes == 300) + #expect(siblingSnapshot.primary?.resetsAt == sessionReset) + #expect(siblingSnapshot.secondary?.usedPercent == 36) + #expect(siblingSnapshot.secondary?.resetsAt == weeklyReset) + let persistedSibling = try #require(snapshotStore.storedSnapshots.first { + $0.account.email == "sibling-email-history@example.com" + }?.snapshot) + #expect(persistedSibling.primary?.resetsAt == sessionReset) + #expect(persistedSibling.secondary?.resetsAt == weeklyReset) + } +} From a154c9831288725f4b1f59c8e627688fd93cbfbf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 12:04:23 +0100 Subject: [PATCH 65/93] fix: refresh migrated Codex visible account identity --- .../Codex/UsageStore+CodexAccountState.swift | 9 +- .../CodexBar/UsageStore+TokenAccounts.swift | 15 +- ...odexAccountAuthFingerprintApplyTests.swift | 243 ++++++++++++++++++ 3 files changed, 261 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 53e22041e..6e7ae672e 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -189,6 +189,7 @@ extension UsageStore { let resultAccountKey = Self.normalizeCodexAccountScopedKey(usage.accountEmail(for: .codex)) let resultMatchesCurrentAccountKey = Self.codexUsageResultAccountKeyMatchesCurrentGuard( resultAccountKey, + expectedGuard: expectedGuard, currentGuard: currentGuard) if expectedGuard.identity != .unresolved { @@ -404,9 +405,14 @@ extension UsageStore { private nonisolated static func codexUsageResultAccountKeyMatchesCurrentGuard( _ resultAccountKey: String?, + expectedGuard: CodexAccountScopedRefreshGuard, currentGuard: CodexAccountScopedRefreshGuard) -> Bool { guard let currentAccountKey = currentGuard.accountKey else { return true } + guard let resultAccountKey else { + guard let expectedAccountKey = expectedGuard.accountKey else { return true } + return expectedAccountKey == currentAccountKey + } return resultAccountKey == currentAccountKey } @@ -417,7 +423,8 @@ extension UsageStore { return CodexAuthFingerprint.normalize(snapshot.liveSystemAccount?.authFingerprint) case let .managedAccount(id): guard let account = snapshot.storedAccounts.first(where: { $0.id == id }) else { return nil } - return CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) + return CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) ?? + CodexAuthFingerprint.normalize(account.authFingerprint) } } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index d940b32df..945b3e77f 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -232,15 +232,20 @@ extension UsageStore { case .emailOnly, .unresolved: nil } + let authFingerprint = CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) return (account.id, CodexManagedVisibleAccountRuntimeState( - authFingerprint: CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath), - workspaceAccountID: workspaceAccountID)) + authFingerprint: authFingerprint ?? account.authFingerprint, + workspaceAccountID: authFingerprint == nil ? account.workspaceAccountID : workspaceAccountID)) }) let visibleAccounts = projection.visibleAccounts.map { account in guard case let .managedAccount(id) = account.selectionSource else { return account } + let accountWorkspaceAccountID = account.workspaceAccountID + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let runtimeWorkspaceAccountID = managedRuntimeStates[id]?.workspaceAccountID + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) guard let runtimeState = managedRuntimeStates[id], - let authFingerprint = runtimeState.authFingerprint, - authFingerprint != account.authFingerprint + runtimeState.authFingerprint != account.authFingerprint || + runtimeWorkspaceAccountID != accountWorkspaceAccountID else { return account } @@ -249,7 +254,7 @@ extension UsageStore { email: account.email, workspaceLabel: account.workspaceLabel, workspaceAccountID: runtimeState.workspaceAccountID, - authFingerprint: authFingerprint, + authFingerprint: runtimeState.authFingerprint, storedAccountID: account.storedAccountID, selectionSource: account.selectionSource, isActive: account.isActive, diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 217e5a25a..ef0feed73 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -876,6 +876,130 @@ extension CodexAccountScopedRefreshTests { }) } + @Test + func `stacked visible refresh keeps migrated managed account after token rotation`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-migrated-managed-token-rotation") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-181818181818")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-migrated-managed-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-migrated-managed-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "migrated-managed@example.com", + plan: "Pro", + accountId: "acct-migrated-managed") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "migrated-sibling@example.com", + plan: "Pro", + accountId: "acct-migrated-sibling") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "migrated-managed@example.com", + providerAccountID: "acct-migrated-managed", + workspaceLabel: "Managed Team", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "migrated-sibling@example.com", + providerAccountID: "acct-migrated-sibling", + workspaceLabel: "Sibling Team", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "migrated-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "migrated-managed@example.com", + plan: "Team", + accountId: "acct-migrated-managed") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "migrated-managed@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + let selectedSnapshot = try #require(store.snapshots[.codex]) + #expect(selectedSnapshot.primary?.usedPercent == 64) + let targetRow = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-migrated-managed" + }) + #expect(targetRow.account.authFingerprint == newFingerprint) + #expect(targetRow.snapshot?.primary?.usedPercent == 64) + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-migrated-managed" + }) + #expect(persistedTarget.account.authFingerprint == newFingerprint) + #expect(persistedTarget.snapshot?.primary?.usedPercent == 64) + } + @Test func `managed failure guard reads current auth file fingerprint`() throws { let settings = self.makeSettingsStore( @@ -1065,6 +1189,125 @@ extension CodexAccountScopedRefreshTests { #expect(store.errors[.codex] == nil) } + @Test + func `managed codex usage success without email applies when auth guard matches`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-usage-without-email") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-usage-without-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "email-less-managed@example.com", + plan: "Pro", + accountId: "acct-managed-email-less") + let authFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "email-less-managed@example.com", + providerAccountID: "acct-managed-email-less", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-email-less", + authFingerprint: authFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same provider account managed email change discards stale codex usage success without email`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-provider-email-change-without-email") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-181818181818")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-managed-provider-email-without-email-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "old-managed-empty@example.com", + plan: "Pro", + accountId: "acct-managed-shared-empty") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "old-managed-empty@example.com", + providerAccountID: "acct-managed-shared-empty", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-shared-empty", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "new-managed-empty@example.com", + plan: "Pro", + accountId: "acct-managed-shared-empty") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + @Test func `same email email-only auth fingerprint switch discards stale codex usage success`() async { SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 From 1a2a787b4af61f7fb452a20e4390c51e0d4ae7a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 12:18:51 +0100 Subject: [PATCH 66/93] fix: apply Codex dashboard policy cleanup after token rotation --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 2 +- .../CodexBar/UsageStore+TokenAccounts.swift | 3 + ...odexAccountAuthFingerprintApplyTests.swift | 128 +++++++++++++++++- ...ntScopedRefreshDashboardCleanupTests.swift | 86 ++++++++++++ 4 files changed, 215 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 4459f9aa6..32f027dcc 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -132,7 +132,7 @@ extension UsageStore { expectedGuard: expectedGuard, routingTargetEmail: targetEmail) case .displayOnly, .failClosed: - self.shouldApplyOpenAIWebNonSuccessResult( + self.shouldApplyOpenAIDashboardRefreshGuard( expectedGuard: expectedGuard, routingTargetEmail: targetEmail) } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 945b3e77f..156192158 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -324,6 +324,9 @@ extension UsageStore { } switch account.selectionSource { case .managedAccount: + if !self.codexVisibleAccountAuthFingerprintMatches(prior, account: account) { + return priorEmail != nil && priorEmail == accountEmail + } return true case .liveSystem: return priorEmail != nil && priorEmail == accountEmail diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index ef0feed73..c1e24daef 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -414,7 +414,7 @@ extension CodexAccountScopedRefreshTests { } @Test - func `same account token refresh fingerprint change discards dashboard policy failure`() async throws { + func `same account token refresh fingerprint change applies dashboard policy failure`() async throws { let settings = self.makeSettingsStore( suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-policy-failure") let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) @@ -447,8 +447,8 @@ extension CodexAccountScopedRefreshTests { expectedGuard: expectedGuard) #expect(store.openAIDashboard == nil) - #expect(store.lastOpenAIDashboardError == nil) - #expect(store.openAIDashboardRequiresLogin == false) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) + #expect(store.openAIDashboardRequiresLogin == true) } @Test @@ -1000,6 +1000,128 @@ extension CodexAccountScopedRefreshTests { #expect(persistedTarget.snapshot?.primary?.usedPercent == 64) } + @Test + func `stacked visible refresh discards selected success after managed auth file email changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-email-success") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-191919191919")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-202020202020")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-email-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-old-email@example.com", + plan: "Pro", + accountId: "acct-managed-email-same") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "managed-email-sibling@example.com", + plan: "Pro", + accountId: "acct-managed-email-sibling") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-old-email@example.com", + providerAccountID: "acct-managed-email-same", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-email-same", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-email-sibling@example.com", + providerAccountID: "acct-managed-email-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-email-sibling", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-email-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-new-email@example.com", + plan: "Pro", + accountId: "acct-managed-email-same") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-old-email@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-email-same" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-email-same" + }) + } + @Test func `managed failure guard reads current auth file fingerprint`() throws { let settings = self.makeSettingsStore( diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift index 4316b0ade..2769fd253 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift @@ -119,4 +119,90 @@ extension CodexAccountScopedRefreshTests { #expect(store.openAIDashboardRequiresLogin == true) #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) } + + @Test + func `dashboard fail closed cleanup applies after same account managed token rotation`() async throws { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-fail-closed-token-rotation-cleanup") + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: managedHome) } + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "pro", + accountId: "acct-managed") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + providerAccountID: "acct-managed", + workspaceLabel: "Managed", + workspaceAccountID: "acct-managed", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_activeManagedCodexAccount = nil + try? FileManager.default.removeItem(at: managedStoreURL) + } + + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings._test_managedCodexAccountStoreURL = managedStoreURL + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(expectedGuard.authFingerprint == oldFingerprint) + store._setSnapshotForTesting( + self.codexSnapshot(email: "managed@example.com", usedPercent: 20), + provider: .codex) + store.lastSourceLabels[.codex] = "openai-web" + let staleCredits = self.credits(remaining: 20) + store.credits = staleCredits + store.lastCreditsSnapshot = staleCredits + store.lastCreditsSnapshotAccountKey = "managed@example.com" + store.lastCreditsSource = .dashboardWeb + store.openAIDashboard = self.dashboard(email: "managed@example.com", creditsRemaining: 20, usedPercent: 20) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "managed@example.com", + snapshot: self.dashboard(email: "managed@example.com", creditsRemaining: 20, usedPercent: 20))) + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "team", + accountId: "acct-managed") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + let currentGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(currentGuard.identity == expectedGuard.identity) + #expect(currentGuard.accountKey == expectedGuard.accountKey) + #expect(currentGuard.authFingerprint == newFingerprint) + + await store.applyOpenAIDashboard( + self.dashboard(email: "other@example.com", creditsRemaining: 9, usedPercent: 35), + targetEmail: "managed@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardSnapshot == nil) + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(store.lastCreditsSource == .none) + #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(store.openAIDashboardRequiresLogin == true) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) + } } From 046fb79c311560e44a95c7174454fe446d827e22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 12:27:31 +0100 Subject: [PATCH 67/93] fix: reject stale Codex results after auth removal --- .../Codex/UsageStore+CodexAccountState.swift | 6 ++++-- ...odexAccountAuthFingerprintApplyTests.swift | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 6e7ae672e..863f0aa38 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -383,6 +383,9 @@ extension UsageStore { if self.codexGuardAuthFingerprintMatches(lhs, rhs) { return true } + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + guard lhsFingerprint != nil, rhsFingerprint != nil else { return false } guard case .providerAccount = rhs.identity, lhs.identity == rhs.identity else { return false } guard case .liveSystem = lhs.source else { return true } return lhs.accountKey != nil && lhs.accountKey == rhs.accountKey @@ -423,8 +426,7 @@ extension UsageStore { return CodexAuthFingerprint.normalize(snapshot.liveSystemAccount?.authFingerprint) case let .managedAccount(id): guard let account = snapshot.storedAccounts.first(where: { $0.id == id }) else { return nil } - return CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) ?? - CodexAuthFingerprint.normalize(account.authFingerprint) + return CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) } } diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index c1e24daef..2fa77f04e 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -1171,6 +1171,25 @@ extension CodexAccountScopedRefreshTests { #expect(store.freshCodexAccountScopedRefreshGuard().authFingerprint == newFingerprint) #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) + + try FileManager.default.removeItem(at: managedHome) + #expect(store.freshCodexAccountScopedRefreshGuard().authFingerprint == nil) + let staleUsage = UsageSnapshot( + primary: RateWindow( + usedPercent: 41, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-auth@example.com", + accountOrganization: nil, + loginMethod: "Managed Auth")) + #expect(!store.shouldApplyCodexUsageResult(expectedGuard: expectedGuard, usage: staleUsage)) + #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) } @Test From eeda76e3a325b46814f0a10b952eec1216bfc7a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 12:35:30 +0100 Subject: [PATCH 68/93] fix: refresh Codex stacked account gate --- .../CodexBar/UsageStore+TokenAccounts.swift | 5 +- ...odexAccountAuthFingerprintApplyTests.swift | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 156192158..7da16397d 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -66,8 +66,9 @@ extension UsageStore { } func shouldFetchAllCodexVisibleAccounts() -> Bool { - self.settings.multiAccountMenuLayout == .stacked && - self.settings.codexVisibleAccountProjection.visibleAccounts.count > 1 + let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() + return self.settings.multiAccountMenuLayout == .stacked && + projection.visibleAccounts.count > 1 } func refreshCodexVisibleAccountsForMenu() async { diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 2fa77f04e..640751416 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -95,6 +95,58 @@ extension CodexAccountScopedRefreshTests { #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") } + @Test + func `stale stacked projection collapse runs single codex fetch`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-stacked-collapse-single-fetch") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + settings._test_managedCodexAccountStoreURL = nil + } + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + settings.codexActiveSource = .liveSystem + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "live-collapse@example.com", + identity: .providerAccount(id: "acct-live-collapse")) + + let managedAccountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-191919191919")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed-collapse@example.com", + managedHomePath: "/tmp/codex-managed-collapse", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let staleStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + let emptyStoreURL = try self.makeManagedAccountStoreURL(accounts: []) + defer { + try? FileManager.default.removeItem(at: staleStoreURL) + try? FileManager.default.removeItem(at: emptyStoreURL) + } + settings._test_managedCodexAccountStoreURL = staleStoreURL + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + #expect(CodexVisibleAccountProjection.make(from: staleReconciliationSnapshot).visibleAccounts.count == 2) + + settings._test_managedCodexAccountStoreURL = emptyStoreURL + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + + let store = self.makeUsageStore(settings: settings) + self.installImmediateCodexProvider( + on: store, + snapshot: self.codexSnapshot(email: "live-collapse@example.com", usedPercent: 42)) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 42) + #expect(store.codexAccountSnapshots.isEmpty) + } + @Test func `same account token refresh fingerprint change keeps scoped state during prepare`() { let settings = self.makeSettingsStore( From f926ee34e073737f8058ad4a988ad9367f8b6bd3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 12:50:46 +0100 Subject: [PATCH 69/93] fix: require live Codex auth for visible refresh matches --- .../CodexBar/UsageStore+TokenAccounts.swift | 27 ++- ...odexAccountAuthFingerprintApplyTests.swift | 52 ------ .../CodexAccountRefreshProjectionTests.swift | 157 ++++++++++++++++++ 3 files changed, 178 insertions(+), 58 deletions(-) create mode 100644 Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 7da16397d..e3d995ccd 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -81,6 +81,7 @@ extension UsageStore { self.codexAccountSnapshots = [] return } + let managedAccountIDsWithReadableAuthAtStart = self.codexManagedAccountIDsWithReadableAuth() let originalVisibleAccountID = projection.activeVisibleAccountID let originalSelectionSource = originalVisibleAccountID.flatMap { @@ -124,7 +125,8 @@ extension UsageStore { } } - let currentProjection = self.freshCodexVisibleAccountProjectionForAccountRefresh() + let currentProjection = self.freshCodexVisibleAccountProjectionForAccountRefresh( + requireLiveManagedAuthFor: managedAccountIDsWithReadableAuthAtStart) let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in guard let currentAccount = Self.currentCodexVisibleAccount( matching: snapshot.account, @@ -211,19 +213,29 @@ extension UsageStore { return Self.codexVisibleAccountMatchesCurrentProjection(originalAccount, account: currentActiveAccount) } - private func freshCodexVisibleAccountProjectionForAccountRefresh() -> CodexVisibleAccountProjection { + private func freshCodexVisibleAccountProjectionForAccountRefresh( + requireLiveManagedAuthFor accountIDs: Set = []) -> CodexVisibleAccountProjection + { // Auth files can change while account fetches are in flight, so account refreshes bypass the // short-lived reconciliation cache used for normal menu rendering and stale-result guards. self.settings.invalidateCodexAccountReconciliationSnapshotCache() let snapshot = self.settings.codexAccountReconciliationSnapshot return Self.codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( CodexVisibleAccountProjection.make(from: snapshot), - snapshot: snapshot) + snapshot: snapshot, + requireLiveManagedAuthFor: accountIDs) + } + + private func codexManagedAccountIDsWithReadableAuth() -> Set { + Set(self.settings.codexAccountReconciliationSnapshot.storedAccounts.compactMap { account in + CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) == nil ? nil : account.id + }) } private nonisolated static func codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( _ projection: CodexVisibleAccountProjection, - snapshot: CodexAccountReconciliationSnapshot) -> CodexVisibleAccountProjection + snapshot: CodexAccountReconciliationSnapshot, + requireLiveManagedAuthFor accountIDs: Set = []) -> CodexVisibleAccountProjection { let managedRuntimeStates = Dictionary( uniqueKeysWithValues: snapshot.storedAccounts.map { account in @@ -234,9 +246,12 @@ extension UsageStore { nil } let authFingerprint = CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) + let requiresLiveAuth = accountIDs.contains(account.id) return (account.id, CodexManagedVisibleAccountRuntimeState( - authFingerprint: authFingerprint ?? account.authFingerprint, - workspaceAccountID: authFingerprint == nil ? account.workspaceAccountID : workspaceAccountID)) + authFingerprint: authFingerprint ?? (requiresLiveAuth ? nil : account.authFingerprint), + workspaceAccountID: authFingerprint == nil && requiresLiveAuth + ? nil + : (workspaceAccountID ?? account.workspaceAccountID))) }) let visibleAccounts = projection.visibleAccounts.map { account in guard case let .managedAccount(id) = account.selectionSource else { return account } diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift index 640751416..2fa77f04e 100644 --- a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -95,58 +95,6 @@ extension CodexAccountScopedRefreshTests { #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") } - @Test - func `stale stacked projection collapse runs single codex fetch`() async throws { - SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 - let settings = self.makeSettingsStore( - suite: "CodexAccountScopedRefreshTests-stacked-collapse-single-fetch") - defer { - SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil - settings._test_liveSystemCodexAccount = nil - settings._test_managedCodexAccountStoreURL = nil - } - settings.refreshFrequency = .manual - settings.multiAccountMenuLayout = .stacked - settings.codexActiveSource = .liveSystem - settings._test_liveSystemCodexAccount = self.liveAccount( - email: "live-collapse@example.com", - identity: .providerAccount(id: "acct-live-collapse")) - - let managedAccountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-191919191919")) - let managedAccount = ManagedCodexAccount( - id: managedAccountID, - email: "managed-collapse@example.com", - managedHomePath: "/tmp/codex-managed-collapse", - createdAt: 1, - updatedAt: 2, - lastAuthenticatedAt: 2) - let staleStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) - let emptyStoreURL = try self.makeManagedAccountStoreURL(accounts: []) - defer { - try? FileManager.default.removeItem(at: staleStoreURL) - try? FileManager.default.removeItem(at: emptyStoreURL) - } - settings._test_managedCodexAccountStoreURL = staleStoreURL - let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot - #expect(CodexVisibleAccountProjection.make(from: staleReconciliationSnapshot).visibleAccounts.count == 2) - - settings._test_managedCodexAccountStoreURL = emptyStoreURL - settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( - activeSource: .liveSystem, - loadedAt: Date(), - snapshot: staleReconciliationSnapshot) - - let store = self.makeUsageStore(settings: settings) - self.installImmediateCodexProvider( - on: store, - snapshot: self.codexSnapshot(email: "live-collapse@example.com", usedPercent: 42)) - - await store.refreshProvider(.codex, allowDisabled: true) - - #expect(store.snapshots[.codex]?.primary?.usedPercent == 42) - #expect(store.codexAccountSnapshots.isEmpty) - } - @Test func `same account token refresh fingerprint change keeps scoped state during prepare`() { let settings = self.makeSettingsStore( diff --git a/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift b/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift new file mode 100644 index 000000000..b50102e8c --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift @@ -0,0 +1,157 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `stale stacked projection collapse runs single codex fetch`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-stacked-collapse-single-fetch") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + settings._test_managedCodexAccountStoreURL = nil + } + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + settings.codexActiveSource = .liveSystem + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "live-collapse@example.com", + identity: .providerAccount(id: "acct-live-collapse")) + + let managedAccountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-191919191919")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed-collapse@example.com", + managedHomePath: "/tmp/codex-managed-collapse", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let staleStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + let emptyStoreURL = try self.makeManagedAccountStoreURL(accounts: []) + defer { + try? FileManager.default.removeItem(at: staleStoreURL) + try? FileManager.default.removeItem(at: emptyStoreURL) + } + settings._test_managedCodexAccountStoreURL = staleStoreURL + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + #expect(CodexVisibleAccountProjection.make(from: staleReconciliationSnapshot).visibleAccounts.count == 2) + + settings._test_managedCodexAccountStoreURL = emptyStoreURL + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + + let store = self.makeUsageStore(settings: settings) + self.installImmediateCodexProvider( + on: store, + snapshot: self.codexSnapshot(email: "live-collapse@example.com", usedPercent: 42)) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 42) + #expect(store.codexAccountSnapshots.isEmpty) + } + + @Test + func `stacked visible refresh discards selected success after managed auth file is removed`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-removed") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-202020202020")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-212121212121")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-removed-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-removed-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-removed@example.com", + plan: "Pro", + accountId: "acct-managed-removed") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-removed@example.com", + providerAccountID: "acct-managed-removed", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-removed", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-removed-sibling@example.com", + providerAccountID: "acct-managed-removed-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-removed-sibling", + authFingerprint: "sibling-managed-removed", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-removed-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManager.default.removeItem(at: targetHome) + await blocker.resume(with: .success(self.codexSnapshot(email: "managed-removed@example.com", usedPercent: 44))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-removed" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-removed" + }) + } +} From 9b54760cf5c2f49e7bb53ad00a681229fb10d252 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 13:01:16 +0100 Subject: [PATCH 70/93] fix: apply Codex dashboard policy cleanup after email rotation --- .../Codex/UsageStore+CodexAccountState.swift | 34 ++++++++++ Sources/CodexBar/UsageStore+OpenAIWeb.swift | 2 +- ...ntScopedRefreshDashboardCleanupTests.swift | 65 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 863f0aa38..190ca2098 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -298,6 +298,29 @@ extension UsageStore { allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail } + func shouldApplyOpenAIDashboardPolicyResult( + expectedGuard: CodexAccountScopedRefreshGuard, + routingTargetEmail: String?) -> Bool + { + let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return false } + + if expectedGuard.identity != .unresolved { + guard currentGuard.identity == expectedGuard.identity else { return false } + return Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) || + Self.codexGuardAuthFingerprintAllowsSameProviderAccount(currentGuard, expectedGuard) + } + + guard case .liveSystem = expectedGuard.source else { return false } + guard currentGuard.identity == .unresolved else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + return CodexIdentityResolver.normalizeEmail( + self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: true, + allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail + } + func codexDashboardKnownOwnerCandidates() -> [CodexDashboardKnownOwnerCandidate] { CodexKnownOwnerCatalog.candidates(from: self.settings.codexAccountReconciliationSnapshot) } @@ -391,6 +414,17 @@ extension UsageStore { return lhs.accountKey != nil && lhs.accountKey == rhs.accountKey } + private nonisolated static func codexGuardAuthFingerprintAllowsSameProviderAccount( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + guard lhsFingerprint != nil, rhsFingerprint != nil else { return false } + guard case .providerAccount = rhs.identity else { return false } + return lhs.identity == rhs.identity + } + nonisolated static func codexScopedRefreshGuardsMatchAccount( _ lhs: CodexAccountScopedRefreshGuard, _ rhs: CodexAccountScopedRefreshGuard) -> Bool diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 32f027dcc..952e25ae3 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -132,7 +132,7 @@ extension UsageStore { expectedGuard: expectedGuard, routingTargetEmail: targetEmail) case .displayOnly, .failClosed: - self.shouldApplyOpenAIDashboardRefreshGuard( + self.shouldApplyOpenAIDashboardPolicyResult( expectedGuard: expectedGuard, routingTargetEmail: targetEmail) } diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift index 2769fd253..719b07c97 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift @@ -205,4 +205,69 @@ extension CodexAccountScopedRefreshTests { #expect(store.openAIDashboardRequiresLogin == true) #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) } + + @Test + func `dashboard fail closed cleanup applies after same live account email changes during token rotation`() async { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-fail-closed-live-email-rotation-cleanup") + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings.codexActiveSource = .liveSystem + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(expectedGuard.accountKey == "alpha@example.com") + #expect(expectedGuard.authFingerprint == "old-token-material") + store._setSnapshotForTesting( + self.codexSnapshot(email: "alpha@example.com", usedPercent: 20), + provider: .codex) + store.lastSourceLabels[.codex] = "openai-web" + let staleCredits = self.credits(remaining: 20) + store.credits = staleCredits + store.lastCreditsSnapshot = staleCredits + store.lastCreditsSnapshotAccountKey = "alpha@example.com" + store.lastCreditsSource = .dashboardWeb + store.openAIDashboard = self.dashboard(email: "alpha@example.com", creditsRemaining: 20, usedPercent: 20) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "alpha@example.com", + snapshot: self.dashboard(email: "alpha@example.com", creditsRemaining: 20, usedPercent: 20))) + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "beta@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let currentGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(currentGuard.identity == expectedGuard.identity) + #expect(currentGuard.accountKey == "beta@example.com") + #expect(currentGuard.authFingerprint == "new-token-material") + + await store.applyOpenAIDashboard( + self.dashboard(email: "alpha@example.com", creditsRemaining: 9, usedPercent: 35), + targetEmail: "alpha@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardSnapshot == nil) + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(store.lastCreditsSource == .none) + #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(store.openAIDashboardRequiresLogin == true) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as alpha@example.com") == true) + } } From 034300c8777bcf9d13e15b0cc1d5c78cc5bda8a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 13:23:41 +0100 Subject: [PATCH 71/93] fix: hydrate Codex account snapshots with live auth --- .../CodexBar/UsageStore+TokenAccounts.swift | 4 + Sources/CodexBar/UsageStore.swift | 2 +- .../CodexAccountRefreshProjectionTests.swift | 82 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index e3d995ccd..5f0476c7c 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -55,6 +55,10 @@ extension UsageStore { private static let codexSessionWindowMinutes = 5 * 60 private static let codexWeeklyWindowMinutes = 7 * 24 * 60 + func freshCodexVisibleAccountsForSnapshotHydration() -> [CodexVisibleAccount] { + self.freshCodexVisibleAccountProjectionForAccountRefresh().visibleAccounts + } + func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } return self.settings.tokenAccounts(for: provider) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index fad66559b..287a96fff 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -313,7 +313,7 @@ final class UsageStore { self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults) if let codexAccountUsageSnapshotStore = self.codexAccountUsageSnapshotStore { self.codexAccountSnapshots = codexAccountUsageSnapshotStore.load( - for: settings.codexVisibleAccountProjection.visibleAccounts) + for: self.freshCodexVisibleAccountsForSnapshotHydration()) } self.logStartupState() self.bindSettings() diff --git a/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift b/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift index b50102e8c..b1b5d837a 100644 --- a/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift +++ b/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift @@ -154,4 +154,86 @@ extension CodexAccountScopedRefreshTests { $0.account.workspaceAccountID == "acct-managed-removed" }) } + + @Test + func `startup snapshot hydration refreshes managed auth fingerprint from disk`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-startup-managed-auth-hydration") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-222222222222")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-startup-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-startup@example.com", + plan: "Pro") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "managed-startup@example.com", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + let snapshotURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-startup-\(UUID().uuidString).json") + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: snapshotURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let staleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.storedAccountID == accountID }) + #expect(staleAccount.authFingerprint == oldFingerprint) + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-startup@example.com", + plan: "Team") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + + let freshAccount = CodexVisibleAccount( + id: staleAccount.id, + email: staleAccount.email, + workspaceLabel: staleAccount.workspaceLabel, + workspaceAccountID: staleAccount.workspaceAccountID, + authFingerprint: newFingerprint, + storedAccountID: staleAccount.storedAccountID, + selectionSource: staleAccount.selectionSource, + isActive: staleAccount.isActive, + isLive: staleAccount.isLive, + canReauthenticate: staleAccount.canReauthenticate, + canRemove: staleAccount.canRemove) + let snapshotStore = FileCodexAccountUsageSnapshotStore(fileURL: snapshotURL) + snapshotStore.store([ + CodexAccountUsageSnapshot( + account: freshAccount, + snapshot: self.codexSnapshot(email: freshAccount.email, usedPercent: 64), + error: nil, + sourceLabel: "cached"), + ]) + #expect(snapshotStore.load(for: [staleAccount]).isEmpty) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + + let hydrated = try #require(store.codexAccountSnapshots.first) + #expect(store.codexAccountSnapshots.count == 1) + #expect(hydrated.id == freshAccount.id) + #expect(hydrated.account.authFingerprint == newFingerprint) + #expect(hydrated.snapshot?.primary?.usedPercent == 64) + } } From ca5ebd8d68ec2fb3615008ace9d7fcddde4aa813 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 13:23:44 +0100 Subject: [PATCH 72/93] test: stabilize login shell cache retry test --- Sources/CodexBarCore/PathEnvironment.swift | 10 ++++- Tests/CodexBarTests/PathBuilderTests.swift | 49 +++++++++++++--------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 2e60e2c5d..d3a6f2f5e 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -946,10 +946,15 @@ public final class LoginShellPathCache: @unchecked Sendable { public static let shared = LoginShellPathCache() private let lock = NSLock() + private let capture: @Sendable (String?, TimeInterval) -> [String]? private var captured: [String]? private var isCapturing = false private var callbacks: [([String]?) -> Void] = [] + init(capture: @escaping @Sendable (String?, TimeInterval) -> [String]? = LoginShellPathCapturer.capture) { + self.capture = capture + } + public var current: [String]? { self.lock.lock() let value = self.captured @@ -981,8 +986,9 @@ public final class LoginShellPathCache: @unchecked Sendable { self.isCapturing = true self.lock.unlock() + let capture = self.capture DispatchQueue.global(qos: .utility).async { [weak self] in - let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout) + let result = capture(shell, timeout) guard let self else { return } self.lock.lock() @@ -1022,7 +1028,7 @@ public final class LoginShellPathCache: @unchecked Sendable { self.isCapturing = true self.lock.unlock() - let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout) + let result = self.capture(shell, timeout) self.lock.lock() self.captured = result self.isCapturing = false diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 3709d73c6..506deefae 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -45,16 +45,16 @@ struct PathBuilderTests { } @Test - func `login shell cache retries after timed out nil capture`() throws { - let shell = try Self.makeLoginShellPathScript( - delay: 0.2, - path: "/login/bin:/usr/bin") - defer { try? FileManager.default.removeItem(at: shell) } + func `login shell cache retries after timed out nil capture`() { + let capture = LoginShellPathCaptureStub([ + nil, + ["/login/bin", "/usr/bin"], + ]) - let cache = LoginShellPathCache() + let cache = LoginShellPathCache { _, _ in capture.next() } let semaphore = DispatchSemaphore(value: 0) var firstResult: [String]? - cache.captureOnce(shell: shell.path, timeout: 0.01) { result in + cache.captureOnce(shell: "/unused", timeout: 0.01) { result in firstResult = result semaphore.signal() } @@ -63,9 +63,10 @@ struct PathBuilderTests { #expect(firstResult == nil) #expect(cache.current == nil) - let recovered = cache.currentOrCapture(shell: shell.path, timeout: 2.0) + let recovered = cache.currentOrCapture(shell: "/unused", timeout: 2.0) #expect(recovered == ["/login/bin", "/usr/bin"]) #expect(cache.current == ["/login/bin", "/usr/bin"]) + #expect(capture.callCount == 2) } @Test @@ -650,18 +651,28 @@ struct PathBuilderTests { private static func shellSingleQuoted(_ value: String) -> String { "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" } +} - private static func makeLoginShellPathScript(delay: TimeInterval, path: String) throws -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("codexbar-login-path-\(UUID().uuidString).sh") - let script = """ - #!/bin/sh - sleep \(delay) - printf '__CODEXBAR_PATH__%s__CODEXBAR_PATH__' \(self.shellSingleQuoted(path)) - """ - try script.write(to: url, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) - return url +private final class LoginShellPathCaptureStub: @unchecked Sendable { + private let lock = NSLock() + private var results: [[String]?] + private var callCountStorage = 0 + + var callCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.callCountStorage + } + + init(_ results: [[String]?]) { + self.results = results + } + + func next() -> [String]? { + self.lock.lock() + defer { self.lock.unlock() } + self.callCountStorage += 1 + return self.results.isEmpty ? nil : self.results.removeFirst() } } From f351a7d6e65e73949149b3f20193e9a684e294d3 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Sun, 7 Jun 2026 23:05:48 +0800 Subject: [PATCH 73/93] perf: cut menu readiness signature cost on store changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu readiness signature serializes every enabled provider's token snapshot and 30-day daily breakdown on each store mutation. Two fixes: - Skip computing it entirely when no menu is open, since the result (refreshOpenMenus) is only consulted while a menu is open. This is the common case during background refresh ticks. - Use the raw Double bit pattern instead of String(format: "%.8f", …), which is a hot per-value cost. The signature is only compared for equality, so the bit pattern is both exact and far cheaper. Reduces main-thread work that regressed popup-menu responsiveness in the 0.32 series (refs #1321). Co-authored-by: Cursor --- .../StatusItemController+MenuRefreshScheduling.swift | 6 +++++- Sources/CodexBar/StatusItemController.swift | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index fe739d106..05ab9274d 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -89,8 +89,12 @@ extension StatusItemController { return self.formatDoubleForSignature(value) } + /// The signature is only ever compared for equality against the previous signature, so it does + /// not need a human-readable decimal form. `String(format: "%.8f", …)` is a surprisingly hot + /// cost here because it runs for every daily/service value across every enabled provider on each + /// store mutation. The raw bit pattern is both exact (no rounding collisions) and far cheaper. private static func formatDoubleForSignature(_ value: Double) -> String { - String(format: "%.8f", value) + String(value.bitPattern, radix: 16) } func performMenuMutationWithoutAnimation(_ updates: () -> Void) { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 747a3dd48..008061cd8 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -444,8 +444,15 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreChanges() + // `refreshOpenMenus` is only consulted when a menu is currently open. + // Computing the readiness signature serializes every enabled provider's + // token snapshot and 30-day daily breakdown, which is wasted main-thread + // work on the common path where no menu is open (background refresh ticks). + let refreshOpenMenus = self.openMenus.isEmpty + ? false + : self.didMenuAdjunctReadinessChange() self.invalidateMenus( - refreshOpenMenus: self.didMenuAdjunctReadinessChange(), + refreshOpenMenus: refreshOpenMenus, deferOpenParentMenuRebuild: true, allowStaleContentDuringDataRefresh: true) } From b7744f2014d31f5c1aaa14b615e4b447d17a9278 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Sun, 7 Jun 2026 23:38:42 +0800 Subject: [PATCH 74/93] perf: size hosted menu charts without a throwaway hosting controller Each hosted chart/submenu item built TWO SwiftUI hierarchies on every (re)build: the MenuHostingView that is actually displayed, plus a separate NSHostingController created solely to measure height via sizeThatFits. That controller's result was always immediately overwritten by the subsequent refreshHostedSubviewHeights() fittingSize pass (run from both menuWillOpen and refreshHostedSubviewMenu), so the second hierarchy was pure overhead on a popup-menu hot path that scales with provider/account count. Measure the live displayed view via fittingSize instead (the same mechanism refreshHostedSubviewHeights already uses), via a shared hostedSubviewFittingHeight helper. Final heights are unchanged; only the redundant SwiftUI tree is removed. Adds a test asserting the append-path height matches the authoritative re-measure across chart types/providers. refs #1321 Co-authored-by: Cursor --- .../StatusItemController+HostedSubmenus.swift | 30 ++++---- .../CodexBar/StatusItemController+Menu.swift | 15 +++- ...tatusItemController+UsageHistoryMenu.swift | 6 +- .../StatusMenuHostedSubmenuRefreshTests.swift | 70 +++++++++++++++++++ 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 1f080abe6..b4c7bfad2 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -186,9 +186,9 @@ extension StatusItemController { 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)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -213,9 +213,9 @@ extension StatusItemController { 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)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -252,9 +252,9 @@ extension StatusItemController { windowLabel: tokenSnapshot.historyLabel, 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)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -288,9 +288,9 @@ extension StatusItemController { let maxHeight = self.storageBreakdownMenuMaxHeight() let view = StorageBreakdownMenuView(footprint: footprint, width: width, maxHeight: maxHeight) let hosting = MenuHostingView(rootView: view) - let controller = NSHostingController(rootView: view) - let size = controller.sizeThatFits(in: CGSize(width: width, height: maxHeight)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let item = NSMenuItem() item.view = hosting @@ -328,9 +328,9 @@ extension StatusItemController { let chartView = ZaiHourlyUsageChartMenuView(modelUsage: modelUsage, 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)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 55a5b8470..7b1a75641 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1541,13 +1541,22 @@ extension StatusItemController { for item in menu.items { guard let view = item.view else { continue } - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - view.layoutSubtreeIfNeeded() - let height = view.fittingSize.height + let height = self.hostedSubviewFittingHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) } } + /// Measures the natural height of a hosted submenu view at the given width using the live + /// view that will actually be displayed. Hosted chart items used to spin up a second, + /// throwaway `NSHostingController` purely to size the chart even though every build path + /// immediately re-measures the live view via `fittingSize`; that extra SwiftUI hierarchy was + /// pure overhead on a popup-menu hot path, so callers now size the displayed view directly. + func hostedSubviewFittingHeight(for view: NSView, width: CGFloat) -> CGFloat { + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + view.layoutSubtreeIfNeeded() + return view.fittingSize.height + } + @objc func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 6b5dbc1f7..5bc0bbed0 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -68,9 +68,9 @@ extension StatusItemController { snapshot: snapshot, width: width) let hosting = UsageHistoryMenuHostingView(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)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index a528c5c51..2a6323058 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -171,6 +171,76 @@ struct StatusMenuHostedSubmenuRefreshTests { seed: Self.seedZaiHourlyUsage) } + @Test + func `hosted chart items size to the displayed view without a throwaway controller`() throws { + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + { controller, submenu, width in + controller.appendCostHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + { controller, submenu, width in + controller.appendUsageHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + { controller, submenu, width in + controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: width) + } + } + + private func assertHostedChartItemHeightMatchesRefresh( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void, + append: (StatusItemController, NSMenu, CGFloat) -> Bool) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + Self.enableOnly(settings, provider: provider) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + seed(store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let width = StatusItemController.menuCardBaseWidth + let submenu = NSMenu() + submenu.minimumWidth = width + #expect(append(controller, submenu, width)) + + let item = try #require(submenu.items.first) + let view = try #require(item.view) + let heightFromAppend = view.frame.height + // The height the append path assigns must match the authoritative re-measure pass; otherwise + // dropping the throwaway NSHostingController would have changed sizing behavior. + controller.refreshHostedSubviewHeights(in: submenu) + #expect(view.frame.height == heightFromAppend) + #expect(heightFromAppend > 1) + } + private func assertHostedSubmenuPreservesIdentity( chartID: String, provider: UsageProvider, From 8da0cfc2564ada662dac86740c84c48fc15f1038 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Sun, 7 Jun 2026 23:53:55 +0800 Subject: [PATCH 75/93] fix: resync menu readiness baseline on root menu open Skipping the readiness signature while all menus are closed (the idle-cost optimization) let the baseline drift from live store data. A menu reopened from new data, followed by an open-menu change reverting to the previous baseline value, was treated as unchanged and skipped the rebuild, leaving stale content visible. Re-anchor the baseline when a root menu opens (rebuilt from current data). Only the root open re-anchors; nested submenu opens must not, to avoid masking a pending parent refresh. Adds a focused regression test. Co-authored-by: Cursor --- .../CodexBar/StatusItemController+Menu.swift | 11 ++ ...ItemController+MenuRefreshScheduling.swift | 11 ++ .../StatusMenuReadinessBaselineTests.swift | 100 ++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 55a5b8470..200a9d4b5 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -85,6 +85,11 @@ extension StatusItemController { self.cancelDeferredMenuInteractionRefreshTask() self.cancelClosedMenuRebuild(menu) + // Track whether this is the root menu opening (no menus were open). Only the root open rebuilds + // all content from current data, so the readiness baseline is re-anchored only here — re-anchoring + // on a nested submenu open could mask a pending refresh for the already-open parent menu. + let menuTrackingWasIdle = self.openMenus.isEmpty + if self.isHostedSubviewMenu(menu) { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) @@ -95,6 +100,9 @@ extension StatusItemController { // 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 + if menuTrackingWasIdle { + self.resyncMenuAdjunctReadinessBaseline() + } } // Removed redundant async refresh - single pass is sufficient after initial layout return @@ -128,6 +136,9 @@ extension StatusItemController { // 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 + if menuTrackingWasIdle { + self.resyncMenuAdjunctReadinessBaseline() + } self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) // Only schedule refresh after menu is registered as open - refreshNow is called async self.scheduleOpenMenuRefresh(for: menu) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 05ab9274d..82fab17b0 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -11,6 +11,17 @@ extension StatusItemController { return signature != self.lastMenuAdjunctReadinessSignature } + /// Resyncs the readiness baseline to the data the menu was just built from. + /// + /// Because the baseline is no longer recomputed on every store change while all menus are closed, + /// it can drift from the live store state. When a root menu opens it is rebuilt from current data, + /// so the baseline must be re-anchored here; otherwise a later open-menu store change that happens + /// to revert to the stale baseline value would be treated as "unchanged" and skip a needed rebuild, + /// leaving the visible menu showing the older content. + func resyncMenuAdjunctReadinessBaseline() { + self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() + } + func menuAdjunctReadinessSignature() -> String { let dashboard = self.store.openAIDashboard let dashboardUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift new file mode 100644 index 000000000..d5f703e34 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -0,0 +1,100 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `reopening root menu resyncs readiness baseline so reverted store data still refreshes`() { + // Regression for the readiness-signature optimization (#1351): the baseline is no longer + // recomputed on every store change while menus are closed, so it must be re-anchored when a + // root menu opens. Otherwise a closed-then-reopened menu built from new data, followed by an + // open-menu change that reverts to the *previous* baseline value, would be treated as + // "unchanged" and skip the rebuild, leaving the visible menu showing stale content. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + + // Root open anchors the baseline to snapshot A. Normalize via an explicit comparison so the + // assertion below is independent of whatever the controller's initial baseline happened to be. + controller.menuWillOpen(menu) + _ = controller.didMenuAdjunctReadinessChange() + controller.menuDidClose(menu) + + // Closed store change to B: the optimization intentionally skips recomputing the baseline here. + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + + // Reopening the root menu rebuilds from B and must re-anchor the baseline to B. + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + // Reverting to A (the value the *first* baseline held) must still register as a change. + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + #expect(controller.didMenuAdjunctReadinessChange()) + } + + private func enableOnlyCodexForReadinessBaseline(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func makeReadinessBaselineTokenSnapshot( + sessionTokens: Int, + sessionCostUSD: Double, + last30DaysTokens: Int, + last30DaysCostUSD: Double, + updatedAt: Date) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-24", + inputTokens: nil, + outputTokens: nil, + totalTokens: sessionTokens, + costUSD: last30DaysCostUSD, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } +} From 7f4bac64e623365679246ac6636e0efe86a111eb Mon Sep 17 00:00:00 2001 From: jskoiz <20649937+jskoiz@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:52:12 -1000 Subject: [PATCH 76/93] Defer status-menu quit during shutdown [skip ci] --- .../StatusItemController+Actions.swift | 11 +++- Sources/CodexBar/StatusItemController.swift | 10 ++++ .../StatusItemControllerShutdownTests.swift | 58 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 6a3c7b419..cd93de3d8 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -328,7 +328,16 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } @objc func quit() { - NSApp.terminate(nil) + let openMenus = Array(self.openMenus.values) + for menu in openMenus { + menu.cancelTrackingWithoutAnimation() + } + + self.scheduleQuitTermination { [weak self] in + guard let self else { return } + self.prepareForAppShutdown() + self.terminateApplicationForQuit() + } } @objc func copyError(_ sender: NSMenuItem) { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 747a3dd48..e99c3963d 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -140,6 +140,16 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? var hasPreparedForAppShutdown = false + var scheduleQuitTermination: (@escaping @MainActor () -> Void) -> Void = { operation in + DispatchQueue.main.async { + Task { @MainActor in + operation() + } + } + } + var terminateApplicationForQuit: @MainActor () -> Void = { + NSApp.terminate(nil) + } var openMenuInvalidationRetryTask: Task? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift index 73a427366..fb06f513f 100644 --- a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -58,6 +58,64 @@ struct StatusItemControllerShutdownTests { #expect(controller.mergedMenu == nil) } + @Test + func `status menu quit defers shutdown until menu tracking can unwind`() { + let controller = self.makeController() + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + + var scheduledTermination: (@MainActor () -> Void)? + var didTerminate = false + controller.scheduleQuitTermination = { operation in + scheduledTermination = operation + } + controller.terminateApplicationForQuit = { + didTerminate = true + } + + controller.quit() + + #expect(scheduledTermination != nil) + #expect(!controller.hasPreparedForAppShutdown) + #expect(!didTerminate) + #expect(controller.openMenus[key] === menu) + + scheduledTermination?() + + #expect(controller.hasPreparedForAppShutdown) + #expect(controller.openMenus.isEmpty) + #expect(controller.statusItem.menu == nil) + #expect(didTerminate) + } + + private func makeController() -> StatusItemController { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + if let codexMetadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + private func makeSettings() -> SettingsStore { let suite = "StatusItemControllerShutdownTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! From deb5efdc0d726b32a6c80ef0d51410e072c3a867 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Mon, 8 Jun 2026 11:22:41 +0800 Subject: [PATCH 77/93] Cache localized bundle resolution to cut main-thread disk lookups (#1347) localizedBundle()/codexBarLocalizationResourceBundle()/lprojBundle() did Bundle(url:)/Bundle(path:) filesystem lookups on every call. Menu row bodies re-evaluate them on every closed-menu rebuild tick on the main thread, so idle CPU climbs. Cache the (constant) resource bundle and the resolved localized bundle keyed on the current language; a language switch transparently re-resolves. Single lock with compute-outside-lock keeps disk work off the critical section and avoids re-entrant deadlock. Co-authored-by: Cursor --- Sources/CodexBar/Localization.swift | 83 ++++++++++++++++++- .../LocalizationBundleCacheTests.swift | 71 ++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/LocalizationBundleCacheTests.swift diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 3e45ee22b..bd25d5e6b 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -45,10 +45,73 @@ func codexBarLocalizationSignature() -> String { resolvedAppLanguage() } +/// Resolving the `.lproj`/resource bundles repeats `Bundle(url:)`/`Bundle(path:)` filesystem lookups, +/// which are surprisingly hot: every `L(…)` and `codexBarLocalizationSignature()` call runs them, and +/// menu row bodies (`MetricRow`, `ProviderCostContent`, `UsageMenuCardView.Model`) re-evaluate them on +/// every closed-menu rebuild tick on the main thread (#1347). The resolved bundles never change unless +/// the language changes, so cache them. A single lock with compute-happening-outside-the-lock keeps the +/// disk work off the critical section and avoids re-entrant deadlock when the localized-bundle compute +/// closure calls back into the resource-bundle accessor. +private enum LocalizationBundleCache { + private static let lock = NSLock() + private nonisolated(unsafe) static var resourceBundle: Bundle? + private nonisolated(unsafe) static var cachedLanguage: String? + private nonisolated(unsafe) static var cachedLocalizedBundle: Bundle? + + static func defaultResourceBundle(_ compute: () -> Bundle) -> Bundle { + self.lock.lock() + if let resourceBundle { + self.lock.unlock() + return resourceBundle + } + self.lock.unlock() + let computed = compute() + self.lock.lock() + resourceBundle = computed + self.lock.unlock() + return computed + } + + static func localizedBundle(forLanguage language: String, _ compute: () -> Bundle) -> Bundle { + self.lock.lock() + if self.cachedLanguage == language, let cachedLocalizedBundle { + let hit = cachedLocalizedBundle + self.lock.unlock() + return hit + } + self.lock.unlock() + let computed = compute() + self.lock.lock() + self.cachedLanguage = language + cachedLocalizedBundle = computed + self.lock.unlock() + return computed + } + + static func reset() { + self.lock.lock() + self.resourceBundle = nil + self.cachedLanguage = nil + self.cachedLocalizedBundle = nil + self.lock.unlock() + } +} + func codexBarLocalizationResourceBundle( mainBundle: Bundle = .main, bundleName: String = "CodexBar_CodexBar") -> Bundle { + // Only the default (process `.main`) resolution is cached: it is constant for the lifetime of the + // process. Custom arguments (tests) keep resolving directly so they stay isolated from the cache. + guard mainBundle === Bundle.main, bundleName == "CodexBar_CodexBar" else { + return resolveLocalizationResourceBundle(mainBundle: mainBundle, bundleName: bundleName) + } + return LocalizationBundleCache.defaultResourceBundle { + resolveLocalizationResourceBundle(mainBundle: mainBundle, bundleName: bundleName) + } +} + +private func resolveLocalizationResourceBundle(mainBundle: Bundle, bundleName: String) -> Bundle { guard mainBundle.bundleURL.pathExtension == "app" else { return Bundle.module } @@ -69,8 +132,16 @@ func codexBarLocalizationResourceBundle( } private func localizedBundle() -> Bundle { - let resourceBundle = codexBarLocalizationResourceBundle() + // Keyed on the resolved language so a language switch (settings change or test override) transparently + // re-resolves; otherwise the cached bundle is returned without touching the filesystem. let language = resolvedAppLanguage() + return LocalizationBundleCache.localizedBundle(forLanguage: language) { + resolveLocalizedBundle(forLanguage: language) + } +} + +private func resolveLocalizedBundle(forLanguage language: String) -> Bundle { + let resourceBundle = codexBarLocalizationResourceBundle() if !language.isEmpty { if let bundle = lprojBundle(named: language, in: resourceBundle) { return bundle @@ -145,6 +216,16 @@ func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bund return fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? key : fallback } +#if DEBUG +func codexBarLocalizedBundleForTesting() -> Bundle { + localizedBundle() +} + +func resetCodexBarLocalizationCacheForTesting() { + LocalizationBundleCache.reset() +} +#endif + func configureUsageFormatterLocalizationProvider() { UsageFormatter.setLocalizationProvider { key in let resourceBundle = codexBarLocalizationResourceBundle() diff --git a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift new file mode 100644 index 000000000..3cd2a4479 --- /dev/null +++ b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CodexBar + +/// Regression coverage for the localized-bundle caching added for #1347. +/// +/// The cache is process-global and these tests run in a parallel suite, so identity (`===`) assertions +/// would race against any other test that resolves a different language. Instead these assert the +/// concurrency-safe property that matters for correctness: every call resolves to the right `.lproj` +/// regardless of what is currently cached, so a language switch (and switch-back) is always honored and +/// the cache can never serve a stale localization. +struct LocalizationBundleCacheTests { + @Test + func `resolves the correct lproj per language and re-resolves on switch`() { + resetCodexBarLocalizationCacheForTesting() + + let fr = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { + codexBarLocalizedBundleForTesting() + } + #expect(fr.bundleURL.lastPathComponent == "fr.lproj") + + // Switching language must re-resolve rather than return the cached French bundle. + let ja = CodexBarLocalizationOverride.$appLanguage.withValue("ja") { + codexBarLocalizedBundleForTesting() + } + #expect(ja.bundleURL.lastPathComponent == "ja.lproj") + + // Switching back must still produce the French bundle (cache key is the language). + let frAgain = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { + codexBarLocalizedBundleForTesting() + } + #expect(frAgain.bundleURL.lastPathComponent == "fr.lproj") + } + + @Test + func `repeated same-language calls keep resolving the same lproj`() { + resetCodexBarLocalizationCacheForTesting() + + for _ in 0..<5 { + let bundle = CodexBarLocalizationOverride.$appLanguage.withValue("es") { + codexBarLocalizedBundleForTesting() + } + #expect(bundle.bundleURL.lastPathComponent == "es.lproj") + } + } + + @Test + func `unknown language falls back to en lproj`() { + resetCodexBarLocalizationCacheForTesting() + + let bundle = CodexBarLocalizationOverride.$appLanguage.withValue("zz-unknown") { + codexBarLocalizedBundleForTesting() + } + #expect(bundle.bundleURL.lastPathComponent == "en.lproj") + } + + @Test + func `resolution survives an explicit cache reset`() { + let first = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { + codexBarLocalizedBundleForTesting() + } + #expect(first.bundleURL.lastPathComponent == "uk.lproj") + + resetCodexBarLocalizationCacheForTesting() + + let afterReset = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { + codexBarLocalizedBundleForTesting() + } + #expect(afterReset.bundleURL.lastPathComponent == "uk.lproj") + } +} From e7915bd2b2fee95b6cd6e86cb408317d541ad15d Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Mon, 8 Jun 2026 11:47:32 +0800 Subject: [PATCH 78/93] Fix localization cache test to use an existing lproj catalog CI failed because the language-switch assertion used "ja", which has no ja.lproj in the repo and correctly falls back to en.lproj. Use "es" instead, which matches an actual catalog. Co-authored-by: Cursor --- Tests/CodexBarTests/LocalizationBundleCacheTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift index 3cd2a4479..7a7d2572c 100644 --- a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift +++ b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift @@ -20,10 +20,10 @@ struct LocalizationBundleCacheTests { #expect(fr.bundleURL.lastPathComponent == "fr.lproj") // Switching language must re-resolve rather than return the cached French bundle. - let ja = CodexBarLocalizationOverride.$appLanguage.withValue("ja") { + let es = CodexBarLocalizationOverride.$appLanguage.withValue("es") { codexBarLocalizedBundleForTesting() } - #expect(ja.bundleURL.lastPathComponent == "ja.lproj") + #expect(es.bundleURL.lastPathComponent == "es.lproj") // Switching back must still produce the French bundle (cache key is the language). let frAgain = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { From 37c8e8c82ee239643de940d0f489efe5662c869c Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Mon, 8 Jun 2026 16:53:53 +0800 Subject: [PATCH 79/93] Gate readiness baseline resync on fresh visible menu content (#1351) Only re-anchor the readiness baseline on root menu open when the menu was actually rebuilt or is already fresh for the current menuContentVersion. When refreshMenuForOpenIfNeeded preserves stale content during an in-flight provider refresh, resyncing to live store data would mask the refresh-completion update. Add a focused regression test (negative-validated). Co-authored-by: Cursor --- .../CodexBar/StatusItemController+Menu.swift | 5 +- ...ItemController+MenuRefreshScheduling.swift | 11 ++-- .../StatusMenuReadinessBaselineTests.swift | 63 +++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 200a9d4b5..cd45853b2 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -136,7 +136,10 @@ extension StatusItemController { // 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 - if menuTrackingWasIdle { + // Only re-anchor when the opened menu actually shows current data. During an in-flight provider + // refresh `refreshMenuForOpenIfNeeded` can preserve stale content; resyncing the baseline to + // live store data in that case would mask the refresh-completion update (#1351). + if menuTrackingWasIdle, !self.menuNeedsRefresh(menu) { self.resyncMenuAdjunctReadinessBaseline() } self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 82fab17b0..62048b191 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -14,10 +14,13 @@ extension StatusItemController { /// Resyncs the readiness baseline to the data the menu was just built from. /// /// Because the baseline is no longer recomputed on every store change while all menus are closed, - /// it can drift from the live store state. When a root menu opens it is rebuilt from current data, - /// so the baseline must be re-anchored here; otherwise a later open-menu store change that happens - /// to revert to the stale baseline value would be treated as "unchanged" and skip a needed rebuild, - /// leaving the visible menu showing the older content. + /// it can drift from the live store state. When a root menu opens and is actually rebuilt (or is + /// already fresh for the current `menuContentVersion`), the baseline must be re-anchored here; + /// otherwise a later open-menu store change that happens to revert to the stale baseline value would + /// be treated as "unchanged" and skip a needed rebuild, leaving the visible menu showing the older + /// content. Callers must **not** invoke this when `refreshMenuForOpenIfNeeded` preserved stale + /// content during an in-flight refresh — that would record live store data while the visible menu + /// still shows older content and mask the refresh-completion update. func resyncMenuAdjunctReadinessBaseline() { self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() } diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index d5f703e34..9939da860 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -65,6 +65,69 @@ extension StatusMenuTests { #expect(controller.didMenuAdjunctReadinessChange()) } + @Test + func `root open during in flight refresh preserves stale content and does not resync baseline`() { + // When `refreshMenuForOpenIfNeeded` keeps existing menu content during an in-flight provider + // refresh, the readiness baseline must not be re-anchored to live store data. Otherwise the + // refresh-completion store mutation would compare equal against the prematurely resynced baseline + // and skip the rebuild, leaving stale content visible (#1351). + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // Stale content was preserved: the menu is still behind the current content version. + #expect(controller.menuNeedsRefresh(menu)) + + store.isRefreshing = false + // Refresh completion must still register as a readiness change so the open menu can rebuild. + #expect(controller.didMenuAdjunctReadinessChange()) + } + private func enableOnlyCodexForReadinessBaseline(_ settings: SettingsStore) { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { From 463ec9148768487b98d2502385f21a5096936a4c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 19:24:08 +0100 Subject: [PATCH 80/93] fix: speed up merged provider switching --- CHANGELOG.md | 1 + Sources/CodexBar/SettingsStore+Defaults.swift | 8 +- .../SettingsStore+MenuObservation.swift | 2 - Sources/CodexBar/SettingsStore.swift | 7 +- .../CodexBar/StatusItemController+Menu.swift | 3 +- ...tusItemController+ProviderNavigation.swift | 14 +- ...tatusItemController+ProviderSwitcher.swift | 1 - Sources/CodexBar/UsageStore.swift | 52 +++--- Tests/CodexBarTests/SettingsStoreTests.swift | 28 +++ .../StatusMenuSwitcherClickTests.swift | 169 ++++++++++++++++++ .../UsageStoreCoverageTests.swift | 50 ++++++ 11 files changed, 300 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b3280fd..a9bcf9e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Performance: memoize models.dev cost catalog load outcomes so large Codex history scans no longer re-read and decode the same cache file per row (#1322, refs #1311). Thanks @turbothad! - Menu bar: compute Claude pace/reserve from the selected menu-bar metric window so Primary (Session) no longer pairs the session percentage with the weekly reserve (#1302). Thanks @outfoxer! - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! +- Menu bar: keep merged provider tab selection from invalidating broad settings observers so switching providers no longer triggers background refresh and status-icon work. - Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! - Menu bar: keep provider-switcher quota bars from replacing Auto Layout constraints when the visible ratio is unchanged, making tab switches responsive with many providers enabled (#1303, #1315). Thanks @juanjoseluisgarcia! - Kiro: retry login-shell PATH capture when CLI discovery races a slow cold shell startup, so `kiro-cli` is no longer stuck as missing for the whole app session (#1316). Thanks @bt-justtrack! diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 2b353ddb1..2e4639d2c 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -452,9 +452,9 @@ extension SettingsStore { } var mergedMenuLastSelectedWasOverview: Bool { - get { self.defaultsState.mergedMenuLastSelectedWasOverview } + get { self.mergedMenuLastSelectedWasOverviewStorage } set { - self.defaultsState.mergedMenuLastSelectedWasOverview = newValue + self.mergedMenuLastSelectedWasOverviewStorage = newValue self.userDefaults.set(newValue, forKey: "mergedMenuLastSelectedWasOverview") } } @@ -468,9 +468,9 @@ extension SettingsStore { } private var selectedMenuProviderRaw: String? { - get { self.defaultsState.selectedMenuProviderRaw } + get { self.selectedMenuProviderRawStorage } set { - self.defaultsState.selectedMenuProviderRaw = newValue + self.selectedMenuProviderRawStorage = newValue if let raw = newValue { self.userDefaults.set(raw, forKey: "selectedMenuProvider") } else { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 3776c1bba..f81c06fb4 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -63,7 +63,6 @@ extension SettingsStore { _ = self.ollamaCookieSource _ = self.mergeIcons _ = self.switcherShowsIcons - _ = self.mergedMenuLastSelectedWasOverview _ = self.mergedOverviewSelectedProviders _ = self.zaiAPIToken _ = self.syntheticAPIToken @@ -88,7 +87,6 @@ extension SettingsStore { _ = self.warpAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern - _ = self.selectedMenuProvider _ = self.configRevision return 0 } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index a38c5ba54..0840f8006 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -140,6 +140,8 @@ final class SettingsStore { @ObservationIgnored var tokenAccountsLoaded = false @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: CachedCodexAccountReconciliationSnapshot? + @ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false + @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 var providerOrder: [UsageProvider] = [] @@ -237,7 +239,10 @@ final class SettingsStore { self.configStore = configStore self.config = config self.configLoading = true - self.defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) + let defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) + self.defaultsState = defaultsState + self.mergedMenuLastSelectedWasOverviewStorage = defaultsState.mergedMenuLastSelectedWasOverview + self.selectedMenuProviderRawStorage = defaultsState.selectedMenuProviderRaw self.updateProviderState(config: config) self.configLoading = false CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 55a5b8470..9bc40bf74 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -992,6 +992,7 @@ extension StatusItemController { self.lastMenuProvider = provider } self.lastMergedSwitcherSelection = selection + self.refreshProviderSelectionDependentUI() self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) }) let item = NSMenuItem() @@ -1574,8 +1575,8 @@ extension StatusItemController { self.lastMergedSwitcherSelection = nil self.selectedMenuProvider = provider self.lastMenuProvider = provider + self.refreshProviderSelectionDependentUI() self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) - self.applyIcon(phase: nil) } } diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift index 8a1091938..8e16a244f 100644 --- a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -1,6 +1,17 @@ import CodexBarCore extension StatusItemController { + func refreshProviderSelectionDependentUI(refreshOpenMenus: Bool = false) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + self.invalidateMenus(refreshOpenMenus: refreshOpenMenus) + self.updateAnimationState() + self.updateBlinkingState() + let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil + self.applyIcon(phase: phase) + } + func navigateProviderSwitcher(_ direction: StatusItemMenuProviderNavigationDirection) { guard self.shouldMergeIcons else { return } let enabledProviders = self.store.enabledProvidersForDisplay() @@ -36,8 +47,7 @@ extension StatusItemController { self.lastMenuProvider = provider } self.lastMergedSwitcherSelection = selection - self.invalidateMenus(refreshOpenMenus: true) - self.applyIcon(phase: nil) + self.refreshProviderSelectionDependentUI(refreshOpenMenus: true) } private func navigationResolvedProvider(enabledProviders: [UsageProvider]) -> UsageProvider? { diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index d227a9ae6..fc8dd019f 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -113,7 +113,6 @@ extension StatusItemController { else { return false } - self.applyIcon(phase: nil) return true } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 287a96fff..23746a1be 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -53,30 +53,7 @@ extension UsageStore { func observeSettingsChanges() { withObservationTracking { - _ = self.settings.refreshFrequency - _ = self.settings.statusChecksEnabled - _ = self.settings.sessionQuotaNotificationsEnabled - _ = self.settings.quotaWarningNotificationsEnabled - _ = self.settings.quotaWarningThresholds - _ = self.settings.quotaWarningThresholds(.session) - _ = self.settings.quotaWarningThresholds(.weekly) - _ = self.settings.quotaWarningSoundEnabled - _ = self.settings.usageBarsShowUsed - _ = self.settings.costUsageEnabled - _ = self.settings.costUsageHistoryDays - _ = self.settings.randomBlinkEnabled - _ = self.settings.configRevision - for implementation in ProviderCatalog.all { - implementation.observeSettings(self.settings) - } - _ = self.settings.multiAccountMenuLayout - _ = self.settings.tokenAccountsByProvider - _ = self.settings.mergeIcons - _ = self.settings.selectedMenuProvider - _ = self.settings.debugLoadingPattern - _ = self.settings.debugKeepCLISessionsAlive - _ = self.settings.historicalTrackingEnabled - _ = self.settings.providerStorageFootprintsEnabled + _ = self.backgroundWorkSettingsObservationToken } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } @@ -92,6 +69,33 @@ extension UsageStore { } } + var backgroundWorkSettingsObservationToken: Int { + _ = self.settings.refreshFrequency + _ = self.settings.statusChecksEnabled + _ = self.settings.sessionQuotaNotificationsEnabled + _ = self.settings.quotaWarningNotificationsEnabled + _ = self.settings.quotaWarningThresholds + _ = self.settings.quotaWarningThresholds(.session) + _ = self.settings.quotaWarningThresholds(.weekly) + _ = self.settings.quotaWarningSoundEnabled + _ = self.settings.usageBarsShowUsed + _ = self.settings.costUsageEnabled + _ = self.settings.costUsageHistoryDays + _ = self.settings.randomBlinkEnabled + _ = self.settings.configRevision + for implementation in ProviderCatalog.all { + implementation.observeSettings(self.settings) + } + _ = self.settings.multiAccountMenuLayout + _ = self.settings.tokenAccountsByProvider + _ = self.settings.mergeIcons + _ = self.settings.debugLoadingPattern + _ = self.settings.debugKeepCLISessionsAlive + _ = self.settings.historicalTrackingEnabled + _ = self.settings.providerStorageFootprintsEnabled + return 0 + } + var attachedOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? { guard self.openAIDashboardAttachmentAuthorized else { return nil } return self.openAIDashboard diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 7c3b70790..d252d758b 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1034,6 +1034,34 @@ struct SettingsStoreTests { #expect(didChange.get() == true) } + @Test + func `menu observation token ignores merged switcher selection churn`() async throws { + let suite = "SettingsStoreTests-observation-switcher-selection" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.selectedMenuProvider = .claude + store.mergedMenuLastSelectedWasOverview.toggle() + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == false) + } + @Test func `menu observation token updates on per-window quota threshold changes`() async throws { let suite = "SettingsStoreTests-observation-quota-threshold-windows" diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 9fe93b78d..e1a3f52a5 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -129,6 +129,175 @@ struct StatusMenuSwitcherClickTests { #expect(settings.selectedMenuProvider == .codex) } + @Test + func `merged switcher runtime click refreshes icon without waiting for menu rebuild`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + 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()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(phase: nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + } + + @Test + func `merged switcher click marks menu stale before deferred rebuild`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + 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()) + defer { controller.releaseStatusItemsForTesting() } + + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + + let openedVersion = controller.menuContentVersion + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + + controller.menuDidClose(menu) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `merged switcher runtime click updates loading animation state`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(phase: nil) + #expect(controller.needsMenuBarIconAnimation() == false) + #expect(controller.animationDriver == nil) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.needsMenuBarIconAnimation() == true) + #expect(controller.animationDriver != nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + + #expect(switcher._test_simulateRuntimeClick(buttonTag: 1)) + #expect(settings.selectedMenuProvider == .codex) + #expect(controller.needsMenuBarIconAnimation() == false) + #expect(controller.animationDriver == nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + } + @Test func `merged switcher switches provider while overview chart submenu is open`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 9ffe6dcf9..3428b2d0d 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -1,10 +1,28 @@ import CodexBarCore import Foundation +import Observation import Testing @testable import CodexBar @MainActor struct UsageStoreCoverageTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test func `provider with highest usage and icon style`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-highest") @@ -501,6 +519,38 @@ struct UsageStoreCoverageTests { NSError(domain: NSCocoaErrorDomain, code: 0))) } + @Test + func `background work settings observation ignores menu provider selection churn`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-switcher-selection-observation") + settings.refreshFrequency = .manual + settings.mergeIcons = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + didChange.set() + } + + settings.selectedMenuProvider = .codex + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(didChange.get() == false) + + let refreshDidChange = ObservationFlag() + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + refreshDidChange.set() + } + + settings.refreshFrequency = .oneMinute + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(refreshDidChange.get() == true) + } + @Test func `startup status network failure schedules bounded retry`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-retry") From 7e73b414ecc85c1805fb8d904c38f0b462afce61 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 20:15:24 +0100 Subject: [PATCH 81/93] fix: handle pending menu readiness observer --- .../CodexBar/StatusItemController+Menu.swift | 6 +- ...ItemController+MenuRefreshScheduling.swift | 40 ++++++++++++ Sources/CodexBar/StatusItemController.swift | 32 ++++++---- .../StatusMenuReadinessBaselineTests.swift | 61 +++++++++++++++++++ 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index cd45853b2..75ea56fad 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -131,6 +131,7 @@ extension StatusItemController { self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") } + let menuWasFreshBeforeOpen = !self.menuNeedsRefresh(menu) self.refreshMenuForOpenIfNeeded(menu, provider: provider) if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). @@ -140,7 +141,10 @@ extension StatusItemController { // refresh `refreshMenuForOpenIfNeeded` can preserve stale content; resyncing the baseline to // live store data in that case would mask the refresh-completion update (#1351). if menuTrackingWasIdle, !self.menuNeedsRefresh(menu) { - self.resyncMenuAdjunctReadinessBaseline() + self.resyncMenuAdjunctReadinessBaselineForRootOpen( + menu, + provider: provider, + menuWasFreshBeforeOpen: menuWasFreshBeforeOpen) } self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) // Only schedule refresh after menu is registered as open - refreshNow is called async diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 62048b191..3a091f298 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -25,6 +25,46 @@ extension StatusItemController { self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() } + /// Resyncs a root-menu baseline after open and handles the narrow race where a store change + /// has updated live data but its deferred observation task has not invalidated menus yet. + func resyncMenuAdjunctReadinessBaselineForRootOpen( + _ menu: NSMenu, + provider: UsageProvider?, + menuWasFreshBeforeOpen: Bool) + { + let signature = self.menuAdjunctReadinessSignature() + guard signature != self.lastMenuAdjunctReadinessSignature else { return } + + if menuWasFreshBeforeOpen { + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.rememberRootOpenHandledMenuObservation(signature: signature) + } + self.lastMenuAdjunctReadinessSignature = signature + } + + private func rememberRootOpenHandledMenuObservation(signature: String) { + self.rootOpenHandledMenuObservationSignature = signature + Task { @MainActor [weak self] in + await Task.yield() + if self?.rootOpenHandledMenuObservationSignature == signature { + self?.rootOpenHandledMenuObservationSignature = nil + } + } + } + + func consumeRootOpenHandledMenuObservationIfNeeded() -> Bool { + guard let handledSignature = self.rootOpenHandledMenuObservationSignature else { return false } + let signature = self.menuAdjunctReadinessSignature() + guard signature == handledSignature else { + self.rootOpenHandledMenuObservationSignature = nil + return false + } + self.rootOpenHandledMenuObservationSignature = nil + self.lastMenuAdjunctReadinessSignature = signature + return true + } + func menuAdjunctReadinessSignature() -> String { let dashboard = self.store.openAIDashboard let dashboardUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 008061cd8..15a06719d 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -119,6 +119,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuVersions: [ObjectIdentifier: Int] = [:] var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" + var rootOpenHandledMenuObservationSignature: String? var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] var fallbackMenu: NSMenu? @@ -443,22 +444,29 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } - self.observeStoreChanges() - // `refreshOpenMenus` is only consulted when a menu is currently open. - // Computing the readiness signature serializes every enabled provider's - // token snapshot and 30-day daily breakdown, which is wasted main-thread - // work on the common path where no menu is open (background refresh ticks). - let refreshOpenMenus = self.openMenus.isEmpty - ? false - : self.didMenuAdjunctReadinessChange() - self.invalidateMenus( - refreshOpenMenus: refreshOpenMenus, - deferOpenParentMenuRebuild: true, - allowStaleContentDuringDataRefresh: true) + self.handleObservedStoreMenuChange() } } } + func handleObservedStoreMenuChange() { + self.observeStoreChanges() + if self.consumeRootOpenHandledMenuObservationIfNeeded() { + return + } + // `refreshOpenMenus` is only consulted when a menu is currently open. + // Computing the readiness signature serializes every enabled provider's + // token snapshot and 30-day daily breakdown, which is wasted main-thread + // work on the common path where no menu is open (background refresh ticks). + let refreshOpenMenus = self.openMenus.isEmpty + ? false + : self.didMenuAdjunctReadinessChange() + self.invalidateMenus( + refreshOpenMenus: refreshOpenMenus, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) + } + private func observeStoreIconChanges() { withObservationTracking { _ = self.store.iconObservationToken diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index 9939da860..1d22769d4 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -128,6 +128,67 @@ extension StatusMenuTests { #expect(controller.didMenuAdjunctReadinessChange()) } + @Test + func `root open before deferred store observation rebuilds and consumes matching observer`() { + // Store observation invalidates menus from a deferred main-actor task. If a closed menu opens after + // live data changes but before that task runs, it must rebuild from live data and consume the matching + // observer instead of letting it mark the freshly rebuilt visible menu stale. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + // Simulate the live store mutation being visible before the observation task has invalidated menus. + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let key = ObjectIdentifier(menu) + let versionAfterOpen = controller.menuContentVersion + let menuVersionAfterOpen = controller.menuVersions[key] + #expect(!controller.menuNeedsRefresh(menu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion == versionAfterOpen) + #expect(controller.menuVersions[key] == menuVersionAfterOpen) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + private func enableOnlyCodexForReadinessBaseline(_ settings: SettingsStore) { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { From c87bc55603cd43aa30ca56a9f23446329b338074 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 20:20:25 +0100 Subject: [PATCH 82/93] fix: invalidate sibling menus for readiness observer --- ...ItemController+MenuRefreshScheduling.swift | 5 ++ .../StatusMenuReadinessBaselineTests.swift | 69 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 3a091f298..4c4f7b9d1 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -27,6 +27,10 @@ extension StatusItemController { /// Resyncs a root-menu baseline after open and handles the narrow race where a store change /// has updated live data but its deferred observation task has not invalidated menus yet. + /// + /// If a previously fresh menu sees new live data before the observer version tick, invalidate all + /// menus first, rebuild only the opened menu, then consume the matching observer. This keeps sibling + /// provider menus stale instead of globally acknowledging data they have not rendered. func resyncMenuAdjunctReadinessBaselineForRootOpen( _ menu: NSMenu, provider: UsageProvider?, @@ -36,6 +40,7 @@ extension StatusItemController { guard signature != self.lastMenuAdjunctReadinessSignature else { return } if menuWasFreshBeforeOpen { + self.invalidateMenus() self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) self.rememberRootOpenHandledMenuObservation(signature: signature) diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index 1d22769d4..4ebc2ec8a 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -189,11 +189,78 @@ extension StatusMenuTests { #expect(!controller.didMenuAdjunctReadinessChange()) } + @Test + func `provider root open before deferred store observation leaves sibling provider menu stale`() { + // The readiness signature is global across enabled providers. In split-icon mode, opening one + // provider's menu must not consume a pending global observation while leaving sibling menus marked + // fresh even though their provider data changed. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableProvidersForReadinessBaseline(settings, providers: [.claude, .codex]) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .claude) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let claudeMenu = controller.makeMenu(for: .claude) + controller.populateMenu(claudeMenu, provider: .claude) + controller.markMenuFresh(claudeMenu) + let codexMenu = controller.makeMenu(for: .codex) + controller.populateMenu(codexMenu, provider: .codex) + controller.markMenuFresh(codexMenu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .claude) + controller.menuWillOpen(codexMenu) + defer { controller.menuDidClose(codexMenu) } + + let versionAfterOpen = controller.menuContentVersion + #expect(!controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuNeedsRefresh(claudeMenu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion == versionAfterOpen) + #expect(!controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuNeedsRefresh(claudeMenu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + private func enableOnlyCodexForReadinessBaseline(_ settings: SettingsStore) { + self.enableProvidersForReadinessBaseline(settings, providers: [.codex]) + } + + private func enableProvidersForReadinessBaseline(_ settings: SettingsStore, providers: Set) { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } - settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) } } From edc4f153df3edd996bbf8d660c1a75a5e66c1199 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 20:26:22 +0100 Subject: [PATCH 83/93] fix: preserve in-flight menu refresh handling --- ...ItemController+MenuRefreshScheduling.swift | 1 + .../StatusMenuReadinessBaselineTests.swift | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 4c4f7b9d1..9328e57d3 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -40,6 +40,7 @@ extension StatusItemController { guard signature != self.lastMenuAdjunctReadinessSignature else { return } if menuWasFreshBeforeOpen { + guard !self.isMenuDataRefreshInFlight else { return } self.invalidateMenus() self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index 4ebc2ec8a..68347b499 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -189,6 +189,64 @@ extension StatusMenuTests { #expect(!controller.didMenuAdjunctReadinessChange()) } + @Test + func `root open before deferred store observation during refresh leaves observer pending`() { + // The pre-observer root-open repair must not bypass the in-flight refresh stale-content path. + // While data is refreshing, the deferred observer should still invalidate the open menu and defer + // parent rebuild instead of marking an intermediate snapshot fresh. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let versionAfterOpen = controller.menuContentVersion + #expect(!controller.menuNeedsRefresh(menu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + @Test func `provider root open before deferred store observation leaves sibling provider menu stale`() { // The readiness signature is global across enabled providers. In split-icon mode, opening one From 6b5191e1349b42e7435ed51bb23cf26951fe144c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 20:32:15 +0100 Subject: [PATCH 84/93] fix: keep menu observation invalidation after readiness consume --- ...tatusItemController+MenuRefreshScheduling.swift | 4 ++-- Sources/CodexBar/StatusItemController.swift | 6 ++---- .../StatusMenuReadinessBaselineTests.swift | 14 +++++++------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 9328e57d3..4d71360f1 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -29,8 +29,8 @@ extension StatusItemController { /// has updated live data but its deferred observation task has not invalidated menus yet. /// /// If a previously fresh menu sees new live data before the observer version tick, invalidate all - /// menus first, rebuild only the opened menu, then consume the matching observer. This keeps sibling - /// provider menus stale instead of globally acknowledging data they have not rendered. + /// menus first and rebuild only the opened menu. The matching observer can then skip the expensive + /// readiness comparison while still invalidating menu-observed state that is not in the signature. func resyncMenuAdjunctReadinessBaselineForRootOpen( _ menu: NSMenu, provider: UsageProvider?, diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 15a06719d..3289a10ed 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -451,16 +451,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin func handleObservedStoreMenuChange() { self.observeStoreChanges() - if self.consumeRootOpenHandledMenuObservationIfNeeded() { - return - } + let rootOpenHandledReadiness = self.consumeRootOpenHandledMenuObservationIfNeeded() // `refreshOpenMenus` is only consulted when a menu is currently open. // Computing the readiness signature serializes every enabled provider's // token snapshot and 30-day daily breakdown, which is wasted main-thread // work on the common path where no menu is open (background refresh ticks). let refreshOpenMenus = self.openMenus.isEmpty ? false - : self.didMenuAdjunctReadinessChange() + : rootOpenHandledReadiness || self.didMenuAdjunctReadinessChange() self.invalidateMenus( refreshOpenMenus: refreshOpenMenus, deferOpenParentMenuRebuild: true, diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index 68347b499..6bdbd7b80 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -129,10 +129,10 @@ extension StatusMenuTests { } @Test - func `root open before deferred store observation rebuilds and consumes matching observer`() { + func `root open before deferred store observation rebuilds and refreshes matching observer`() { // Store observation invalidates menus from a deferred main-actor task. If a closed menu opens after - // live data changes but before that task runs, it must rebuild from live data and consume the matching - // observer instead of letting it mark the freshly rebuilt visible menu stale. + // live data changes but before that task runs, it must rebuild from live data and let the matching + // observer invalidate any coalesced non-readiness menu state without losing the readiness baseline. self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -183,9 +183,9 @@ extension StatusMenuTests { controller.handleObservedStoreMenuChange() - #expect(controller.menuContentVersion == versionAfterOpen) + #expect(controller.menuContentVersion != versionAfterOpen) #expect(controller.menuVersions[key] == menuVersionAfterOpen) - #expect(!controller.menuNeedsRefresh(menu)) + #expect(controller.menuNeedsRefresh(menu)) #expect(!controller.didMenuAdjunctReadinessChange()) } @@ -304,8 +304,8 @@ extension StatusMenuTests { controller.handleObservedStoreMenuChange() - #expect(controller.menuContentVersion == versionAfterOpen) - #expect(!controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuNeedsRefresh(codexMenu)) #expect(controller.menuNeedsRefresh(claudeMenu)) #expect(!controller.didMenuAdjunctReadinessChange()) } From 2bcd223aacde6958a98c4527d1a30fe5433353aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 20:38:28 +0100 Subject: [PATCH 85/93] fix: track menu readiness baseline version --- ...ItemController+MenuRefreshScheduling.swift | 22 ++++++-- Sources/CodexBar/StatusItemController.swift | 2 + .../StatusMenuReadinessBaselineTests.swift | 55 +++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 4d71360f1..bdb0a5c83 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -7,7 +7,7 @@ extension StatusItemController { func didMenuAdjunctReadinessChange() -> Bool { let signature = self.menuAdjunctReadinessSignature() - defer { self.lastMenuAdjunctReadinessSignature = signature } + defer { self.recordMenuAdjunctReadinessBaseline(signature) } return signature != self.lastMenuAdjunctReadinessSignature } @@ -22,7 +22,7 @@ extension StatusItemController { /// content during an in-flight refresh — that would record live store data while the visible menu /// still shows older content and mask the refresh-completion update. func resyncMenuAdjunctReadinessBaseline() { - self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() + self.recordMenuAdjunctReadinessBaseline(self.menuAdjunctReadinessSignature()) } /// Resyncs a root-menu baseline after open and handles the narrow race where a store change @@ -40,13 +40,27 @@ extension StatusItemController { guard signature != self.lastMenuAdjunctReadinessSignature else { return } if menuWasFreshBeforeOpen { - guard !self.isMenuDataRefreshInFlight else { return } + let menuKey = ObjectIdentifier(menu) + let menuIsFreshForNewerVersion = self.menuVersions[menuKey] == self.menuContentVersion && + self.menuContentVersion > self.lastMenuAdjunctReadinessBaselineVersion + if self.isMenuDataRefreshInFlight, !menuIsFreshForNewerVersion { + return + } + if menuIsFreshForNewerVersion { + self.recordMenuAdjunctReadinessBaseline(signature) + return + } self.invalidateMenus() self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) self.rememberRootOpenHandledMenuObservation(signature: signature) } + self.recordMenuAdjunctReadinessBaseline(signature) + } + + private func recordMenuAdjunctReadinessBaseline(_ signature: String) { self.lastMenuAdjunctReadinessSignature = signature + self.lastMenuAdjunctReadinessBaselineVersion = self.menuContentVersion } private func rememberRootOpenHandledMenuObservation(signature: String) { @@ -67,7 +81,7 @@ extension StatusItemController { return false } self.rootOpenHandledMenuObservationSignature = nil - self.lastMenuAdjunctReadinessSignature = signature + self.recordMenuAdjunctReadinessBaseline(signature) return true } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 3289a10ed..c6baca4f4 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -119,6 +119,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuVersions: [ObjectIdentifier: Int] = [:] var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" + var lastMenuAdjunctReadinessBaselineVersion = 0 var rootOpenHandledMenuObservationSignature: String? var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] @@ -374,6 +375,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin metadata: ["keys": repairedStatusItemVisibilityKeys.joined(separator: ",")]) } self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() + self.lastMenuAdjunctReadinessBaselineVersion = self.menuContentVersion self.wireBindings() self.updateVisibility() self.updateIcons() diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index 6bdbd7b80..ef81c33a1 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -247,6 +247,61 @@ extension StatusMenuTests { #expect(!controller.didMenuAdjunctReadinessChange()) } + @Test + func `fresh newer-version root open during unrelated refresh still reanchors baseline`() { + // An in-flight refresh elsewhere must not block re-anchoring when this menu was already rebuilt for + // a newer menuContentVersion. Otherwise the stale baseline can still hide a later reverted update. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + + store.isRefreshing = true + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + #expect(controller.didMenuAdjunctReadinessChange()) + } + @Test func `provider root open before deferred store observation leaves sibling provider menu stale`() { // The readiness signature is global across enabled providers. In split-icon mode, opening one From 534d8e888fe2f450d29b8a94a18df6e26323be64 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 20:43:55 +0100 Subject: [PATCH 86/93] fix: advance readiness baseline version on equal signature --- ...ItemController+MenuRefreshScheduling.swift | 5 +- .../StatusMenuReadinessBaselineTests.swift | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index bdb0a5c83..8d4e87ce8 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -37,7 +37,10 @@ extension StatusItemController { menuWasFreshBeforeOpen: Bool) { let signature = self.menuAdjunctReadinessSignature() - guard signature != self.lastMenuAdjunctReadinessSignature else { return } + guard signature != self.lastMenuAdjunctReadinessSignature else { + self.lastMenuAdjunctReadinessBaselineVersion = self.menuContentVersion + return + } if menuWasFreshBeforeOpen { let menuKey = ObjectIdentifier(menu) diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index ef81c33a1..daf8948fa 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -302,6 +302,66 @@ extension StatusMenuTests { #expect(controller.didMenuAdjunctReadinessChange()) } + @Test + func `equal-signature root open advances baseline version before next pre-observer change`() { + // A root open whose signature still equals the baseline can nevertheless confirm that the visible + // menu is fresh for a newer menuContentVersion. Record that version so a later live-data change before + // its deferred observer does not look like already-rendered data. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + let versionBeforeChange = controller.menuContentVersion + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeChange) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + @Test func `provider root open before deferred store observation leaves sibling provider menu stale`() { // The readiness signature is global across enabled providers. In split-icon mode, opening one From 16287c0280074123b507412b171819a96d611662 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 20:48:16 +0100 Subject: [PATCH 87/93] fix: track rendered menu readiness signatures --- ...ItemController+MenuRefreshScheduling.swift | 8 +-- .../StatusItemController+MenuTracking.swift | 2 + Sources/CodexBar/StatusItemController.swift | 1 + .../StatusMenuReadinessBaselineTests.swift | 66 +++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 8d4e87ce8..93dee1641 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -44,12 +44,12 @@ extension StatusItemController { if menuWasFreshBeforeOpen { let menuKey = ObjectIdentifier(menu) - let menuIsFreshForNewerVersion = self.menuVersions[menuKey] == self.menuContentVersion && - self.menuContentVersion > self.lastMenuAdjunctReadinessBaselineVersion - if self.isMenuDataRefreshInFlight, !menuIsFreshForNewerVersion { + let menuRenderedCurrentSignature = self.menuVersions[menuKey] == self.menuContentVersion && + self.menuReadinessSignatures[menuKey] == signature + if self.isMenuDataRefreshInFlight, !menuRenderedCurrentSignature { return } - if menuIsFreshForNewerVersion { + if menuRenderedCurrentSignature { self.recordMenuAdjunctReadinessBaseline(signature) return } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index f5c0b8ed9..542cbf932 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -91,6 +91,7 @@ extension StatusItemController { func clearTransientMenuTrackingState(_ key: ObjectIdentifier) { self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) + self.menuReadinessSignatures.removeValue(forKey: key) self.closedMenusDeferredUntilNextOpen.remove(key) } @@ -227,6 +228,7 @@ extension StatusItemController { func markMenuFresh(_ menu: NSMenu) { let key = ObjectIdentifier(menu) self.menuVersions[key] = self.menuContentVersion + self.menuReadinessSignatures[key] = self.menuAdjunctReadinessSignature() } func hasOpenHostedSubviewMenu() -> Bool { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index c6baca4f4..73372640e 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -117,6 +117,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuContentVersion: Int = 0 var latestRequiredMenuRebuildVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] + var menuReadinessSignatures: [ObjectIdentifier: String] = [:] var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" var lastMenuAdjunctReadinessBaselineVersion = 0 diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index daf8948fa..f2f4448fb 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -362,6 +362,72 @@ extension StatusMenuTests { #expect(!controller.didMenuAdjunctReadinessChange()) } + @Test + func `newer-version root open rebuilds when rendered signature is older than live data`() { + // A menu can be fresh for the current menuContentVersion while still having rendered an older + // readiness signature than the current live store. Root open must rebuild in that pre-observer gap. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + let snapshotC = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 333, + sessionCostUSD: 3.33, + last30DaysTokens: 3333, + last30DaysCostUSD: 33.33, + updatedAt: Date(timeIntervalSince1970: 300)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + let versionBeforeChange = controller.menuContentVersion + store._setTokenSnapshotForTesting(snapshotC, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeChange) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + @Test func `provider root open before deferred store observation leaves sibling provider menu stale`() { // The readiness signature is global across enabled providers. In split-icon mode, opening one From 1b0b1d67edb323eee498e35d8365b539d40be115 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 20:54:49 +0100 Subject: [PATCH 88/93] fix: repair equal-signature menu readiness reopen --- ...ItemController+MenuRefreshScheduling.swift | 17 ++++-- .../StatusMenuReadinessBaselineTests.swift | 61 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 93dee1641..88f5a8265 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -37,15 +37,24 @@ extension StatusItemController { menuWasFreshBeforeOpen: Bool) { let signature = self.menuAdjunctReadinessSignature() + let menuKey = ObjectIdentifier(menu) + let menuRenderedCurrentSignature = self.menuVersions[menuKey] == self.menuContentVersion && + self.menuReadinessSignatures[menuKey] == signature guard signature != self.lastMenuAdjunctReadinessSignature else { - self.lastMenuAdjunctReadinessBaselineVersion = self.menuContentVersion + guard menuWasFreshBeforeOpen, !menuRenderedCurrentSignature else { + self.lastMenuAdjunctReadinessBaselineVersion = self.menuContentVersion + return + } + guard !self.isMenuDataRefreshInFlight else { return } + self.invalidateMenus() + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.rememberRootOpenHandledMenuObservation(signature: signature) + self.recordMenuAdjunctReadinessBaseline(signature) return } if menuWasFreshBeforeOpen { - let menuKey = ObjectIdentifier(menu) - let menuRenderedCurrentSignature = self.menuVersions[menuKey] == self.menuContentVersion && - self.menuReadinessSignatures[menuKey] == signature if self.isMenuDataRefreshInFlight, !menuRenderedCurrentSignature { return } diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift index f2f4448fb..8cf42bbf9 100644 --- a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -428,6 +428,67 @@ extension StatusMenuTests { #expect(!controller.didMenuAdjunctReadinessChange()) } + @Test + func `equal-signature root open rebuilds when rendered signature reverted before observer`() { + // A closed provider menu can be rebuilt from B while the readiness baseline remains A. If live data + // reverts to A before the deferred observer runs, root open must still repair the B-rendered menu. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + let key = ObjectIdentifier(menu) + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + + let renderedBSignature = controller.menuReadinessSignatures[key] + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let versionBeforeOpen = controller.menuContentVersion + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeOpen) + #expect(controller.menuReadinessSignatures[key] != renderedBSignature) + #expect(controller.menuReadinessSignatures[key] == controller.menuAdjunctReadinessSignature()) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + @Test func `provider root open before deferred store observation leaves sibling provider menu stale`() { // The readiness signature is global across enabled providers. In split-icon mode, opening one From 8448b81dfc1fadad2f1d2caf9fab0d5578aa9df9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 21:00:27 +0100 Subject: [PATCH 89/93] docs: update changelog for menu performance fixes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9bcf9e2f..2a6a5ea8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - Localization: add Vietnamese as a selectable app language (#1247). Thanks @Yuxin-Qiao! ### Fixed +- Localization: cache resolved localized bundles so repeated menu/status text lookups no longer hit disk on the main thread (#1355, fixes #1347). Thanks @Yuxin-Qiao! +- Menu bar: size hosted chart submenus directly instead of spinning up throwaway SwiftUI hosting controllers during menu layout (#1352). Thanks @Yuxin-Qiao! +- Menu bar: avoid recomputing expensive readiness signatures on closed-menu store ticks while preserving root-open refresh correctness for deferred observations (#1351). Thanks @Yuxin-Qiao! +- Menu bar: defer Quit from the status menu until AppKit menu tracking unwinds so shutdown does not wedge Dock autohide state (#1354, fixes #1353). Thanks @jskoiz! - Claude: remove transient ClaudeProbe session artifacts after CLI usage polls so background refreshes no longer fill Claude Code project history with CodexBar `/usage` sessions (#1301). Thanks @LPFchan and @matthewod11-stack! - Menu bar: keep z.ai overview rows with detail submenus in Overview so hovering quota details no longer recurses into a nested provider menu (#1279, fixes #1246). Thanks @RajvardhanPatil07! - Codex: backfill visible-account reset timestamps and missing 5-hour/weekly window metadata from same-workspace plan history so segmented multi-account JSON keeps machine-readable reset data (#1283). Thanks @callmepopo! From 100c8d3a90c779bf5d0af1ef93d71ea0984d58aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Jun 2026 21:00:59 +0100 Subject: [PATCH 90/93] style: format status controller declarations --- Sources/CodexBar/StatusItemController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 51fe8db2d..c29728e4d 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -150,9 +150,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } } + var terminateApplicationForQuit: @MainActor () -> Void = { NSApp.terminate(nil) } + var openMenuInvalidationRetryTask: Task? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? From f975a0a98aa6ffafb2288232eeedbcf321bb629a Mon Sep 17 00:00:00 2001 From: ColumbusLabs <287001685+ColumbusLabs@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:50:51 -0400 Subject: [PATCH 91/93] Track reviewed upstream monitor base --- .github/workflows/upstream-monitor.yml | 24 +++++++++++++++++------- Scripts/check_upstreams.sh | 23 ++++++++++++++++------- docs/versioning.md | 10 ++++++++-- version.env | 5 +++++ 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/.github/workflows/upstream-monitor.yml b/.github/workflows/upstream-monitor.yml index 2f7d219a4..0e6f27503 100644 --- a/.github/workflows/upstream-monitor.yml +++ b/.github/workflows/upstream-monitor.yml @@ -78,16 +78,24 @@ jobs: UPSTREAM_BRANCH=$(remote_default_branch upstream) UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}" UPSTREAM_VERSION=$(awk -F= '$1 == "UPSTREAM_VERSION" {print $2; exit}' version.env) - UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}" - if [ -z "$UPSTREAM_VERSION" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then - echo "Could not resolve UPSTREAM_VERSION=${UPSTREAM_VERSION:-} from version.env" >&2 + UPSTREAM_MONITOR_BASE=$(awk -F= '$1 == "UPSTREAM_MONITOR_BASE" {print $2; exit}' version.env) + if [ -n "$UPSTREAM_MONITOR_BASE" ]; then + UPSTREAM_BASE_REF="$UPSTREAM_MONITOR_BASE" + UPSTREAM_BASE_LABEL="$UPSTREAM_MONITOR_BASE" + else + UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}" + UPSTREAM_BASE_LABEL="$UPSTREAM_VERSION" + fi + if [ -z "$UPSTREAM_BASE_REF" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then + echo "Could not resolve upstream monitor base ${UPSTREAM_BASE_REF:-} from version.env" >&2 exit 1 fi echo "upstream_ref=$UPSTREAM_REF" >> $GITHUB_OUTPUT echo "upstream_version=$UPSTREAM_VERSION" >> $GITHUB_OUTPUT echo "upstream_base_ref=$UPSTREAM_BASE_REF" >> $GITHUB_OUTPUT + echo "upstream_base_label=$UPSTREAM_BASE_LABEL" >> $GITHUB_OUTPUT - # Count new commits since the upstream version last recorded in version.env. + # Count new commits since the upstream ref last reviewed/merged by QuotaKit. UPSTREAM_NEW=$(git log --oneline "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ') echo "upstream_commits=$UPSTREAM_NEW" >> $GITHUB_OUTPUT @@ -105,12 +113,14 @@ jobs: const upstreamRef = '${{ steps.check.outputs.upstream_ref }}'; const upstreamVersion = '${{ steps.check.outputs.upstream_version }}'; const upstreamBaseRef = '${{ steps.check.outputs.upstream_base_ref }}'; + const upstreamBaseLabel = '${{ steps.check.outputs.upstream_base_label }}'; const upstreamBranch = upstreamRef.replace('upstream/', ''); const upstreamSummary = `${{ steps.check.outputs.upstream_summary }}`; const body = `## Upstream Changes Detected - **steipete/CodexBar:** ${upstreamCommits} new commits since \`${upstreamVersion}\` + **steipete/CodexBar:** ${upstreamCommits} new commits since \`${upstreamBaseLabel}\` + **Last shipped upstream version:** \`${upstreamVersion}\` **Source refs:** ${upstreamBaseRef}..${upstreamRef} ### steipete/CodexBar Recent Commits @@ -131,7 +141,7 @@ jobs: \`\`\` ### Links - - [steipete commits](https://github.com/steipete/CodexBar/compare/${upstreamVersion}...steipete:CodexBar:${upstreamBranch}) + - [steipete commits](https://github.com/steipete/CodexBar/compare/${upstreamBaseLabel}...steipete:CodexBar:${upstreamBranch}) --- *Auto-generated by upstream-monitor workflow* @@ -159,7 +169,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issues.data[0].number, - body: `Updated with latest steipete/CodexBar changes (${upstreamCommits} upstream commits since ${upstreamVersion})` + body: `Updated with latest steipete/CodexBar changes (${upstreamCommits} upstream commits since ${upstreamBaseLabel})` }); } else { // Create new issue diff --git a/Scripts/check_upstreams.sh b/Scripts/check_upstreams.sh index fabedcfc6..eaf5a4b4a 100755 --- a/Scripts/check_upstreams.sh +++ b/Scripts/check_upstreams.sh @@ -67,31 +67,40 @@ echo -e "${BLUE}==> Upstream (steipete/CodexBar) changes:${NC}" UPSTREAM_BRANCH=$(remote_default_branch upstream) UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}" UPSTREAM_VERSION=$(awk -F= '$1 == "UPSTREAM_VERSION" {print $2; exit}' version.env) -UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}" -if [ -z "$UPSTREAM_VERSION" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then - echo -e "${RED}Error: Could not resolve UPSTREAM_VERSION=${UPSTREAM_VERSION:-} from version.env.${NC}" >&2 +UPSTREAM_MONITOR_BASE=$(awk -F= '$1 == "UPSTREAM_MONITOR_BASE" {print $2; exit}' version.env) +if [ -n "$UPSTREAM_MONITOR_BASE" ]; then + UPSTREAM_BASE_REF="$UPSTREAM_MONITOR_BASE" + UPSTREAM_BASE_LABEL="$UPSTREAM_MONITOR_BASE" +else + UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}" + UPSTREAM_BASE_LABEL="$UPSTREAM_VERSION" +fi +if [ -z "$UPSTREAM_BASE_REF" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then + echo -e "${RED}Error: Could not resolve upstream monitor base ${UPSTREAM_BASE_REF:-} from version.env.${NC}" >&2 exit 1 fi UPSTREAM_COUNT=$(git log --oneline "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ') if [ "$UPSTREAM_COUNT" -gt 0 ]; then - echo -e "${GREEN}Found $UPSTREAM_COUNT new commits since $UPSTREAM_VERSION${NC}" + echo -e "${GREEN}Found $UPSTREAM_COUNT new commits since $UPSTREAM_BASE_LABEL${NC}" + echo "Last shipped upstream version: $UPSTREAM_VERSION" echo "" git log --oneline --graph "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges | head -20 || true echo "" echo -e "${YELLOW}Files changed:${NC}" git diff --stat "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" | tail -20 || true else - echo -e "${GREEN}No new commits since $UPSTREAM_VERSION${NC}" + echo -e "${GREEN}No new commits since $UPSTREAM_BASE_LABEL${NC}" + echo "Last shipped upstream version: $UPSTREAM_VERSION" fi echo "" # Summary echo -e "${BLUE}==> Summary${NC}" -echo "Upstream commits since $UPSTREAM_VERSION: $UPSTREAM_COUNT" +echo "Upstream commits since $UPSTREAM_BASE_LABEL: $UPSTREAM_COUNT" echo "" echo -e "${YELLOW}Next steps:${NC}" echo " Review upstream: ./Scripts/review_upstream.sh upstream" -echo " Detailed diff: git diff upstream/$UPSTREAM_VERSION..upstream/$UPSTREAM_BRANCH" +echo " Detailed diff: git diff $UPSTREAM_BASE_REF..upstream/$UPSTREAM_BRANCH" diff --git a/docs/versioning.md b/docs/versioning.md index a5e74e739..5a4d44b31 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -9,8 +9,14 @@ QuotaKit currently tracks three version lanes. - `MARKETING_VERSION`: Mac app version. - `BUILD_NUMBER`: Mac build number. - `MOBILE_VERSION`: paired iOS companion version. -- `UPSTREAM_VERSION`: last upstream CodexBar version incorporated. -- `UPSTREAM_SYNC_DATE`: date that upstream alignment was last confirmed. +- `UPSTREAM_VERSION`: last upstream CodexBar version shipped to users. +- `UPSTREAM_SYNC_DATE`: date that shipped upstream alignment was last confirmed. +- `UPSTREAM_MONITOR_BASE`: last upstream CodexBar commit already merged or reviewed for the daily monitor. + +`UPSTREAM_VERSION` is release-facing and should only advance after users can get +the corresponding QuotaKit release. `UPSTREAM_MONITOR_BASE` is workflow-facing +and should advance when an upstream sync PR lands, so the daily monitor only +reopens issues for newly-arrived Pete upstream commits. The Mac release tag for Columbus Labs releases is: diff --git a/version.env b/version.env index febabb525..4c48735ca 100644 --- a/version.env +++ b/version.env @@ -6,3 +6,8 @@ MOBILE_VERSION=1.11.1 # Bump this after the merged version is actually live, not at merge time. UPSTREAM_VERSION=v0.32.4 UPSTREAM_SYNC_DATE=2026-06-06 +# Last upstream commit already merged/reviewed for the daily monitor. +# Advance this when an upstream sync PR lands. It is independent of shipped +# release tracking above, so the monitor does not reopen stale issues while a +# merged upstream sync has not yet shipped to users. +UPSTREAM_MONITOR_BASE=100c8d3a90c779bf5d0af1ef93d71ea0984d58aa From 9778128bcb9c75778fd7ec6f4063e61ac1a992dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Jun 2026 01:20:28 +0100 Subject: [PATCH 92/93] perf: eliminate merged menu switching hangs --- .../CodexBar/StatusItemController+Menu.swift | 126 +++++----- ...tatusItemController+MenuLocalization.swift | 1 + ...tatusItemController+MenuPresentation.swift | 6 +- ...ItemController+MenuRefreshScheduling.swift | 2 +- .../StatusItemController+MenuTracking.swift | 6 +- .../StatusItemController+MenuWidthCache.swift | 98 ++++++++ ...ontroller+MergedSwitcherContentCache.swift | 96 ++++++++ ...tatusItemController+OverviewSubmenus.swift | 29 +++ ...tusItemController+ProviderNavigation.swift | 46 +++- ...tatusItemController+ProviderSwitcher.swift | 90 ++++++- .../StatusItemController+Shutdown.swift | 6 + .../StatusItemController+SwitcherViews.swift | 204 ++++++++++----- Sources/CodexBar/StatusItemController.swift | 10 + .../CodexBar/UsageStore+ProviderStorage.swift | 98 +++++++- Sources/CodexBar/UsageStore.swift | 2 + .../ProviderStorageFootprintTests.swift | 63 +++++ .../StatusMenuHeightCacheTests.swift | 63 +++++ .../StatusMenuSwitcherClickTests.swift | 70 +++++- .../StatusMenuSwitcherRefreshTests.swift | 233 +++++++++++++++++- .../StatusMenuSwitcherTrackingTests.swift | 186 ++++++++++++++ 20 files changed, 1283 insertions(+), 152 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+MenuWidthCache.swift create mode 100644 Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift create mode 100644 Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 5610481c3..db4b49abc 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -9,7 +9,7 @@ import SwiftUI extension StatusItemController { static let menuCardBaseWidth: CGFloat = 310 private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit - private static let overviewRowIdentifierPrefix = "overviewRow-" + static let overviewRowIdentifierPrefix = "overviewRow-" private static let defaultMenuOpenRefreshDelay: Duration = .seconds(1.2) #if DEBUG private static var menuOpenRefreshDelayForTesting: Duration = .seconds(1.2) @@ -58,13 +58,6 @@ extension StatusItemController { return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) } - private func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { - let measuringMenu = NSMenu() - measuringMenu.autoenablesItems = false - self.addActionableSections(sections, to: measuringMenu, width: baseWidth) - return ceil(measuringMenu.size.width) - } - func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) @@ -130,6 +123,9 @@ extension StatusItemController { if self.isMenuRefreshEnabled, (provider ?? self.lastMenuProvider) == .codex { self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") } + if self.settings.providerStorageFootprintsEnabled { + self.store.refreshStorageFootprintsForOverview() + } let menuWasFreshBeforeOpen = !self.menuNeedsRefresh(menu) self.refreshMenuForOpenIfNeeded(menu, provider: provider) @@ -168,6 +164,7 @@ extension StatusItemController { } self.cancelClosedMenuRebuild(menu) + self.clearMergedSwitcherContentCache(for: menu) self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() @@ -412,12 +409,30 @@ extension StatusItemController { switcherView.updateSelection(context.switcherSelection) switcherView.updateQuotaIndicators() } + if let outgoingSelection = self.lastMergedMenuContentSelection, + outgoingSelection != context.switcherSelection + { + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: outgoingSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth) + } while menu.items.count > contentStartIndex { menu.removeItem(at: contentStartIndex) } let enabledProviders = self.store.enabledProvidersForDisplay() self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + if self.addCachedMergedSwitcherContent( + for: context.switcherSelection, + to: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + return + } self.addCodexAccountSwitcherIfNeeded( to: menu, display: context.codexAccountDisplay, @@ -438,6 +453,12 @@ extension StatusItemController { openAIContext: context.openAIContext) self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) } } @@ -446,7 +467,9 @@ extension StatusItemController { context: MenuRebuildContext) { self.performMenuMutationWithoutAnimation { + self.lastMergedMenuContentSelection = nil menu.removeAllItems() + let contentSelection = context.switcherSelection ?? .provider(context.currentProvider) self.addProviderSwitcherIfNeeded( to: menu, enabledProviders: context.enabledProviders, @@ -460,6 +483,17 @@ extension StatusItemController { context.switcherSelection, context.includesOverview) } + if self.shouldMergeIcons, + context.enabledProviders.count > 1, + self.addCachedMergedSwitcherContent( + for: contentSelection, + to: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + return + } self.addCodexAccountSwitcherIfNeeded( to: menu, display: context.codexAccountDisplay, @@ -480,8 +514,14 @@ extension StatusItemController { self.addPrimaryMenuContent( to: menu, context: menuContext, - switcherSelection: context.switcherSelection ?? .provider(context.currentProvider)) + switcherSelection: contentSelection) self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: contentSelection, + contentStartIndex: self.providerSwitcherContentStartIndex(in: menu), + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) } } @@ -729,7 +769,6 @@ extension StatusItemController { context: MenuCardContext, switcherSelection: ProviderSwitcherSelection) { - self.store.refreshStorageFootprintsForOverview() if switcherSelection == .overview { let enabledProviders = self.store.enabledProvidersForDisplay() if self.addOverviewRows( @@ -766,7 +805,7 @@ extension StatusItemController { } } - private func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { + func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { let actionableSections = sections.filter { section in section.entries.contains { entry in if case .action = entry { return true } @@ -993,25 +1032,27 @@ extension StatusItemController { }, onSelect: { [weak self, weak menu] selection in guard let self, let menu else { return } - let provider: UsageProvider? - switch selection { - case .overview: - self.settings.mergedMenuLastSelectedWasOverview = true - provider = self.resolvedMenuProvider() - case let .provider(selectedProvider): - self.settings.mergedMenuLastSelectedWasOverview = false - self.selectedMenuProvider = selectedProvider - provider = selectedProvider - } - switch selection { - case .overview: - self.lastMenuProvider = provider ?? .codex - case let .provider(provider): - self.lastMenuProvider = provider + var provider: UsageProvider? + self.preservingMergedSwitcherContentCachesDuringInvalidation { + switch selection { + case .overview: + self.settings.mergedMenuLastSelectedWasOverview = true + provider = self.resolvedMenuProvider() + case let .provider(selectedProvider): + self.settings.mergedMenuLastSelectedWasOverview = false + self.selectedMenuProvider = selectedProvider + provider = selectedProvider + } + switch selection { + case .overview: + self.lastMenuProvider = provider ?? .codex + case let .provider(provider): + self.lastMenuProvider = provider + } + self.lastMergedSwitcherSelection = selection + self.refreshProviderSelectionDependentUI(deferRendering: true) } - self.lastMergedSwitcherSelection = selection - self.refreshProviderSelectionDependentUI() - self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) + self.requestProviderSwitcherMenuRebuild(menu, provider: provider) }) let item = NSMenuItem() item.view = view @@ -1579,31 +1620,4 @@ extension StatusItemController { @objc func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } - - @objc private func selectOverviewProvider(_ sender: NSMenuItem) { - guard let represented = sender.representedObject as? String, - represented.hasPrefix(Self.overviewRowIdentifierPrefix) - else { - return - } - let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) - guard let provider = UsageProvider(rawValue: rawProvider), - let menu = sender.menu - else { - return - } - - self.selectOverviewProvider(provider, menu: menu) - } - - private func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { - if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } - self.settings.mergedMenuLastSelectedWasOverview = false - self.lastMergedSwitcherSelection = nil - self.selectedMenuProvider = provider - self.lastMenuProvider = provider - self.refreshProviderSelectionDependentUI() - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - } } diff --git a/Sources/CodexBar/StatusItemController+MenuLocalization.swift b/Sources/CodexBar/StatusItemController+MenuLocalization.swift index e90f03de7..abad4320d 100644 --- a/Sources/CodexBar/StatusItemController+MenuLocalization.swift +++ b/Sources/CodexBar/StatusItemController+MenuLocalization.swift @@ -25,6 +25,7 @@ extension StatusItemController { self.lastSwitcherProviders = providers self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed self.lastMergedSwitcherSelection = selection + self.lastMergedMenuContentSelection = selection self.lastSwitcherIncludesOverview = includesOverview self.lastMenuLocalizationSignature = self.menuLocalizationSignature() } diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index f1097a50b..527e35439 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -127,9 +127,9 @@ final class MenuCardItemHostingView: NSHostingView, Menu } func measuredHeight(width: CGFloat) -> CGFloat { - let controller = NSHostingController(rootView: self.rootView) - let measured = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - return measured.height + self.frame = NSRect(origin: self.frame.origin, size: NSSize(width: width, height: 1)) + self.layoutSubtreeIfNeeded() + return self.fittingSize.height } func setHighlighted(_ highlighted: Bool) { diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 88f5a8265..f9aab228d 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -3,7 +3,7 @@ import CodexBarCore import QuartzCore extension StatusItemController { - private static let providerSwitcherMenuRebuildDebounceNanoseconds: UInt64 = 45_000_000 + private static let providerSwitcherMenuRebuildDebounceNanoseconds: UInt64 = 0 func didMenuAdjunctReadinessChange() -> Bool { let signature = self.menuAdjunctReadinessSignature() diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 542cbf932..2df363724 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -32,8 +32,12 @@ extension StatusItemController { guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 + let preservesMergedSwitcherContentCaches = self.preservesMergedSwitcherContentCachesDuringInvalidation + if !preservesMergedSwitcherContentCaches { + self.clearMergedSwitcherContentCaches() + } self.pruneVersionScopedMenuCardHeightCache() - if !allowStaleContentDuringDataRefresh { + if !allowStaleContentDuringDataRefresh, !preservesMergedSwitcherContentCaches { self.latestRequiredMenuRebuildVersion = self.menuContentVersion } guard self.isMenuRefreshEnabled else { return } diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift new file mode 100644 index 000000000..a376958e1 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -0,0 +1,98 @@ +import AppKit + +extension StatusItemController { + private static let measuredStandardMenuWidthCacheLimit = 96 + + func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { + let cacheKey = self.measuredStandardMenuWidthCacheKey(for: sections, baseWidth: baseWidth) + if let cached = self.measuredStandardMenuWidthCache[cacheKey] { + return cached + } + + let measuringMenu = NSMenu() + measuringMenu.autoenablesItems = false + self.addActionableSections(sections, to: measuringMenu, width: baseWidth) + let measured = ceil(measuringMenu.size.width) + if self.measuredStandardMenuWidthCache.count >= Self.measuredStandardMenuWidthCacheLimit { + self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: true) + } + self.measuredStandardMenuWidthCache[cacheKey] = measured + return measured + } + + private func measuredStandardMenuWidthCacheKey( + for sections: [MenuDescriptor.Section], + baseWidth: CGFloat) -> String + { + var parts = [ + "base=\(Int((baseWidth * 100).rounded()))", + "font=\(Self.menuCardHeightTextScaleToken())", + self.menuLocalizationSignature(), + ] + for section in sections { + parts.append("[") + for entry in section.entries { + parts.append(self.measuredStandardMenuWidthCacheToken(for: entry)) + } + parts.append("]") + } + return parts.joined(separator: "\u{1f}") + } + + private func measuredStandardMenuWidthCacheToken(for entry: MenuDescriptor.Entry) -> String { + switch entry { + case let .text(text, style): + "text:\(style):\(text)" + case let .action(title, action): + "action:\(title):\(self.measuredStandardMenuWidthCacheToken(for: action))" + case let .submenu(title, systemImageName, submenuItems): + "submenu:\(title):\(systemImageName ?? ""):" + submenuItems.map { item in + [ + item.title, + item.isEnabled ? "1" : "0", + item.isChecked ? "1" : "0", + item.action.map(self.measuredStandardMenuWidthCacheToken(for:)) ?? "", + ].joined(separator: ":") + }.joined(separator: ",") + case .divider: + "divider" + } + } + + private func measuredStandardMenuWidthCacheToken(for action: MenuDescriptor.MenuAction) -> String { + switch action { + case .installUpdate: + "installUpdate" + case .refresh: + "refresh" + case .refreshAugmentSession: + "refreshAugmentSession" + case .dashboard: + "dashboard" + case .statusPage: + "statusPage" + case .changelog: + "changelog" + case .addCodexAccount: + "addCodexAccount:\(self.codexAddAccountSubtitle() ?? "")" + case let .requestCodexSystemPromotion(id): + "requestCodexSystemPromotion:\(id)" + case let .addProviderAccount(provider): + "addProviderAccount:\(provider.rawValue)" + case let .switchAccount(provider): + "switchAccount:\(provider.rawValue):\(self.switchAccountSubtitle(for: provider) ?? "")" + case let .openTerminal(command): + "openTerminal:\(command)" + case let .loginToProvider(url): + "loginToProvider:\(url)" + case .settings: + "settings" + case .about: + "about" + case .quit: + "quit" + case let .copyError(message): + "copyError:\(message)" + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift new file mode 100644 index 000000000..b31c447c9 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -0,0 +1,96 @@ +import AppKit + +struct CachedMergedSwitcherMenuContent { + let requiredMenuContentVersion: Int + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let localizationSignature: String + let items: [NSMenuItem] + + func matches( + requiredMenuContentVersion: Int, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?, + localizationSignature: String) + -> Bool + { + self.requiredMenuContentVersion >= requiredMenuContentVersion && + abs(self.menuWidth - menuWidth) <= 0.5 && + self.codexAccountDisplay == codexAccountDisplay && + self.tokenAccountDisplay == tokenAccountDisplay && + self.localizationSignature == localizationSignature + } +} + +extension StatusItemController { + func preservingMergedSwitcherContentCachesDuringInvalidation(_ body: () -> Void) { + let previous = self.preservesMergedSwitcherContentCachesDuringInvalidation + self.preservesMergedSwitcherContentCachesDuringInvalidation = true + defer { self.preservesMergedSwitcherContentCachesDuringInvalidation = previous } + body() + } + + func clearMergedSwitcherContentCaches() { + self.mergedSwitcherContentCaches.removeAll(keepingCapacity: true) + } + + func clearMergedSwitcherContentCache(for menu: NSMenu) { + self.mergedSwitcherContentCaches.removeValue(forKey: ObjectIdentifier(menu)) + } + + func cacheVisibleMergedSwitcherContent( + in menu: NSMenu, + selection: ProviderSwitcherSelection, + contentStartIndex: Int, + menuWidth: CGFloat, + contentVersion: Int? = nil) + { + guard self.shouldMergeIcons else { return } + guard menu.items.first?.view is ProviderSwitcherView else { return } + guard contentStartIndex < menu.items.count else { return } + let items = Array(menu.items[contentStartIndex...]) + guard !items.isEmpty else { return } + + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: contentVersion ?? + self.menuVersions[ObjectIdentifier(menu)] ?? + self.latestRequiredMenuRebuildVersion, + menuWidth: menuWidth, + codexAccountDisplay: self.lastCodexAccountMenuDisplay, + tokenAccountDisplay: self.lastTokenAccountMenuDisplay, + localizationSignature: self.lastMenuLocalizationSignature, + items: items) + self.mergedSwitcherContentCaches[ObjectIdentifier(menu), default: [:]][selection] = entry + } + + func addCachedMergedSwitcherContent( + for selection: ProviderSwitcherSelection, + to menu: NSMenu, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?) + -> Bool + { + let key = ObjectIdentifier(menu) + guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return false } + guard entry.matches( + requiredMenuContentVersion: self.latestRequiredMenuRebuildVersion, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + localizationSignature: self.menuLocalizationSignature()) + else { + self.mergedSwitcherContentCaches[key]?.removeValue(forKey: selection) + return false + } + + self.lastCodexAccountMenuDisplay = codexAccountDisplay + self.lastTokenAccountMenuDisplay = tokenAccountDisplay + for item in entry.items { + menu.addItem(item) + } + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index bfefcf78d..4d3444edd 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -27,4 +27,33 @@ extension StatusItemController { } return self.makeStorageBreakdownSubmenu(provider: provider, width: width) } + + @objc func selectOverviewProvider(_ sender: NSMenuItem) { + guard let represented = sender.representedObject as? String, + represented.hasPrefix(Self.overviewRowIdentifierPrefix) + else { + return + } + let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) + guard let provider = UsageProvider(rawValue: rawProvider), + let menu = sender.menu + else { + return + } + + self.selectOverviewProvider(provider, menu: menu) + } + + func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { + if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } + self.preservingMergedSwitcherContentCachesDuringInvalidation { + self.settings.mergedMenuLastSelectedWasOverview = false + self.lastMergedSwitcherSelection = .provider(provider) + self.selectedMenuProvider = provider + self.lastMenuProvider = provider + self.refreshProviderSelectionDependentUI(deferRendering: true) + } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } } diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift index 8e16a244f..6611fdd52 100644 --- a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -1,11 +1,33 @@ import CodexBarCore extension StatusItemController { - func refreshProviderSelectionDependentUI(refreshOpenMenus: Bool = false) { + func refreshProviderSelectionDependentUI( + refreshOpenMenus: Bool = false, + deferRendering: Bool = false) + { #if DEBUG guard !self.isReleasedForTesting else { return } #endif self.invalidateMenus(refreshOpenMenus: refreshOpenMenus) + if deferRendering { + self.scheduleProviderSelectionUIRefresh() + return + } + self.refreshProviderSelectionRendering() + } + + private func scheduleProviderSelectionUIRefresh() { + self.providerSelectionUIRefreshTask?.cancel() + self.providerSelectionUIRefreshTask = Task { @MainActor [weak self] in + await Task.yield() + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled, let self else { return } + self.refreshProviderSelectionRendering() + self.providerSelectionUIRefreshTask = nil + } + } + + private func refreshProviderSelectionRendering() { self.updateAnimationState() self.updateBlinkingState() let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil @@ -37,17 +59,19 @@ extension StatusItemController { let delta = direction == .next ? 1 : -1 let nextIndex = (currentIndex + delta + selections.count) % selections.count let selection = selections[nextIndex] - switch selection { - case .overview: - self.settings.mergedMenuLastSelectedWasOverview = true - self.lastMenuProvider = self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex - case let .provider(provider): - self.settings.mergedMenuLastSelectedWasOverview = false - self.selectedMenuProvider = provider - self.lastMenuProvider = provider + self.preservingMergedSwitcherContentCachesDuringInvalidation { + switch selection { + case .overview: + self.settings.mergedMenuLastSelectedWasOverview = true + self.lastMenuProvider = self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex + case let .provider(provider): + self.settings.mergedMenuLastSelectedWasOverview = false + self.selectedMenuProvider = provider + self.lastMenuProvider = provider + } + self.lastMergedSwitcherSelection = selection + self.refreshProviderSelectionDependentUI(refreshOpenMenus: true, deferRendering: true) } - self.lastMergedSwitcherSelection = selection - self.refreshProviderSelectionDependentUI(refreshOpenMenus: true) } private func navigationResolvedProvider(enabledProviders: [UsageProvider]) -> UsageProvider? { diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index fc8dd019f..034d1699e 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -1,14 +1,18 @@ import AppKit import CodexBarCore +struct PendingProviderSwitcherRebuild { + let menu: NSMenu + let provider: UsageProvider? +} + +@MainActor final class ProviderSwitcherShortcutEventMonitor { - private let events: NSEvent.EventTypeMask private let callback: @MainActor (NSEvent) -> Bool private let observer: CFRunLoopObserver private var isActive = false init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { - self.events = events self.callback = callback self.observer = CFRunLoopObserverCreateWithHandler( @@ -36,7 +40,9 @@ final class ProviderSwitcherShortcutEventMonitor { } deinit { - self.stop() + MainActor.assumeIsolated { + self.stop() + } } func start() { @@ -68,7 +74,9 @@ extension StatusItemController { } self.removeProviderSwitcherShortcutMonitor() - let monitor = ProviderSwitcherShortcutEventMonitor(events: [.keyDown]) { [weak self, weak menu] event in + let monitor = ProviderSwitcherShortcutEventMonitor( + events: [.keyDown, .leftMouseDown, .leftMouseUp]) + { [weak self, weak menu] event in guard let self, let menu, self.openMenus[ObjectIdentifier(menu)] != nil, @@ -77,7 +85,7 @@ extension StatusItemController { return false } - return self.handleProviderSwitcherShortcut(event, menu: menu) + return self.handleProviderSwitcherTrackingEvent(event, menu: menu) } monitor.start() self.providerSwitcherShortcutEventMonitor = monitor @@ -88,6 +96,7 @@ extension StatusItemController { self.providerSwitcherShortcutEventMonitor?.stop() self.providerSwitcherShortcutEventMonitor = nil self.providerSwitcherShortcutMenuID = nil + self.clearProviderSwitcherPointerInteraction() } func providerSwitcherContentStartIndex(in menu: NSMenu) -> Int { @@ -106,6 +115,77 @@ extension StatusItemController { return false } + @discardableResult + func handleProviderSwitcherTrackingEvent(_ event: NSEvent, menu: NSMenu) -> Bool { + switch event.type { + case .keyDown: + return self.handleProviderSwitcherShortcut(event, menu: menu) + case .leftMouseDown: + guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { return false } + self.beginProviderSwitcherPointerInteraction(in: menu) + let handled = switcher.handleMenuTrackingMouseDown(event) + if !handled { + self.clearProviderSwitcherPointerInteraction(in: menu) + } + return handled + case .leftMouseUp: + guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { + return false + } + guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { + self.clearProviderSwitcherPointerInteraction(in: menu) + return true + } + _ = switcher.handleMenuTrackingMouseUp(event) + self.finishProviderSwitcherPointerInteraction(in: menu) + return true + default: + return false + } + } + + func requestProviderSwitcherMenuRebuild(_ menu: NSMenu, provider: UsageProvider?) { + guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) + return + } + self.pendingProviderSwitcherPointerRebuild = PendingProviderSwitcherRebuild( + menu: menu, + provider: provider) + } + + private func beginProviderSwitcherPointerInteraction(in menu: NSMenu) { + let menuID = ObjectIdentifier(menu) + if self.providerSwitcherPointerInteractionMenuID != menuID { + self.pendingProviderSwitcherPointerRebuild = nil + } + self.providerSwitcherPointerInteractionMenuID = menuID + } + + private func finishProviderSwitcherPointerInteraction(in menu: NSMenu) { + let menuID = ObjectIdentifier(menu) + guard self.providerSwitcherPointerInteractionMenuID == menuID else { return } + self.providerSwitcherPointerInteractionMenuID = nil + guard let pending = self.pendingProviderSwitcherPointerRebuild, + pending.menu === menu + else { + self.pendingProviderSwitcherPointerRebuild = nil + return + } + self.pendingProviderSwitcherPointerRebuild = nil + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: pending.provider) + } + + private func clearProviderSwitcherPointerInteraction(in menu: NSMenu? = nil) { + if let menu, + self.providerSwitcherPointerInteractionMenuID != ObjectIdentifier(menu) + { + return + } + self.providerSwitcherPointerInteractionMenuID = nil + self.pendingProviderSwitcherPointerRebuild = nil + } + @discardableResult private func selectProviderSwitcherSegment(at index: Int, menu: NSMenu) -> Bool { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView, diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 15a9c6801..6e44db4e0 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -52,6 +52,10 @@ extension StatusItemController { } self.openMenuInvalidationRetryTask?.cancel() self.openMenuInvalidationRetryTask = nil + self.providerSelectionUIRefreshTask?.cancel() + self.providerSelectionUIRefreshTask = nil + self.providerSwitcherPointerInteractionMenuID = nil + self.pendingProviderSwitcherPointerRebuild = nil } private func clearShutdownMenuState() { @@ -67,6 +71,8 @@ extension StatusItemController { self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) self.menuCardHeightCache.removeAll(keepingCapacity: false) + self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: false) + self.mergedSwitcherContentCaches.removeAll(keepingCapacity: false) self.menuProviders.removeAll(keepingCapacity: false) self.menuVersions.removeAll(keepingCapacity: false) self.providerMenus.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index f0c006e03..a5404b54e 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -2,7 +2,7 @@ import AppKit import CodexBarCore import QuartzCore -enum ProviderSwitcherSelection: Equatable { +enum ProviderSwitcherSelection: Hashable { case overview case provider(UsageProvider) } @@ -40,6 +40,7 @@ final class ProviderSwitcherView: NSView { private var preferredWidth: CGFloat = 0 private var hoveredButtonTag: Int? private var pressedButtonTag: Int? + private var selectedSegmentIndex: Int? private let lightModeOverlayLayer = CALayer() private static let quotaIndicatorHeight: CGFloat = 3 private static let quotaIndicatorBottomInset: CGFloat = 2 @@ -190,6 +191,9 @@ final class ProviderSwitcherView: NSView { let button = makeButton(index: index, segment: segment) self.addSubview(button) } + self.selectedSegmentIndex = selected.flatMap { selected in + self.segments.firstIndex { $0.selection == selected } + } let uniformWidth: CGFloat if self.rowCount > 1 || !self.stackedIcons { @@ -294,21 +298,52 @@ final class ProviderSwitcherView: NSView { } override func mouseDown(with event: NSEvent) { - let location = self.convert(event.locationInWindow, from: nil) - self.pressedButtonTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag + _ = self.handleMenuTrackingMouseDown(event) } override func mouseUp(with event: NSEvent) { + _ = self.handleMenuTrackingMouseUp(event) + } + + @discardableResult + func handleMenuTrackingMouseDown(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseDown else { return false } + let location = self.locationInView(for: event) + guard let pressedTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag, + self.segments.indices.contains(pressedTag) + else { + return false + } + self.pressedButtonTag = pressedTag + return true + } + + @discardableResult + func handleMenuTrackingMouseUp(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseUp else { return false } defer { self.pressedButtonTag = nil } - guard let pressedTag = self.pressedButtonTag else { return } - let location = self.convert(event.locationInWindow, from: nil) + guard let pressedTag = self.pressedButtonTag else { return false } + let location = self.locationInView(for: event) guard let releasedTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag, - releasedTag == pressedTag, - self.segments.indices.contains(pressedTag) + releasedTag == pressedTag else { - return + return true } + // Commit only after the matching release. The controller schedules structural menu + // replacement after this callback returns so AppKit can finish the tracking transaction. self.applySelection(at: pressedTag) + return true + } + + private func locationInView(for event: NSEvent) -> NSPoint { + guard let eventWindow = event.window, + let viewWindow = self.window, + eventWindow !== viewWindow + else { + return self.convert(event.locationInWindow, from: nil) + } + let screenLocation = eventWindow.convertPoint(toScreen: event.locationInWindow) + return self.convert(viewWindow.convertPoint(fromScreen: screenLocation), from: nil) } func handleKeyboardSelection(at index: Int) -> Bool { @@ -319,22 +354,14 @@ final class ProviderSwitcherView: NSView { private func applySelection(at index: Int) { let selection = self.segments[index].selection + guard self.selectedSegmentIndex != index else { + self.updateSelection(selection) + return + } self.updateSelection(selection) self.onSelect(selection) } - #if DEBUG - /// Simulates the runtime click path (mouseDown → mouseUp on this view) that the menu uses - /// in production, bypassing `NSButton.performClick`. Tests use this to cover the path that - /// regressed in issue #867. - @discardableResult - func _test_simulateRuntimeClick(buttonTag: Int) -> Bool { - guard self.segments.indices.contains(buttonTag) else { return false } - self.applySelection(at: buttonTag) - return true - } - #endif - private func applyLayout( outerPadding: CGFloat, minimumGap: CGFloat, @@ -588,10 +615,15 @@ final class ProviderSwitcherView: NSView { } func updateSelection(_ selection: ProviderSwitcherSelection) { + var selectedIndex: Int? for (index, button) in self.buttons.enumerated() { let isSelected = self.segments.indices.contains(index) && self.segments[index].selection == selection + if isSelected { + selectedIndex = index + } button.state = isSelected ? .on : .off } + self.selectedSegmentIndex = selectedIndex self.updateButtonStyles() } @@ -661,47 +693,6 @@ final class ProviderSwitcherView: NSView { } } - #if DEBUG - func _test_buttonFrames() -> [NSRect] { - self.buttons.map(\.frame) - } - - func _test_buttonFittingSizes() -> [NSSize] { - self.buttons.map(\.fittingSize) - } - - func _test_rowCount() -> Int { - self.rowCount - } - - func _test_rowHeight() -> CGFloat { - self.rowHeight - } - - func _test_setHoveredButtonTag(_ tag: Int?) { - self.hoveredButtonTag = tag - self.updateButtonStyles() - } - - func _test_quotaIndicatorFillRatios() -> [CGFloat] { - self.buttons.compactMap { button in - self.quotaIndicators[ObjectIdentifier(button)]?.fillRatio - } - } - - func _test_quotaIndicatorFillFrames() -> [NSRect] { - self.buttons.compactMap { button in - self.quotaIndicators[ObjectIdentifier(button)]?.fill.frame - } - } - - func _test_quotaIndicatorConstraintIdentifiers() -> [ObjectIdentifier] { - self.buttons.compactMap { button in - self.quotaIndicators[ObjectIdentifier(button)].map { ObjectIdentifier($0.fillWidthConstraint) } - } - } - #endif - private func isLightMode() -> Bool { self.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua } @@ -905,6 +896,97 @@ final class ProviderSwitcherView: NSView { } } +#if DEBUG +extension ProviderSwitcherView { + func _test_mouseDownEvent(buttonTag: Int) -> NSEvent? { + self._test_mouseEvent(buttonTag: buttonTag, type: .leftMouseDown) + } + + func _test_mouseUpEvent(buttonTag: Int) -> NSEvent? { + self._test_mouseEvent(buttonTag: buttonTag, type: .leftMouseUp) + } + + private func _test_mouseEvent(buttonTag: Int, type: NSEvent.EventType) -> NSEvent? { + guard let button = self.buttons.first(where: { $0.tag == buttonTag }) else { return nil } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + return NSEvent.mouseEvent( + with: type, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: type == .leftMouseDown ? 1 : 2, + clickCount: 1, + pressure: type == .leftMouseDown ? 1 : 0) + } + + @discardableResult + func _test_simulateMouseDown(buttonTag: Int) -> Bool { + guard let event = self._test_mouseDownEvent(buttonTag: buttonTag) else { return false } + return self.handleMenuTrackingMouseDown(event) + } + + /// Simulates the parent-view event path used while NSMenu owns mouse tracking. + @discardableResult + func _test_simulateRuntimeClick(buttonTag: Int) -> Bool { + guard self._test_simulateMouseDown(buttonTag: buttonTag) else { return false } + guard let event = self._test_mouseUpEvent(buttonTag: buttonTag) else { return false } + guard self.handleMenuTrackingMouseUp(event) else { return false } + return self.selectedSegmentIndex == buttonTag + } + + @discardableResult + func _test_simulateNativeAction(buttonTag: Int, state: NSControl.StateValue) -> Bool { + guard let button = self.buttons.first(where: { $0.tag == buttonTag }) else { return false } + button.state = state + self.handleSelection(button) + return true + } + + func _test_buttonFrames() -> [NSRect] { + self.buttons.map(\.frame) + } + + func _test_buttonFittingSizes() -> [NSSize] { + self.buttons.map(\.fittingSize) + } + + func _test_rowCount() -> Int { + self.rowCount + } + + func _test_rowHeight() -> CGFloat { + self.rowHeight + } + + func _test_setHoveredButtonTag(_ tag: Int?) { + self.hoveredButtonTag = tag + self.updateButtonStyles() + } + + func _test_quotaIndicatorFillRatios() -> [CGFloat] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)]?.fillRatio + } + } + + func _test_quotaIndicatorFillFrames() -> [NSRect] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)]?.fill.frame + } + } + + func _test_quotaIndicatorConstraintIdentifiers() -> [ObjectIdentifier] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)].map { ObjectIdentifier($0.fillWidthConstraint) } + } + } +} +#endif + extension ProviderSwitcherView { private func addQuotaIndicator(to view: NSView, selection: ProviderSwitcherSelection, remainingPercent: Double?) { guard let remainingPercent else { return } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index c29728e4d..dd246f1a5 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -119,6 +119,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuVersions: [ObjectIdentifier: Int] = [:] var menuReadinessSignatures: [ObjectIdentifier: String] = [:] var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] + var measuredStandardMenuWidthCache: [String: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" var lastMenuAdjunctReadinessBaselineVersion = 0 var rootOpenHandledMenuObservationSignature: String? @@ -142,6 +143,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? + var providerSwitcherPointerInteractionMenuID: ObjectIdentifier? + var pendingProviderSwitcherPointerRebuild: PendingProviderSwitcherRebuild? var hasPreparedForAppShutdown = false var scheduleQuitTermination: (@escaping @MainActor () -> Void) -> Void = { operation in DispatchQueue.main.async { @@ -221,12 +224,19 @@ 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 which provider/overview content is currently attached below the merged-menu switcher. + var lastMergedMenuContentSelection: ProviderSwitcherSelection? /// Tracks the visible Codex account switcher contents for merged-menu smart updates. var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? /// Tracks the visible token account switcher contents for merged-menu smart updates. var lastTokenAccountMenuDisplay: TokenAccountMenuDisplay? + /// Keeps detached merged-menu tab content reusable while the same menu remains open. + var mergedSwitcherContentCaches: [ObjectIdentifier: [ProviderSwitcherSelection: CachedMergedSwitcherMenuContent]] + = [:] + var preservesMergedSwitcherContentCachesDuringInvalidation = false /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 + var providerSelectionUIRefreshTask: Task? var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] var lastObservedStoreIconWorkSignature: String? diff --git a/Sources/CodexBar/UsageStore+ProviderStorage.swift b/Sources/CodexBar/UsageStore+ProviderStorage.swift index 3e5197eb5..a31f01740 100644 --- a/Sources/CodexBar/UsageStore+ProviderStorage.swift +++ b/Sources/CodexBar/UsageStore+ProviderStorage.swift @@ -45,7 +45,16 @@ extension UsageStore { self.clearStorageFootprints() return } - guard let request = self.makeStorageRefreshRequest(for: providers) else { + let environment = self.environmentBase + let managedAccountsOverride = self.managedCodexAccountsForStorageOverride + let request = await Task.detached(priority: .utility) { + let managedAccounts = Self.loadManagedCodexAccountsForStorage(override: managedAccountsOverride) + return Self.makeStorageRefreshRequest( + for: providers, + environment: environment, + managedAccounts: managedAccounts) + }.value + guard let request else { self.clearStorageFootprints() return } @@ -67,6 +76,7 @@ extension UsageStore { updatedAt: Date()) self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil } func scheduleStorageFootprintRefresh(for providers: [UsageProvider], force: Bool = false) { @@ -74,19 +84,23 @@ extension UsageStore { self.clearStorageFootprints() return } - guard let request = self.makeStorageRefreshRequest(for: providers) else { + let managedAccountsOverride = self.managedCodexAccountsForStorageOverride + let requestKey = Self.storageRefreshRequestKey( + for: providers, + managedAccountsOverride: managedAccountsOverride) + guard !requestKey.isEmpty else { self.clearStorageFootprints() return } let now = Date() if self.storageRefreshTask != nil, - self.storageRefreshInFlightSignature == request.signature + self.storageRefreshInFlightRequestKey == nil || self.storageRefreshInFlightRequestKey == requestKey { return } if !force { - if self.lastStorageRefreshSignature == request.signature, + if self.lastStorageRefreshRequestKey == requestKey, let lastStorageRefreshAt, now.timeIntervalSince(lastStorageRefreshAt) < Self.automaticStorageRefreshInterval { @@ -97,9 +111,32 @@ extension UsageStore { self.storageRefreshTask?.cancel() self.storageRefreshGeneration &+= 1 let generation = self.storageRefreshGeneration - self.storageRefreshInFlightSignature = request.signature + self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = requestKey + let environment = self.environmentBase self.storageRefreshTask = Task.detached(priority: .utility) { [weak self] in + let managedAccounts = Self.loadManagedCodexAccountsForStorage(override: managedAccountsOverride) + guard let request = Self.makeStorageRefreshRequest( + for: providers, + environment: environment, + managedAccounts: managedAccounts) + else { + await MainActor.run { [weak self] in + guard let self, + !Task.isCancelled, + generation == self.storageRefreshGeneration + else { return } + self.providerStorageFootprints.removeAll() + self.storageRefreshTask = nil + self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil + self.lastStorageRefreshSignature = nil + self.lastStorageRefreshRequestKey = requestKey + self.lastStorageRefreshAt = Date() + } + return + } let footprints = Self.scanStorageFootprints(candidatePathsByProvider: request.candidatePathsByProvider) await MainActor.run { [weak self] in @@ -112,9 +149,11 @@ extension UsageStore { footprints, providers: request.providers, signature: request.signature, + requestKey: requestKey, updatedAt: Date()) self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil } } } @@ -123,7 +162,9 @@ extension UsageStore { self.storageRefreshTask?.cancel() self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil self.lastStorageRefreshSignature = nil + self.lastStorageRefreshRequestKey = nil self.lastStorageRefreshAt = nil self.providerStorageFootprints.removeAll() } @@ -132,6 +173,7 @@ extension UsageStore { _ footprints: [UsageProvider: ProviderStorageFootprint], providers: [UsageProvider], signature: String, + requestKey: String? = nil, updatedAt: Date) { let providerSet = Set(providers) @@ -140,15 +182,19 @@ extension UsageStore { self.providerStorageFootprints[provider] = footprints[provider] } self.lastStorageRefreshSignature = signature + self.lastStorageRefreshRequestKey = requestKey ?? signature self.lastStorageRefreshAt = updatedAt } - private func makeStorageRefreshRequest(for providers: [UsageProvider]) -> StorageRefreshRequest? { + private nonisolated static func makeStorageRefreshRequest( + for providers: [UsageProvider], + environment: [String: String], + managedAccounts: [ManagedCodexAccount]) + -> StorageRefreshRequest? + { let uniqueProviders = Array(Set(providers)).sorted { $0.rawValue < $1.rawValue } guard !uniqueProviders.isEmpty else { return nil } - let environment = self.environmentBase - let managedAccounts = self.loadManagedCodexAccountsForStorage() var candidatePathsByProvider: [UsageProvider: [String]] = [:] for provider in uniqueProviders { @@ -175,9 +221,39 @@ extension UsageStore { signature: signature) } - private func loadManagedCodexAccountsForStorage() -> [ManagedCodexAccount] { - if let managedCodexAccountsForStorageOverride { - return managedCodexAccountsForStorageOverride + private nonisolated static func storageRefreshRequestKey( + for providers: [UsageProvider], + managedAccountsOverride: [ManagedCodexAccount]?) + -> String + { + let uniqueProviders = Array(Set(providers)) + .sorted { $0.rawValue < $1.rawValue } + let providerKey = uniqueProviders.map(\.rawValue).joined(separator: ",") + guard uniqueProviders.contains(.codex) else { return providerKey } + + let managedAccountsRevision: String + if let managedAccountsOverride { + managedAccountsRevision = Array(Set(managedAccountsOverride.map(\.managedHomePath))) + .sorted() + .joined(separator: "\u{1f}") + } else { + let fileURL = FileManagedCodexAccountStore.defaultURL() + let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) + let modificationDate = (attributes?[.modificationDate] as? Date)? + .timeIntervalSinceReferenceDate.bitPattern ?? 0 + let fileNumber = (attributes?[.systemFileNumber] as? NSNumber)?.uint64Value ?? 0 + let fileSize = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 + managedAccountsRevision = "\(fileNumber):\(modificationDate):\(fileSize)" + } + return "\(providerKey)\u{1e}\(managedAccountsRevision)" + } + + private nonisolated static func loadManagedCodexAccountsForStorage( + override: [ManagedCodexAccount]?) + -> [ManagedCodexAccount] + { + if let override { + return override } return (try? FileManagedCodexAccountStore().loadAccounts().accounts) ?? [] } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 23746a1be..bae9cef7e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -239,7 +239,9 @@ final class UsageStore { @ObservationIgnored var storageRefreshTask: Task? @ObservationIgnored var storageRefreshGeneration: UInt64 = 0 @ObservationIgnored var storageRefreshInFlightSignature: String? + @ObservationIgnored var storageRefreshInFlightRequestKey: String? @ObservationIgnored var lastStorageRefreshSignature: String? + @ObservationIgnored var lastStorageRefreshRequestKey: String? @ObservationIgnored var lastStorageRefreshAt: Date? @ObservationIgnored var managedCodexAccountsForStorageOverride: [ManagedCodexAccount]? @ObservationIgnored private var pathDebugRefreshTask: Task? diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift index 0a903e95e..8879df6d7 100644 --- a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -401,6 +401,69 @@ struct ProviderStorageFootprintTests { #expect(store.storageRefreshGeneration == 41) } + @Test + @MainActor + func `scheduled storage refresh notices managed Codex home changes`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let ambientHome = home.appendingPathComponent("ambient", isDirectory: true) + let firstManagedHome = home.appendingPathComponent("managed-a", isDirectory: true) + let secondManagedHome = home.appendingPathComponent("managed-b", isDirectory: true) + try FileManager.default.createDirectory(at: ambientHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: firstManagedHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondManagedHome, withIntermediateDirectories: true) + try Data(repeating: 1, count: 16).write(to: firstManagedHome.appendingPathComponent("session.jsonl")) + try Data(repeating: 2, count: 32).write(to: secondManagedHome.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-managed-refresh-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": ambientHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [ + Self.managedCodexAccount(homePath: firstManagedHome.path), + ] + + store.scheduleStorageFootprintRefresh(for: [.codex]) + for _ in 0..<100 where store.isStorageRefreshInFlight { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(store.storageFootprint(for: .codex)?.totalBytes == 16) + + store.managedCodexAccountsForStorageOverride = [ + Self.managedCodexAccount(homePath: secondManagedHome.path), + ] + store.scheduleStorageFootprintRefresh(for: [.codex]) + for _ in 0..<100 where store.isStorageRefreshInFlight { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + } + + private static func managedCodexAccount(homePath: String) -> ManagedCodexAccount { + ManagedCodexAccount( + id: UUID(), + email: "storage@example.com", + managedHomePath: homePath, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: nil) + } + private static func makeTemporaryDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("ProviderStorageFootprintTests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift index f7e4874a6..18f27e501 100644 --- a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -1,9 +1,35 @@ import CodexBarCore import Foundation +import SwiftUI import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `menu card sizing uses displayed hosting view`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let counter = MenuCardRepresentableCounter() + let item = controller.makeMenuCardItem( + CountingMenuCardRepresentable(counter: counter), + id: "countingCard-\(UUID().uuidString)", + width: 320, + heightCacheScope: "counting", + heightCacheFingerprint: "counting-\(UUID().uuidString)") + let view = try #require(item.view) + + view.layoutSubtreeIfNeeded() + + #expect(counter.makeViewCount == 1) + } + @Test func `menu card height cache is reused for stable card content`() { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled @@ -45,6 +71,24 @@ extension StatusMenuTests { #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) } + @Test + func `standard menu width cache is reused for stable action rows`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstCache = controller.measuredStandardMenuWidthCache + + #expect(!firstCache.isEmpty) + #expect(firstCache.keys.allSatisfy { + $0.contains("font=\(StatusItemController.menuCardHeightTextScaleToken())") + }) + + controller.populateMenu(menu, provider: .codex) + #expect(controller.measuredStandardMenuWidthCache == firstCache) + } + @Test func `fingerprinted menu card height cache survives content version invalidation`() { let controller = self.makeHeightCacheController() @@ -237,3 +281,22 @@ extension StatusMenuTests { statusBar: self.makeStatusBarForTesting()) } } + +@MainActor +private final class MenuCardRepresentableCounter { + var makeViewCount = 0 +} + +private struct CountingMenuCardRepresentable: NSViewRepresentable { + let counter: MenuCardRepresentableCounter + + func makeNSView(context: Context) -> NSTextField { + self.counter.makeViewCount += 1 + return NSTextField(labelWithString: "Counted") + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + _ = nsView + _ = context + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index e1a3f52a5..1da133c67 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -130,7 +130,69 @@ struct StatusMenuSwitcherClickTests { } @Test - func `merged switcher runtime click refreshes icon without waiting for menu rebuild`() throws { + func `merged switcher commits selection on matching mouse up`() throws { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateMouseDown(buttonTag: 0)) + #expect(selections.isEmpty) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) + #expect(switcher.handleMenuTrackingMouseUp(mouseUp)) + #expect(selections == [.overview]) + } + + @Test + func `menu tracking routes switcher pointer sequence before AppKit menu dispatch`() throws { + var selected: ProviderSwitcherSelection? + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selected = $0 }) + let menu = StatusItemMenu() + let item = NSMenuItem() + item.view = switcher + item.isEnabled = false + menu.addItem(item) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let fetcher = UsageFetcher() + let controller = StatusItemController( + store: UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings), + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 0)) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(selected == nil) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) + #expect(selected == .overview) + } + + @Test + func `merged switcher runtime click defers icon rendering until after event handling`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false @@ -175,6 +237,8 @@ struct StatusMenuSwitcherClickTests { #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) #expect(settings.selectedMenuProvider == .claude) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + await controller.providerSelectionUIRefreshTask?.value #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) } @@ -236,7 +300,7 @@ struct StatusMenuSwitcherClickTests { } @Test - func `merged switcher runtime click updates loading animation state`() throws { + func `merged switcher runtime click updates loading animation state after event handling`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false @@ -287,12 +351,14 @@ struct StatusMenuSwitcherClickTests { let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) #expect(settings.selectedMenuProvider == .claude) + await controller.providerSelectionUIRefreshTask?.value #expect(controller.needsMenuBarIconAnimation() == true) #expect(controller.animationDriver != nil) #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) #expect(switcher._test_simulateRuntimeClick(buttonTag: 1)) #expect(settings.selectedMenuProvider == .codex) + await controller.providerSelectionUIRefreshTask?.value #expect(controller.needsMenuBarIconAnimation() == false) #expect(controller.animationDriver == nil) #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index bd2da02ec..a49fc7c1c 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -6,6 +6,41 @@ import Testing @MainActor @Suite(.serialized) struct StatusMenuSwitcherRefreshTests { + @Test + func `native switcher action preserves off tab switches after button state toggles`() { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateNativeAction(buttonTag: 1, state: .on)) + #expect(selections == [.provider(.claude)]) + } + + @Test + func `native switcher action restores active tab after native toggle`() { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateNativeAction(buttonTag: 0, state: .off)) + #expect(selections.isEmpty) + #expect(Self.switcherButtons(in: switcher).first { $0.tag == 0 }?.state == .on) + } + @Test func `merged provider switch rebuilds stale width switcher rows`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled @@ -73,6 +108,176 @@ struct StatusMenuSwitcherRefreshTests { #expect(Self.switcherButtons(in: menu).first { $0.tag == nextProviderButton.tag }?.state == .on) } + @Test + func `selected provider tab click does not rebuild open menu`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(switcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + try? await Task.sleep(for: .milliseconds(40)) + + #expect(rebuildCount == 0) + #expect(Self.switcherButtons(in: menu).first { $0.tag == selectedButton.tag }?.state == .on) + } + + @Test + func `merged provider switch restores cached tab content`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) + #expect(menu.items.indices.contains(contentStartIndex)) + let originalContentID = ObjectIdentifier(menu.items[contentStartIndex]) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + let alternateContentID = ObjectIdentifier(menu.items[contentStartIndex]) + #expect(alternateContentID != originalContentID) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) + + let restoredSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(restoredSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(3, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == alternateContentID) + + controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) + } + + @Test + func `provider switch does not cache stale rows after required invalidation`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) + let originalContent = try #require( + menu.items.indices.contains(contentStartIndex) ? menu.items[contentStartIndex] : nil) + let originalContentID = ObjectIdentifier(originalContent) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.invalidateMenus() + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) != originalContentID) + } + @Test func `tab switch does not replace quota indicator constraints`() { let switcher = ProviderSwitcherView( @@ -138,9 +343,35 @@ struct StatusMenuSwitcherRefreshTests { } } + private static func disableOverview(_ settings: SettingsStore) { + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + } + + private static func waitForRebuildCount( + _ expectedCount: Int, + rebuildCount: () -> Int) async + { + for _ in 0..<100 where rebuildCount() < expectedCount { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + } + private static func switcherButtons(in menu: NSMenu) -> [NSButton] { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } - return switcherView.subviews + return self.switcherButtons(in: switcherView) + } + + private static func switcherButtons(in switcherView: ProviderSwitcherView) -> [NSButton] { + switcherView.subviews .compactMap { $0 as? NSButton } .sorted { $0.tag < $1.tag } } diff --git a/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift new file mode 100644 index 000000000..780c73698 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift @@ -0,0 +1,186 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherTrackingTests { + @Test + func `pointer switch defers structural menu rebuild until mouse up`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 2)) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 2)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(settings.selectedMenuProvider == .codex) + #expect(controller.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu)) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 0) + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) + #expect(settings.selectedMenuProvider == .claude) + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(rebuildCount == 1) + #expect(controller.providerSwitcherPointerInteractionMenuID == nil) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + } + + @Test + func `pointer switch cancels when mouse up leaves pressed segment`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 2)) + let mouseUpElsewhere = try #require(switcher._test_mouseUpEvent(buttonTag: 1)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUpElsewhere, menu: menu)) + #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 0) + #expect(controller.providerSwitcherPointerInteractionMenuID == nil) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + } + + @Test + func `unrelated mouse up remains available to normal menu items`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let fetcher = UsageFetcher() + let controller = StatusItemController( + store: UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings), + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let item = NSMenuItem() + item.view = switcher + menu.addItem(item) + let unrelatedMouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 1)) + + #expect(!controller.handleProviderSwitcherTrackingEvent(unrelatedMouseUp, menu: menu)) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherTrackingTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} From d7b58a050cb18bfc3eac41400d238b84839461e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Jun 2026 01:20:28 +0100 Subject: [PATCH 93/93] docs: finalize 0.32.5 changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6a5ea8d..f119203d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.32.5 — Unreleased +## 0.32.5 — 2026-06-09 ### Added - Localization: add French as a selectable app language (#1241). Thanks @Yuxin-Qiao! @@ -9,6 +9,7 @@ - Localization: add Vietnamese as a selectable app language (#1247). Thanks @Yuxin-Qiao! ### Fixed +- Menu bar: keep provider switching inside AppKit's menu-tracking transaction and defer structural dropdown rebuilds until mouse-up completes, preventing intermittent hangs when moving between providers and Overview. - Localization: cache resolved localized bundles so repeated menu/status text lookups no longer hit disk on the main thread (#1355, fixes #1347). Thanks @Yuxin-Qiao! - Menu bar: size hosted chart submenus directly instead of spinning up throwaway SwiftUI hosting controllers during menu layout (#1352). Thanks @Yuxin-Qiao! - Menu bar: avoid recomputing expensive readiness signatures on closed-menu store ticks while preserving root-open refresh correctness for deferred observations (#1351). Thanks @Yuxin-Qiao!