From 2b807974d3d53c7b1b92a7c01f224c8932fb7deb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 07:46:18 -0700 Subject: [PATCH 1/4] fix: preserve Claude usage across OAuth rotation --- CHANGELOG.md | 1 + .../Claude/ClaudeUIErrorMapper.swift | 25 ++++ .../Resources/ar.lproj/Localizable.strings | 2 + .../Resources/ca.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fa.lproj/Localizable.strings | 2 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/gl.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/pt-BR.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/th.lproj/Localizable.strings | 2 + .../Resources/tr.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + Sources/CodexBar/UsageStore+Accessors.swift | 4 + .../UsageStore+ClaudeHistoryFallback.swift | 140 ++++++++++++++++++ .../UsageStore+PlanUtilizationLoading.swift | 1 + Sources/CodexBar/UsageStore+Refresh.swift | 59 +++----- Sources/CodexBar/UsageStore.swift | 3 + .../ClaudeOAuthCredentialModels.swift | 4 + .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 29 +++- .../Claude/ClaudeProviderDescriptor.swift | 27 +++- .../Providers/Claude/ClaudeUsageFetcher.swift | 2 + .../ClaudeCLIUsageSpawnThrottleTests.swift | 51 ++++++- .../ClaudeOAuthRotationErrorTests.swift | 53 +++++++ .../CodexBarTests/ClaudeResilienceTests.swift | 105 +++++++++++++ docs/claude.md | 6 + 38 files changed, 505 insertions(+), 51 deletions(-) create mode 100644 Sources/CodexBar/Providers/Claude/ClaudeUIErrorMapper.swift create mode 100644 Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift create mode 100644 Tests/CodexBarTests/ClaudeOAuthRotationErrorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6feb46598a..f139798316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Widgets: opt-in display of Claude model-scoped weekly quotas (for example Fable) projected from the shared usage snapshot, off by default (#2645). Thanks @alfredjbclaw! ### Fixed +- Claude: let Auto reuse a working CLI fallback when OAuth Keychain access is revoked by Claude Code token rotation, distinguish revoked access from missing credentials, and keep last-known quota history visible with its capture age when every live source fails (#2516). Thanks @axisrow and everyone who supplied forensics! - Menu: apply the cost summary display style to every provider's menu card, so Submenu only hides inline cost rows for z.ai and other providers (#2976). Thanks @ar0nbg! - Cost history: align x-axis date labels with their bars in status-menu charts (#2974). Thanks @Yuxin-Qiao! - Codex: preserve completed empty local history as known-zero usage and spend without fabricating zeroes for incomplete scans (#2932). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/Providers/Claude/ClaudeUIErrorMapper.swift b/Sources/CodexBar/Providers/Claude/ClaudeUIErrorMapper.swift new file mode 100644 index 0000000000..acd29f2a09 --- /dev/null +++ b/Sources/CodexBar/Providers/Claude/ClaudeUIErrorMapper.swift @@ -0,0 +1,25 @@ +import CodexBarCore +import Foundation + +@MainActor +enum ClaudeUIErrorMapper { + static func userFacingMessage( + _ raw: String?, + staleSnapshotUpdatedAt: Date?, + localize: (String) -> String = L) -> String? + { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let message = if trimmed == ClaudeOAuthCredentialsError.keychainAccessRevoked.localizedDescription { + localize("claude_oauth_keychain_access_revoked") + } else { + trimmed + } + guard let staleSnapshotUpdatedAt else { return message } + return message + " " + String( + format: localize("claude_showing_last_known_usage"), + staleSnapshotUpdatedAt.relativeDescription()) + } +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index a38e5442d4..565f105595 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1445,3 +1445,5 @@ "5-hour usage" = "الاستخدام خلال 5 ساعات"; "7-day usage" = "الاستخدام خلال 7 أيام"; "Total usage" = "إجمالي الاستخدام"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c18e4d4f0f..d3e180294a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1444,3 +1444,5 @@ "5-hour usage" = "Ús de 5 hores"; "7-day usage" = "Ús de 7 dies"; "Total usage" = "Ús total"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 92eddb4d34..a9f5bd7290 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1442,3 +1442,5 @@ "5-hour usage" = "5-Stunden-Nutzung"; "7-day usage" = "7-Tage-Nutzung"; "Total usage" = "Gesamtnutzung"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index a97e5d5e4e..975bcb435e 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -3,6 +3,8 @@ "ollama_safari_cookie_access_hint" = "Safari cookies need Full Disk Access for CodexBar (System Settings > Privacy & Security)."; "ollama_browser_cookie_decryption_denied" = "%@ cookie decryption was declined in Keychain. Open the provider card and click Refresh (⌘R) to request Keychain access again."; "ollama_browser_cookie_decryption_disabled" = "%@ cookie decryption is disabled in CodexBar; enable Keychain access and refresh."; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; " providers" = " providers"; "(System)" = "(System)"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 3b1c063979..10d1c43882 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1440,3 +1440,5 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 días"; "Total usage" = "Uso total"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 1c6578045b..6fc6f387fc 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1445,3 +1445,5 @@ "5-hour usage" = "مصرف ۵ ساعته"; "7-day usage" = "مصرف ۷ روزه"; "Total usage" = "مصرف کل"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index ae957e15cf..1fc858edb9 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1441,3 +1441,5 @@ "5-hour usage" = "Utilisation sur 5 heures"; "7-day usage" = "Utilisation sur 7 jours"; "Total usage" = "Utilisation totale"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index b27ddfb80e..5d153d3449 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1441,3 +1441,5 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 días"; "Total usage" = "Uso total"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 2e32de49db..976983b7fd 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1445,3 +1445,5 @@ "5-hour usage" = "Penggunaan 5 jam"; "7-day usage" = "Penggunaan 7 hari"; "Total usage" = "Total penggunaan"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index a67b2dc385..8b73f220a7 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1445,3 +1445,5 @@ "5-hour usage" = "Utilizzo di 5 ore"; "7-day usage" = "Utilizzo di 7 giorni"; "Total usage" = "Utilizzo totale"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 6e60cae34f..a9df994c29 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1442,3 +1442,5 @@ "5-hour usage" = "5時間の使用量"; "7-day usage" = "7日間の使用量"; "Total usage" = "合計使用量"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index c0e92e7821..4425995d2f 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1409,3 +1409,5 @@ "5-hour usage" = "5시간 사용량"; "7-day usage" = "7일 사용량"; "Total usage" = "총 사용량"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 436e5c0462..89c8e99bc0 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1441,3 +1441,5 @@ "5-hour usage" = "5-uursgebruik"; "7-day usage" = "7-dagengebruik"; "Total usage" = "Totaal gebruik"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 01cc915700..4cabb7e22b 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1445,3 +1445,5 @@ "5-hour usage" = "Użycie 5-godzinne"; "7-day usage" = "Użycie 7-dniowe"; "Total usage" = "Łączne użycie"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 4161486429..53134f2f70 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1442,3 +1442,5 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 dias"; "Total usage" = "Uso total"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 9249e1a5cb..f57e2c39e9 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1443,3 +1443,5 @@ "5-hour usage" = "Использование за 5 часов"; "7-day usage" = "Использование за 7 дней"; "Total usage" = "Общее использование"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index dbef042d77..52bb32994c 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1440,3 +1440,5 @@ "5-hour usage" = "5-timmarsanvändning"; "7-day usage" = "7-dagarsanvändning"; "Total usage" = "Total användning"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 1ca4d6ae82..981648fca6 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1445,3 +1445,5 @@ "5-hour usage" = "การใช้งาน 5 ชั่วโมง"; "7-day usage" = "การใช้งาน 7 วัน"; "Total usage" = "การใช้งานทั้งหมด"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 9e616a05b2..1786b9b124 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1443,3 +1443,5 @@ "5-hour usage" = "5 saatlik kullanım"; "7-day usage" = "7 günlük kullanım"; "Total usage" = "Toplam kullanım"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 3641ff6609..8227262440 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1441,3 +1441,5 @@ "5-hour usage" = "Використання за 5 годин"; "7-day usage" = "Використання за 7 днів"; "Total usage" = "Загальне використання"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index b8802511b3..b122ffe6fa 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1442,3 +1442,5 @@ "5-hour usage" = "Mức sử dụng 5 giờ"; "7-day usage" = "Mức sử dụng 7 ngày"; "Total usage" = "Tổng mức sử dụng"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 2eb49a4556..98817a277f 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1420,3 +1420,5 @@ "5-hour usage" = "5 小时用量"; "7-day usage" = "7 天用量"; "Total usage" = "总用量"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index bb3f53b411..dd69b710e8 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1472,3 +1472,5 @@ "5-hour usage" = "5 小時用量"; "7-day usage" = "7 天用量"; "Total usage" = "總用量"; +"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; +"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 1af86a2b3f..3ccfc20b84 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -153,6 +153,10 @@ extension UsageStore { switch provider { case .codex: return CodexUIErrorMapper.userFacingMessage(raw) + case .claude: + return ClaudeUIErrorMapper.userFacingMessage( + raw, + staleSnapshotUpdatedAt: self.snapshots[provider.instanceID]?.updatedAt) case .ollama: return OllamaUIErrorMapper.userFacingMessage(raw) default: diff --git a/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift b/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift new file mode 100644 index 0000000000..854dec2d4c --- /dev/null +++ b/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift @@ -0,0 +1,140 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + nonisolated static func isClaudeConsumerAutoPipeline( + provider: UsageProvider, + context: ProviderFetchContext, + hasAdminAPIKey: Bool, + hasTokenAccount: Bool) -> Bool + { + provider == .claude && context.sourceMode == .auto && !hasAdminAPIKey && !hasTokenAccount + } + + func clearClaudeHistoryFallbackEligibility(provider: UsageProvider) { + guard provider == .claude else { return } + self.claudeHistoryFallbackEligible = false + } + + func recordProviderFetchSuccessErrorState(provider: UsageProvider) { + self.errors[provider.instanceID] = nil + self.clearClaudeHistoryFallbackEligibility(provider: provider) + } + + @discardableResult + func prepareClaudeHistoryFallback( + provider: UsageProvider, + usesConsumerAutoPipeline: Bool, + accountStateWasStable: Bool) -> Bool + { + guard provider == .claude else { return false } + let eligible = usesConsumerAutoPipeline && accountStateWasStable + self.claudeHistoryFallbackEligible = eligible + return eligible && self.restoreClaudeHistorySnapshotIfNeeded() + } + + nonisolated static func shouldPreservePriorSnapshot(after error: Error, hadPriorData: Bool) -> Bool { + guard hadPriorData else { return false } + if error is CancellationError { + return true + } + if self.isPreservableNetworkTransportError(error) { + return true + } + + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || + message.contains("timeout") || + message.contains("cancelled") || + message.contains("network connection was lost") || + message.contains("not connected to the internet") + } + + nonisolated static func lastAvailableFailedFetchKind( + from attempts: [ProviderFetchAttempt]) -> ProviderFetchKind? + { + attempts.last { $0.wasAvailable && $0.errorDescription != nil }?.kind + } + + nonisolated static func isClaudeCLIRateLimitFailure(_ error: Error) -> Bool { + ClaudeUsageFetcher.isCLIRateLimitError(error) + } + + nonisolated static func isClaudeCLIUsageParseFailure(_ error: Error) -> Bool { + if case let ClaudeStatusProbeError.parseFailed(message) = error { + return !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message) + } + if case let ClaudeUsageError.parseFailed(message) = error { + return !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message) + } + return false + } + + /// Rebuilds only Claude's quota bars from persisted captures. Identity and source authority intentionally stay + /// unset: history proves the percentages and capture time, but it must never impersonate a live account fetch. + @discardableResult + func restoreClaudeHistorySnapshotIfNeeded() -> Bool { + guard self.claudeHistoryFallbackEligible, + self.planUtilizationHistoryLoaded, + self.settings.claudeUsageDataSource == .auto, + self.settings.effectiveSelectedTokenAccount(for: .claude) == nil, + self.snapshots[.claude] == nil + else { + return false + } + + let histories = self.planUtilizationHistorySelection(for: .claude).histories + let session = Self.latestClaudeHistoryEntry( + named: .session, + preferredWindowMinutes: Self.sessionWindowMinutes, + histories: histories) + let weekly = Self.latestClaudeHistoryEntry( + named: .weekly, + preferredWindowMinutes: Self.weeklyWindowMinutes, + histories: histories) + let opus = Self.latestClaudeHistoryEntry( + named: .opus, + preferredWindowMinutes: Self.weeklyWindowMinutes, + histories: histories) + let captures = [session?.entry.capturedAt, weekly?.entry.capturedAt, opus?.entry.capturedAt] + .compactMap(\.self) + guard let capturedAt = captures.max() else { return false } + + let snapshot = UsageSnapshot( + primary: session.map(Self.rateWindow(from:)), + secondary: weekly.map(Self.rateWindow(from:)), + tertiary: opus.map(Self.rateWindow(from:)), + updatedAt: capturedAt, + dataConfidence: .percentOnly) + self.snapshots[.claude] = snapshot + self.lastKnownResetSnapshots[.claude] = snapshot + return true + } + + private nonisolated static func latestClaudeHistoryEntry( + named name: PlanUtilizationSeriesName, + preferredWindowMinutes: Int, + histories: [PlanUtilizationSeriesHistory]) + -> (windowMinutes: Int, entry: PlanUtilizationHistoryEntry)? + { + let named = histories.filter { $0.name == name && !$0.entries.isEmpty } + let candidates = named.filter { $0.windowMinutes == preferredWindowMinutes }.isEmpty + ? named + : named.filter { $0.windowMinutes == preferredWindowMinutes } + return candidates.compactMap { history in + history.entries.last.map { (history.windowMinutes, $0) } + }.max { lhs, rhs in + lhs.1.capturedAt < rhs.1.capturedAt + } + } + + private nonisolated static func rateWindow( + from value: (windowMinutes: Int, entry: PlanUtilizationHistoryEntry)) -> RateWindow + { + RateWindow( + usedPercent: value.entry.usedPercent, + windowMinutes: value.windowMinutes, + resetsAt: value.entry.resetsAt, + resetDescription: nil) + } +} diff --git a/Sources/CodexBar/UsageStore+PlanUtilizationLoading.swift b/Sources/CodexBar/UsageStore+PlanUtilizationLoading.swift index 6337bdd4af..064f39dad0 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilizationLoading.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilizationLoading.swift @@ -32,6 +32,7 @@ extension UsageStore { self.planUtilizationHistory = loaded self.planUtilizationHistoryLoaded = true self.planUtilizationHistoryRevision &+= 1 + _ = self.restoreClaudeHistorySnapshotIfNeeded() } } } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index b5ce2a7c8b..53f0dc04c6 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -23,6 +23,7 @@ extension UsageStore { private struct ProviderRefreshOutcomeContext { let generation: UInt64 + let claudeUsesConsumerAutoPipeline: Bool let codexExpectedGuard: CodexAccountScopedRefreshGuard? let tokenAccount: ProviderTokenAccount? let priorTokenAccountSnapshot: TokenAccountUsageSnapshot? @@ -480,6 +481,11 @@ extension UsageStore { generation: generation)) let outcomeContext = ProviderRefreshOutcomeContext( generation: generation, + claudeUsesConsumerAutoPipeline: Self.isClaudeConsumerAutoPipeline( + provider: provider, + context: fetchContext, + hasAdminAPIKey: claudeHasAdminAPIKey, + hasTokenAccount: tokenAccount != nil), codexExpectedGuard: codexExpectedGuard, tokenAccount: tokenAccount, priorTokenAccountSnapshot: priorTokenAccountSnapshot, @@ -745,7 +751,7 @@ extension UsageStore { self.tokenErrors[provider.instanceID] = nil } self.lastSourceLabels[provider.instanceID] = result.sourceLabel - self.errors[provider.instanceID] = nil + self.recordProviderFetchSuccessErrorState(provider: provider) self.diagnostics[provider.instanceID] = result.diagnostic if let tokenAccount = currentTokenAccount { self.cacheTokenAccountSnapshot( @@ -1303,6 +1309,7 @@ extension UsageStore { self.errors[.claude] = nil self.knownLimitsAvailabilityByProvider.removeValue(forKey: .claude) self.lastSourceLabels.removeValue(forKey: .claude) + self.claudeHistoryFallbackEligible = false self.clearTokenSnapshot(for: .claude) self.tokenErrors[.claude] = nil self.failureGates[.claude]?.reset() @@ -1322,6 +1329,10 @@ extension UsageStore { await MainActor.run { guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } self.diagnostics[provider.instanceID] = nil + let restoredClaudeHistory = self.prepareClaudeHistoryFallback( + provider: provider, + usesConsumerAutoPipeline: context.claudeUsesConsumerAutoPipeline, + accountStateWasStable: context.claudeOAuthActiveAccountObservation != .changed) if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) { // This is a durable provider migration signal, not a transient fetch failure. // Surface it immediately so a cached snapshot cannot hide the required handoff. @@ -1402,11 +1413,12 @@ extension UsageStore { hadPriorData: hadPriorData) || (provider == .claude && hadPriorData && - (Self.isClaudeCLIRateLimitFailure(error) || + (context.claudeUsesConsumerAutoPipeline || + Self.isClaudeCLIRateLimitFailure(error) || isTerminalClaudeCLIParseFailure)) - let shouldSurface = + let shouldSurface = restoredClaudeHistory || self.failureGates[provider.instanceID]? - .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true let preservesClaudeWebSessionFailure = provider == .claude && hadPriorData && @@ -1482,30 +1494,7 @@ extension UsageStore { } } - private static func shouldPreservePriorSnapshot(after error: Error, hadPriorData: Bool) -> Bool { - guard hadPriorData else { return false } - if error is CancellationError { - return true - } - if self.isPreservableNetworkTransportError(error) { - return true - } - - let message = error.localizedDescription.lowercased() - return message.contains("timed out") || - message.contains("timeout") || - message.contains("cancelled") || - message.contains("network connection was lost") || - message.contains("not connected to the internet") - } - - private static func lastAvailableFailedFetchKind(from attempts: [ProviderFetchAttempt]) -> ProviderFetchKind? { - attempts.last { attempt in - attempt.wasAvailable && attempt.errorDescription != nil - }?.kind - } - - static func isPreservableNetworkTransportError(_ error: Error) -> Bool { + nonisolated static func isPreservableNetworkTransportError(_ error: Error) -> Bool { let nsError = error as NSError guard nsError.domain == NSURLErrorDomain else { return false } switch nsError.code { @@ -1565,20 +1554,6 @@ extension UsageStore { return error.localizedDescription == ClaudeStatusProbeError.timedOut.localizedDescription } - private static func isClaudeCLIRateLimitFailure(_ error: Error) -> Bool { - ClaudeUsageFetcher.isCLIRateLimitError(error) - } - - private static func isClaudeCLIUsageParseFailure(_ error: Error) -> Bool { - if case let ClaudeStatusProbeError.parseFailed(message) = error { - return !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message) - } - if case let ClaudeUsageError.parseFailed(message) = error { - return !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message) - } - return false - } - private static func isClaudeWebSessionRefreshFailure(_ error: Error) -> Bool { if case ClaudeWebAPIFetcher.FetchError.unauthorized = error { return true diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index c12fcfefbc..b0748a0225 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -398,6 +398,9 @@ final class UsageStore { @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownResetSnapshots: [ProviderInstanceID: UsageSnapshot] = [:] + /// A stable ambient Auto refresh failed after every live Claude source was exhausted, so persisted + /// plan-utilization history may safely supply a stale presentation snapshot for the same profile. + @ObservationIgnored var claudeHistoryFallbackEligible = false @ObservationIgnored var deepseekProfileTransition: DeepSeekProfileTransition? @ObservationIgnored var sessionQuotaTransitionStates: [ProviderInstanceID: SessionQuotaTransitionState] = [:] @ObservationIgnored var codexSessionQuotaBaselineRequirement: CodexSessionQuotaBaselineRequirement? diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift index 1b3401c8f6..1bc0867818 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift @@ -254,6 +254,7 @@ public enum ClaudeOAuthCredentialsError: LocalizedError, Sendable { case mcpOAuthOnlyKeychain case missingAccessToken case notFound + case keychainAccessRevoked case keychainError(Int) case readFailed(String) case refreshFailed(String) @@ -275,6 +276,9 @@ public enum ClaudeOAuthCredentialsError: LocalizedError, Sendable { return "Claude OAuth access token missing. Run `claude` to authenticate." case .notFound: return "Claude OAuth credentials not found. Run `claude` to authenticate." + case .keychainAccessRevoked: + return "Claude Keychain access was revoked by Claude Code's token rotation. " + + "Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web." case let .keychainError(status): #if os(macOS) if status == Int(errSecUserCanceled) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 1243f258fa..761f3d8d2d 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -450,7 +450,7 @@ public enum ClaudeOAuthCredentialsStore { if let lastError { throw lastError } - throw ClaudeOAuthCredentialsError.notFound + throw ClaudeOAuthCredentialsStore.terminalMissingCredentialsError(environment: environment) } } @@ -1970,6 +1970,33 @@ public enum ClaudeOAuthCredentialsStore { case value(Value) } + static func classifyTerminalMissingCredentialsError( + directReadConsentGranted: Bool, + keychainAccessDisabled: Bool, + keychainAccessDenied: Bool, + previousKeychainGrantRecorded: Bool, + loggedInProfilePresent: Bool) -> ClaudeOAuthCredentialsError + { + guard directReadConsentGranted, + !keychainAccessDisabled, + keychainAccessDenied || (previousKeychainGrantRecorded && loggedInProfilePresent) + else { + return .notFound + } + return .keychainAccessRevoked + } + + private static func terminalMissingCredentialsError(environment: [String: String]) + -> ClaudeOAuthCredentialsError + { + self.classifyTerminalMissingCredentialsError( + directReadConsentGranted: ClaudeOAuthDirectKeychainReadConsent.isGranted(), + keychainAccessDisabled: KeychainAccessGate.isDisabled, + keychainAccessDenied: !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(), + previousKeychainGrantRecorded: self.loadClaudeKeychainFingerprint() != nil, + loggedInProfilePresent: ClaudeAccountProfile.identifiedSessionScope(environment: environment) != nil) + } + @discardableResult public static func invalidateCacheIfCredentialsFileChanged( environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index a8c2b394e0..3142e7cffe 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -993,6 +993,14 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { let isBackgroundAutoRefresh = isBackgroundAppRefresh && context.sourceMode == .auto if isBackgroundAutoRefresh { + // Reusing a recent result does not launch Claude or touch its Keychain item. Keep this fallback + // available even when token rotation invalidated CodexBar's ACL grant; otherwise a dead OAuth step + // can mask CLI usage that succeeded moments earlier. + if let throttleKey = self.throttleKey(binary: binary, context: context), + ClaudeCLIUsageSpawnThrottle.cachedResult(for: throttleKey) != nil + { + return true + } // Every Claude child process is opaque to CodexBar's no-UI Keychain controls, including // `claude auth status`. Background Auto therefore reuses only availability established by a // successful user-initiated CLI fetch in this process. The narrow exception is the owner usage @@ -1011,13 +1019,7 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) - let throttleKey = binary.flatMap { - ClaudeCLIUsageSpawnThrottle.key( - binary: $0, - environment: context.env, - useWebExtras: self.useWebExtras, - includePrepaidBalance: self.includePrepaidBalance && context.includeOptionalUsage) - } + let throttleKey = binary.flatMap { self.throttleKey(binary: $0, context: context) } if context.runtime == .app, ProviderInteractionContext.current == .background, !context.claudeOwnerCLIRecoveryOnly, @@ -1069,6 +1071,17 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { return result } + private func throttleKey( + binary: String, + context: ProviderFetchContext) -> ClaudeCLIUsageSpawnThrottle.Key? + { + ClaudeCLIUsageSpawnThrottle.key( + binary: binary, + environment: context.env, + useWebExtras: self.useWebExtras, + includePrepaidBalance: self.includePrepaidBalance && context.includeOptionalUsage) + } + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { guard context.runtime == .app, context.sourceMode == .auto else { return false } guard !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(error.localizedDescription) else { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 134f3d84a6..aa78d677eb 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -1589,6 +1589,8 @@ extension ClaudeUsageFetcher { "missingAccessToken" case .notFound: "notFound" + case .keychainAccessRevoked: + "keychainAccessRevoked" case let .keychainError(status): "keychainError:\(status)" case .readFailed: diff --git a/Tests/CodexBarTests/ClaudeCLIUsageSpawnThrottleTests.swift b/Tests/CodexBarTests/ClaudeCLIUsageSpawnThrottleTests.swift index c74259ce41..c9432d8b1e 100644 --- a/Tests/CodexBarTests/ClaudeCLIUsageSpawnThrottleTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIUsageSpawnThrottleTests.swift @@ -56,6 +56,37 @@ struct ClaudeCLIUsageSpawnThrottleTests { } } + @Test + func `Auto returns cached CLI usage after OAuth Keychain access is revoked`() async throws { + let strategy = self.makeStrategy() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment, sourceMode: .auto) + let attempts = AttemptRecorder() + let pipeline = ProviderFetchPipeline { _ in [RevokedOAuthStrategy(), strategy] } + + try await self.withGateStack { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ClaudeStatusProbe.$fetchOverride.withValue( + self.successfulFetchOverride(attempts: attempts)) + { + _ = try await self.fetch(strategy, context: context, interaction: .userInitiated) + let outcome = await ProviderInteractionContext.$current.withValue(.background) { + await pipeline.fetch(context: context, provider: .claude) + } + + let result = try outcome.result.get() + #expect(result.strategyID == "claude.cli") + #expect(result.usage.primary?.usedPercent == 11) + #expect(await attempts.snapshot() == 1) + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth-revoked", "claude.cli"]) + } + } + } + } + } + @Test func `background refresh respawns the CLI after the spawn floor elapses`() async throws { let strategy = self.makeStrategy() @@ -217,6 +248,23 @@ struct ClaudeCLIUsageSpawnThrottleTests { case failed } + private struct RevokedOAuthStrategy: ProviderFetchStrategy { + let id = "claude.oauth-revoked" + let kind = ProviderFetchKind.oauth + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeOAuthCredentialsError.keychainAccessRevoked + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } + } + private func makeStrategy() -> ClaudeCLIFetchStrategy { ClaudeCLIFetchStrategy( useWebExtras: false, @@ -228,12 +276,13 @@ struct ClaudeCLIUsageSpawnThrottleTests { private func makeContext( environment: [String: String], + sourceMode: ProviderSourceMode = .cli, claudeOwnerCLIRecoveryOnly: Bool = false) -> ProviderFetchContext { let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( runtime: .app, - sourceMode: .cli, + sourceMode: sourceMode, includeCredits: false, webTimeout: 1, webDebugDumpHTML: false, diff --git a/Tests/CodexBarTests/ClaudeOAuthRotationErrorTests.swift b/Tests/CodexBarTests/ClaudeOAuthRotationErrorTests.swift new file mode 100644 index 0000000000..f77a16c554 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthRotationErrorTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeOAuthRotationErrorTests { + @Test + func `logged in profile with a prior Keychain grant reports revoked access`() { + let error = ClaudeOAuthCredentialsStore.classifyTerminalMissingCredentialsError( + directReadConsentGranted: true, + keychainAccessDisabled: false, + keychainAccessDenied: false, + previousKeychainGrantRecorded: true, + loggedInProfilePresent: true) + + #expect(error.localizedDescription.contains("token rotation")) + #expect(error.localizedDescription.contains("Click Refresh")) + #expect(error.localizedDescription.contains("CLI/Web")) + } + + @Test + func `profile without current login evidence still reports missing credentials`() { + let error = ClaudeOAuthCredentialsStore.classifyTerminalMissingCredentialsError( + directReadConsentGranted: true, + keychainAccessDisabled: false, + keychainAccessDenied: false, + previousKeychainGrantRecorded: true, + loggedInProfilePresent: false) + + guard case .notFound = error else { + Issue.record("Expected a genuinely absent item to remain notFound") + return + } + } + + @Test + @MainActor + func `Claude rotation error localizes and includes stale capture age`() throws { + let raw = ClaudeOAuthCredentialsError.keychainAccessRevoked.localizedDescription + let message = try #require(ClaudeUIErrorMapper.userFacingMessage( + raw, + staleSnapshotUpdatedAt: Date(timeIntervalSinceNow: -5 * 60), + localize: { key in + switch key { + case "claude_oauth_keychain_access_revoked": "localized revoked access" + case "claude_showing_last_known_usage": "stale capture %@" + default: key + } + })) + + #expect(message.hasPrefix("localized revoked access stale capture ")) + } +} diff --git a/Tests/CodexBarTests/ClaudeResilienceTests.swift b/Tests/CodexBarTests/ClaudeResilienceTests.swift index 9b9fccee4f..8ef4e1a4f2 100644 --- a/Tests/CodexBarTests/ClaudeResilienceTests.swift +++ b/Tests/CodexBarTests/ClaudeResilienceTests.swift @@ -1191,6 +1191,94 @@ extension ClaudeResilienceTests { } } + @Test + func `Auto restores stale Claude quota bars from disk history when every source fails`() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-history-fallback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") + let sessionCapturedAt = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyCapturedAt = sessionCapturedAt.addingTimeInterval(60) + + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + let store = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-history-fallback") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .auto + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == .claude) + } + + let environment = ["CLAUDE_CONFIG_DIR": tempDir.path] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + store.planUtilizationHistoryLoaded = true + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [ + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [PlanUtilizationHistoryEntry( + capturedAt: sessionCapturedAt, + usedPercent: 21, + resetsAt: nil)]), + PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [PlanUtilizationHistoryEntry( + capturedAt: weeklyCapturedAt, + usedPercent: 42, + resetsAt: nil)]), + ]) + + let baseSpec = try #require(store.providerSpecs[.claude]) + let descriptor = ProviderDescriptor( + id: .claude, + metadata: baseSpec.descriptor.metadata, + branding: baseSpec.descriptor.branding, + tokenCost: baseSpec.descriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto], + pipeline: ProviderFetchPipeline { _ in [AllClaudeSourcesFailureStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + primary: store.snapshot(for: .claude)?.primary?.usedPercent, + secondary: store.snapshot(for: .claude)?.secondary?.usedPercent, + updatedAt: store.snapshot(for: .claude)?.updatedAt, + rawError: store.error(for: .claude), + userFacingError: store.userFacingError(for: .claude)) + } + + #expect(result.primary == 21) + #expect(result.secondary == 42) + #expect(result.updatedAt == weeklyCapturedAt) + #expect(result.rawError == ClaudeOAuthCredentialsError.keychainAccessRevoked.localizedDescription) + #expect(result.userFacingError?.contains("Showing last-known usage captured") == true) + } + } + } + @Test func `credentials change during successful Claude fetch applies fresh snapshot without stale reset`() async throws { try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { @@ -1322,6 +1410,23 @@ private struct TimeoutFetchStrategy: ProviderFetchStrategy { } } +private struct AllClaudeSourcesFailureStrategy: ProviderFetchStrategy { + let id = "test.all-claude-sources-failed" + let kind = ProviderFetchKind.oauth + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeOAuthCredentialsError.keychainAccessRevoked + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + private struct CancellationFetchStrategy: ProviderFetchStrategy { let id = "test.cancellation" let kind: ProviderFetchKind = .cli diff --git a/docs/claude.md b/docs/claude.md index b6c32d0f9f..55bc684f2e 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -91,6 +91,12 @@ Admin API key setup: snapshot persisted. It does not change fetching, the menu, history, notifications, hooks, or CLI output. - Successful OAuth login enables Claude and preserves the selected usage source. With the default Auto source, OAuth remains preferred when readable, while CLI/Web fallback stays available when OAuth credentials are not usable. +- Claude Code periodically rotates its `Claude Code-credentials` Keychain item and can replace the ACL grant that + allowed CodexBar to read it. Auto treats that as a failed OAuth source, reuses a recent successful CLI result or + continues to CLI/Web, and does not misreport the existing credentials as missing. A manual Refresh can re-grant + Keychain access; selecting CLI or Web avoids the foreign-Keychain dependency. +- When every live Auto source fails, CodexBar keeps the last captured session/weekly percentages from + `history/claude.json` visible as stale data and shows their capture age instead of blanking the quota bars. - Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to Max/Pro/Team/Enterprise. When a Max `rate_limit_tier` carries a usage multiplier (`default_claude_max_5x` / `default_claude_max_20x`), it is surfaced in the label as "Max 5x" / "Max 20x". From c3a7333ef236e79ea4eb488944902317e2f749a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 08:15:18 -0700 Subject: [PATCH 2/4] fix: translate Claude OAuth recovery messages --- Sources/CodexBar/Resources/ar.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/ca.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/de.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/es.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/fa.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/fr.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/gl.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/id.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/it.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/ja.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/ko.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/nl.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/pl.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/ru.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/sv.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/th.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/tr.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/uk.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/vi.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings | 4 ++-- Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings | 4 ++-- 22 files changed, 44 insertions(+), 44 deletions(-) diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 565f105595..d5d6f39856 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1445,5 +1445,5 @@ "5-hour usage" = "الاستخدام خلال 5 ساعات"; "7-day usage" = "الاستخدام خلال 7 أيام"; "Total usage" = "إجمالي الاستخدام"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "تم إلغاء وصول CodexBar إلى سلسلة مفاتيح Claude بسبب تدوير الرمز المميز في Claude Code. انقر على «تحديث» لمنح الوصول مجددًا، أو بدّل مصدر استخدام Claude إلى CLI/Web."; +"claude_showing_last_known_usage" = "يتم عرض آخر بيانات استخدام معروفة، تم التقاطها %@."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index d3e180294a..7291997c40 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1444,5 +1444,5 @@ "5-hour usage" = "Ús de 5 hores"; "7-day usage" = "Ús de 7 dies"; "Total usage" = "Ús total"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "L'accés al clauer de Claude s'ha revocat per la rotació del testimoni de Claude Code. Feu clic a Actualitza per tornar a concedir l'accés, o canvieu l'origen d'ús de Claude a CLI/Web."; +"claude_showing_last_known_usage" = "Es mostra l'últim ús conegut capturat %@."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index a9f5bd7290..b06050f128 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1442,5 +1442,5 @@ "5-hour usage" = "5-Stunden-Nutzung"; "7-day usage" = "7-Tage-Nutzung"; "Total usage" = "Gesamtnutzung"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Der Zugriff auf den Claude-Schlüsselbund wurde durch die Token-Rotation von Claude Code widerrufen. Klicken Sie auf „Aktualisieren“, um den Zugriff erneut zu gewähren, oder stellen Sie die Claude-Nutzungsquelle auf CLI/Web um."; +"claude_showing_last_known_usage" = "Letzte bekannte Nutzung wird angezeigt (erfasst: %@)."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 10d1c43882..d0b9d2617c 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1440,5 +1440,5 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 días"; "Total usage" = "Uso total"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "El acceso al llavero de Claude fue revocado por la rotación del token de Claude Code. Haz clic en Actualizar para volver a conceder acceso o cambia el origen del uso de Claude a CLI/Web."; +"claude_showing_last_known_usage" = "Mostrando el último uso conocido, capturado %@."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 6fc6f387fc..4e10f8dd42 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1445,5 +1445,5 @@ "5-hour usage" = "مصرف ۵ ساعته"; "7-day usage" = "مصرف ۷ روزه"; "Total usage" = "مصرف کل"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "دسترسی به Keychain کلود با چرخش توکن Claude Code لغو شد. برای اعطای مجدد دسترسی روی «تازه‌سازی» کلیک کنید، یا منبع استفاده Claude را به CLI/Web تغییر دهید."; +"claude_showing_last_known_usage" = "آخرین میزان استفاده شناخته‌شده که در %@ ثبت شده نمایش داده می‌شود."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 1fc858edb9..6d200ec5c3 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1441,5 +1441,5 @@ "5-hour usage" = "Utilisation sur 5 heures"; "7-day usage" = "Utilisation sur 7 jours"; "Total usage" = "Utilisation totale"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "L’accès au trousseau Claude a été révoqué par la rotation du jeton de Claude Code. Cliquez sur Actualiser pour accorder à nouveau l’accès, ou définissez la source d’utilisation de Claude sur CLI/Web."; +"claude_showing_last_known_usage" = "Affichage de la dernière utilisation connue, capturée %@."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 5d153d3449..0d8fa069a6 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1441,5 +1441,5 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 días"; "Total usage" = "Uso total"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "O acceso ao chaveiro de Claude foi revogado pola rotación do token de Claude Code. Preme Actualizar para volver conceder o acceso ou cambia a orixe de uso de Claude a CLI/Web."; +"claude_showing_last_known_usage" = "Mostrando o último uso coñecido, capturado %@."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 976983b7fd..107e0ad34f 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1445,5 +1445,5 @@ "5-hour usage" = "Penggunaan 5 jam"; "7-day usage" = "Penggunaan 7 hari"; "Total usage" = "Total penggunaan"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Akses Rantai Kunci Claude dicabut akibat rotasi token Claude Code. Klik Segarkan untuk memberikan akses lagi, atau ubah sumber penggunaan Claude ke CLI/Web."; +"claude_showing_last_known_usage" = "Menampilkan penggunaan terakhir yang diketahui, diambil %@."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 8b73f220a7..d69926cf42 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1445,5 +1445,5 @@ "5-hour usage" = "Utilizzo di 5 ore"; "7-day usage" = "Utilizzo di 7 giorni"; "Total usage" = "Utilizzo totale"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "L'accesso al portachiavi di Claude è stato revocato dalla rotazione del token di Claude Code. Fai clic su Aggiorna per concedere nuovamente l'accesso oppure imposta la fonte di utilizzo di Claude su CLI/Web."; +"claude_showing_last_known_usage" = "Visualizzazione dell'ultimo utilizzo noto, acquisito %@."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index a9df994c29..205839e72c 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1442,5 +1442,5 @@ "5-hour usage" = "5時間の使用量"; "7-day usage" = "7日間の使用量"; "Total usage" = "合計使用量"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Claude Code のトークン更新により、Claude キーチェーンへのアクセスが取り消されました。「更新」をクリックしてアクセスを再許可するか、Claude の使用量の取得元を CLI/Web に切り替えてください。"; +"claude_showing_last_known_usage" = "最後に取得した既知の使用量を表示しています(取得: %@)。"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 4425995d2f..139c4c213e 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1409,5 +1409,5 @@ "5-hour usage" = "5시간 사용량"; "7-day usage" = "7일 사용량"; "Total usage" = "총 사용량"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Claude Code의 토큰 교체로 Claude 키체인 접근 권한이 취소되었습니다. 새로 고침을 클릭해 접근 권한을 다시 부여하거나 Claude 사용량 소스를 CLI/Web으로 전환하세요."; +"claude_showing_last_known_usage" = "마지막으로 확인된 사용량을 표시 중입니다(캡처: %@)."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 89c8e99bc0..753e891abc 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1441,5 +1441,5 @@ "5-hour usage" = "5-uursgebruik"; "7-day usage" = "7-dagengebruik"; "Total usage" = "Totaal gebruik"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "De toegang tot de Claude-sleutelhanger is ingetrokken door de tokenrotatie van Claude Code. Klik op Vernieuwen om opnieuw toegang te verlenen of zet de Claude-gebruiksbron op CLI/Web."; +"claude_showing_last_known_usage" = "De laatst bekende gebruiksgegevens worden weergegeven (vastgelegd: %@)."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 4cabb7e22b..4343d67ee2 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1445,5 +1445,5 @@ "5-hour usage" = "Użycie 5-godzinne"; "7-day usage" = "Użycie 7-dniowe"; "Total usage" = "Łączne użycie"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Dostęp do pęku kluczy Claude został cofnięty wskutek rotacji tokenu przez Claude Code. Kliknij Odśwież, aby ponownie przyznać dostęp, albo przełącz źródło użycia Claude na CLI/Web."; +"claude_showing_last_known_usage" = "Wyświetlane jest ostatnie znane użycie zarejestrowane %@."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 53134f2f70..98fcbd7282 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1442,5 +1442,5 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 dias"; "Total usage" = "Uso total"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "O acesso às Chaves do Claude foi revogado pela rotação do token do Claude Code. Clique em Atualizar para conceder o acesso novamente ou altere a fonte de uso do Claude para CLI/Web."; +"claude_showing_last_known_usage" = "Exibindo o último uso conhecido, capturado %@."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index f57e2c39e9..5f40829ee4 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1443,5 +1443,5 @@ "5-hour usage" = "Использование за 5 часов"; "7-day usage" = "Использование за 7 дней"; "Total usage" = "Общее использование"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Доступ к Связке ключей Claude был отозван из-за ротации токена в Claude Code. Нажмите «Обновить», чтобы повторно предоставить доступ, или переключите источник использования Claude на CLI/Web."; +"claude_showing_last_known_usage" = "Показаны последние известные данные об использовании (получены %@)."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 52bb32994c..f97f42e323 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1440,5 +1440,5 @@ "5-hour usage" = "5-timmarsanvändning"; "7-day usage" = "7-dagarsanvändning"; "Total usage" = "Total användning"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Åtkomsten till Claudes nyckelring återkallades när Claude Code roterade token. Klicka på Uppdatera för att ge åtkomst igen, eller byt Claudes användningskälla till CLI/Web."; +"claude_showing_last_known_usage" = "Visar senast kända användning, registrerad %@."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 981648fca6..5ec60cb6c4 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1445,5 +1445,5 @@ "5-hour usage" = "การใช้งาน 5 ชั่วโมง"; "7-day usage" = "การใช้งาน 7 วัน"; "Total usage" = "การใช้งานทั้งหมด"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "สิทธิ์เข้าถึงพวงกุญแจ Claude ถูกเพิกถอนจากการหมุนเวียนโทเค็นของ Claude Code คลิกรีเฟรชเพื่อให้สิทธิ์อีกครั้ง หรือเปลี่ยนแหล่งที่มาการใช้งาน Claude เป็น CLI/Web"; +"claude_showing_last_known_usage" = "กำลังแสดงการใช้งานล่าสุดที่ทราบ ซึ่งบันทึกเมื่อ %@"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 1786b9b124..5a659bffef 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1443,5 +1443,5 @@ "5-hour usage" = "5 saatlik kullanım"; "7-day usage" = "7 günlük kullanım"; "Total usage" = "Toplam kullanım"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Claude Anahtar Zinciri erişimi, Claude Code'un belirteç yenilemesi nedeniyle iptal edildi. Erişimi yeniden vermek için Yenile'ye tıklayın veya Claude Kullanım kaynağını CLI/Web olarak değiştirin."; +"claude_showing_last_known_usage" = "Bilinen son kullanım gösteriliyor (yakalanma zamanı: %@)."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 8227262440..53a65e52b2 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1441,5 +1441,5 @@ "5-hour usage" = "Використання за 5 годин"; "7-day usage" = "Використання за 7 днів"; "Total usage" = "Загальне використання"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Доступ до В’язки ключів Claude було відкликано через ротацію токена в Claude Code. Натисніть «Оновити», щоб повторно надати доступ, або перемкніть джерело використання Claude на CLI/Web."; +"claude_showing_last_known_usage" = "Показано останні відомі дані про використання (отримано %@)."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index b122ffe6fa..1c065806c7 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1442,5 +1442,5 @@ "5-hour usage" = "Mức sử dụng 5 giờ"; "7-day usage" = "Mức sử dụng 7 ngày"; "Total usage" = "Tổng mức sử dụng"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Quyền truy cập Chuỗi khóa Claude đã bị thu hồi do Claude Code xoay vòng mã thông báo. Nhấp vào Làm mới để cấp lại quyền truy cập hoặc chuyển nguồn sử dụng Claude sang CLI/Web."; +"claude_showing_last_known_usage" = "Đang hiển thị mức sử dụng đã biết gần nhất, được ghi nhận %@."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 98817a277f..d995bc5016 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1420,5 +1420,5 @@ "5-hour usage" = "5 小时用量"; "7-day usage" = "7 天用量"; "Total usage" = "总用量"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Claude Code 轮换令牌后撤销了对 Claude 钥匙串的访问权限。点击“刷新”以重新授权,或将 Claude 用量来源切换为 CLI/Web。"; +"claude_showing_last_known_usage" = "正在显示 %@ 采集的最后已知用量。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index dd69b710e8..a4b6529432 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1472,5 +1472,5 @@ "5-hour usage" = "5 小時用量"; "7-day usage" = "7 天用量"; "Total usage" = "總用量"; -"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; -"claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"claude_oauth_keychain_access_revoked" = "Claude Code 輪替權杖後撤銷了 Claude 鑰匙圈的存取權。按一下「重新整理」以重新授權,或將 Claude 使用量來源切換為 CLI/Web。"; +"claude_showing_last_known_usage" = "正在顯示於 %@ 擷取的最後已知使用量。"; From 46cce585c9ce906a70338f97c2d20457f0b95f50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 09:08:08 -0700 Subject: [PATCH 3/4] test: update provider architecture fingerprints --- .../UsageStore+ClaudeHistoryFallback.swift | 5 ++ .../ProviderArchitectureGatekeeperTests.swift | 70 +++++++++---------- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift b/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift index 854dec2d4c..f846676b8c 100644 --- a/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift +++ b/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift @@ -8,10 +8,12 @@ extension UsageStore { hasAdminAPIKey: Bool, hasTokenAccount: Bool) -> Bool { + // Provider-specific by design: this gate protects Claude's consumer Auto source from Admin/token accounts. provider == .claude && context.sourceMode == .auto && !hasAdminAPIKey && !hasTokenAccount } func clearClaudeHistoryFallbackEligibility(provider: UsageProvider) { + // Provider-specific by design: only Claude owns the persisted quota-history fallback state. guard provider == .claude else { return } self.claudeHistoryFallbackEligible = false } @@ -27,6 +29,7 @@ extension UsageStore { usesConsumerAutoPipeline: Bool, accountStateWasStable: Bool) -> Bool { + // Provider-specific by design: only a stable Claude refresh may arm the Claude history fallback. guard provider == .claude else { return false } let eligible = usesConsumerAutoPipeline && accountStateWasStable self.claudeHistoryFallbackEligible = eligible @@ -74,6 +77,7 @@ extension UsageStore { /// unset: history proves the percentages and capture time, but it must never impersonate a live account fetch. @discardableResult func restoreClaudeHistorySnapshotIfNeeded() -> Bool { + // Provider-specific by design: this reconstructs only Claude quota bars in Claude's isolated state lanes. guard self.claudeHistoryFallbackEligible, self.planUtilizationHistoryLoaded, self.settings.claudeUsageDataSource == .auto, @@ -106,6 +110,7 @@ extension UsageStore { tertiary: opus.map(Self.rateWindow(from:)), updatedAt: capturedAt, dataConfidence: .percentOnly) + // Provider-specific by design: reconstructed history is published only to Claude's snapshot/reset lanes. self.snapshots[.claude] = snapshot self.lastKnownResetSnapshots[.claude] = snapshot return true diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 490125945e..9f776d3179 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1058,37 +1058,37 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 436, + line: 437, anchor: "usage: result.usage.scoped(to: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 460, + line: 461, anchor: "usage: result.usage.scoped(to: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 925, + line: 931, anchor: "let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 933, + line: 939, anchor: "let identity = snapshot.identity(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 935, + line: 941, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1466, + line: 1478, anchor: "let currentAccount = self.uniqueTokenAccount(provider: .claude, accountID: fetchedAccount.id),", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1275,19 +1275,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1050, + line: 1053, anchor: "provider: .deepseek,", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1152, + line: 1155, anchor: "let sourceMode = self.sourceMode(for: .claude)", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1156, + line: 1159, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2588,9 +2588,9 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBar/UsageStore+Accessors.swift", line: 154, anchor: "case .codex:", - expectedProviderIDs: ["codex", "ollama"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "ollama@2"], + expectedProviderIDs: ["claude", "codex", "ollama"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["codex@0", "claude@2", "ollama@6"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", @@ -2762,7 +2762,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 342, + line: 343, anchor: "let codexPreparation = provider == .codex ? self.prepareCodexRefreshPublication() : nil", expectedProviderIDs: ["claude", "codex", "kilo"], expectedReferenceCount: 6, @@ -2770,7 +2770,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 398, + line: 399, anchor: "let priorClaudeSourceLabel = provider == .claude ? self.lastSourceLabels[.claude] : nil", expectedProviderIDs: ["claude"], expectedReferenceCount: 2, @@ -2778,7 +2778,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 412, + line: 413, anchor: "guard provider == .codex else { return outcome }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2786,7 +2786,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 431, + line: 432, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2794,7 +2794,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 523, + line: 529, anchor: "guard input.provider == .claude else {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2802,7 +2802,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 691, + line: 697, anchor: "if provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2810,7 +2810,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 725, + line: 731, anchor: "codexOwnerKey: provider == .codex ? context.codexSessionQuotaOwnerKey : nil)", expectedProviderIDs: ["claude", "codex", "deepseek"], expectedReferenceCount: 4, @@ -2818,7 +2818,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 757, + line: 763, anchor: "if provider == .gemini {", expectedProviderIDs: ["claude", "codex", "gemini"], expectedReferenceCount: 5, @@ -2826,7 +2826,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 809, + line: 815, anchor: "if provider == .codex {", expectedProviderIDs: ["codex", "deepseek"], expectedReferenceCount: 4, @@ -2834,7 +2834,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 858, + line: 864, anchor: "guard provider == .deepseek else { return snapshot }", expectedProviderIDs: ["codex", "deepseek"], expectedReferenceCount: 8, @@ -2851,7 +2851,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 985, + line: 991, anchor: "guard provider == .claude, !hasSelectedTokenAccount else { return false }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2859,7 +2859,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1325, + line: 1336, anchor: "if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {", expectedProviderIDs: ["claude", "gemini"], expectedReferenceCount: 2, @@ -2867,7 +2867,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1369, + line: 1380, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2875,15 +2875,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1385, + line: 1396, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 5, - expectedReferenceFingerprint: ["claude@0", "claude@11", "claude@18", "claude@26", "claude@35"], + expectedReferenceFingerprint: ["claude@0", "claude@11", "claude@18", "claude@27", "claude@36"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1467, + line: 1479, anchor: "cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount)", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3233,7 +3233,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 591, + line: 594, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3241,7 +3241,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 643, + line: 646, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3249,7 +3249,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 676, + line: 679, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3257,7 +3257,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1024, + line: 1027, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3265,7 +3265,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1047, + line: 1050, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3273,7 +3273,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1104, + line: 1107, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3289,7 +3289,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1159, + line: 1162, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, From 4fe1ceb3e3f96da4a9324ccc4407108e4442b203 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 09:56:19 -0700 Subject: [PATCH 4/4] fix: clear Claude history after account removal --- .../UsageStore+ClaudeHistoryFallback.swift | 6 ++-- Sources/CodexBar/UsageStore+Refresh.swift | 11 +++---- .../CodexBar/UsageStore+TokenAccounts.swift | 11 +++++++ .../CodexBarTests/ClaudeResilienceTests.swift | 4 ++- .../ProviderArchitectureGatekeeperTests.swift | 30 +++++++++---------- 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift b/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift index f846676b8c..5ce24c22ac 100644 --- a/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift +++ b/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift @@ -6,10 +6,12 @@ extension UsageStore { provider: UsageProvider, context: ProviderFetchContext, hasAdminAPIKey: Bool, - hasTokenAccount: Bool) -> Bool + hasTokenAccount: Bool, + removedTokenAccountAuthority: Bool) -> Bool { // Provider-specific by design: this gate protects Claude's consumer Auto source from Admin/token accounts. - provider == .claude && context.sourceMode == .auto && !hasAdminAPIKey && !hasTokenAccount + provider == .claude && context.sourceMode == .auto && !hasAdminAPIKey && !hasTokenAccount && + !removedTokenAccountAuthority } func clearClaudeHistoryFallbackEligibility(provider: UsageProvider) { diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 53f0dc04c6..2bfeb3c752 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -370,18 +370,18 @@ extension UsageStore { self.scheduleClaudeSwapAccountRefresh(generation: generation) } - let tokenAccounts = self.tokenAccounts(for: provider) - if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { + let tokenAccountPreparation = self.tokenAccountRefreshPreparation(for: provider) + if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccountPreparation.accounts) { await self.refreshTokenAccounts( provider: provider, - accounts: tokenAccounts, + accounts: tokenAccountPreparation.accounts, generation: generation) return nil } else { _ = await MainActor.run { self.reconcileSelectedTokenAccountSnapshotBeforeRefresh( provider: provider, - accounts: tokenAccounts) + accounts: tokenAccountPreparation.accounts) } } @@ -485,7 +485,8 @@ extension UsageStore { provider: provider, context: fetchContext, hasAdminAPIKey: claudeHasAdminAPIKey, - hasTokenAccount: tokenAccount != nil), + hasTokenAccount: tokenAccount != nil, + removedTokenAccountAuthority: tokenAccountPreparation.removesAccountAuthority), codexExpectedGuard: codexExpectedGuard, tokenAccount: tokenAccount, priorTokenAccountSnapshot: priorTokenAccountSnapshot, diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 681c76733f..6323f72aa3 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -1531,3 +1531,14 @@ extension UsageStore { } } } + +extension UsageStore { + func tokenAccountRefreshPreparation(for provider: UsageProvider) + -> (accounts: [ProviderTokenAccount], removesAccountAuthority: Bool) + { + let accounts = self.tokenAccounts(for: provider) + let removesAccountAuthority = self.tokenAccountLiveStateProviders.contains(provider.instanceID) && + self.settings.effectiveSelectedTokenAccount(for: provider) == nil + return (accounts, removesAccountAuthority) + } +} diff --git a/Tests/CodexBarTests/ClaudeResilienceTests.swift b/Tests/CodexBarTests/ClaudeResilienceTests.swift index 8ef4e1a4f2..2af77ca337 100644 --- a/Tests/CodexBarTests/ClaudeResilienceTests.swift +++ b/Tests/CodexBarTests/ClaudeResilienceTests.swift @@ -1192,11 +1192,13 @@ extension ClaudeResilienceTests { } @Test - func `Auto restores stale Claude quota bars from disk history when every source fails`() async throws { + func `Auto restores stale Claude quota bars for a configured account when every source fails`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-claude-history-fallback-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } + try Data(#"{"oauthAccount":{"accountUuid":"account-a"}}"#.utf8) + .write(to: tempDir.appendingPathComponent(".config.json"), options: .atomic) let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") let sessionCapturedAt = Date(timeIntervalSince1970: 1_800_000_000) let weeklyCapturedAt = sessionCapturedAt.addingTimeInterval(60) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 9f776d3179..d15d2dea65 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1070,25 +1070,25 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 931, + line: 932, anchor: "let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 939, + line: 940, anchor: "let identity = snapshot.identity(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 941, + line: 942, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1478, + line: 1479, anchor: "let currentAccount = self.uniqueTokenAccount(provider: .claude, accountID: fetchedAccount.id),", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2794,7 +2794,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 529, + line: 530, anchor: "guard input.provider == .claude else {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2802,7 +2802,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 697, + line: 698, anchor: "if provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2810,7 +2810,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 731, + line: 732, anchor: "codexOwnerKey: provider == .codex ? context.codexSessionQuotaOwnerKey : nil)", expectedProviderIDs: ["claude", "codex", "deepseek"], expectedReferenceCount: 4, @@ -2818,7 +2818,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 763, + line: 764, anchor: "if provider == .gemini {", expectedProviderIDs: ["claude", "codex", "gemini"], expectedReferenceCount: 5, @@ -2826,7 +2826,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 815, + line: 816, anchor: "if provider == .codex {", expectedProviderIDs: ["codex", "deepseek"], expectedReferenceCount: 4, @@ -2834,7 +2834,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 864, + line: 865, anchor: "guard provider == .deepseek else { return snapshot }", expectedProviderIDs: ["codex", "deepseek"], expectedReferenceCount: 8, @@ -2851,7 +2851,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 991, + line: 992, anchor: "guard provider == .claude, !hasSelectedTokenAccount else { return false }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2859,7 +2859,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1336, + line: 1337, anchor: "if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {", expectedProviderIDs: ["claude", "gemini"], expectedReferenceCount: 2, @@ -2867,7 +2867,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1380, + line: 1381, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2875,7 +2875,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1396, + line: 1397, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 5, @@ -2883,7 +2883,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1479, + line: 1480, anchor: "cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount)", expectedProviderIDs: ["claude"], expectedReferenceCount: 1,