diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f9708e11..f53b73c93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,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 bb2657c495..a1add5270e 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1454,3 +1454,5 @@ "5-hour usage" = "الاستخدام خلال 5 ساعات"; "7-day usage" = "الاستخدام خلال 7 أيام"; "Total usage" = "إجمالي الاستخدام"; +"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 f4238b2f6f..f0ce9c6813 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1453,3 +1453,5 @@ "5-hour usage" = "Ús de 5 hores"; "7-day usage" = "Ús de 7 dies"; "Total usage" = "Ús total"; +"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 97bd758fd1..0cda3a177a 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1451,3 +1451,5 @@ "5-hour usage" = "5-Stunden-Nutzung"; "7-day usage" = "7-Tage-Nutzung"; "Total usage" = "Gesamtnutzung"; +"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/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 3b7acc5ba9..d2ffdf0529 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 0131a4980d..a6726934f3 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1449,3 +1449,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" = "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 e326f42bb2..2989520621 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1454,3 +1454,5 @@ "5-hour usage" = "مصرف ۵ ساعته"; "7-day usage" = "مصرف ۷ روزه"; "Total usage" = "مصرف کل"; +"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 df09c3aa61..2074575c7d 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1450,3 +1450,5 @@ "5-hour usage" = "Utilisation sur 5 heures"; "7-day usage" = "Utilisation sur 7 jours"; "Total usage" = "Utilisation totale"; +"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 dd25240951..29cef092ca 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1450,3 +1450,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" = "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 7b36c5678d..2c023e04d5 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1454,3 +1454,5 @@ "5-hour usage" = "Penggunaan 5 jam"; "7-day usage" = "Penggunaan 7 hari"; "Total usage" = "Total penggunaan"; +"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 86689851f5..e5c84c1902 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1454,3 +1454,5 @@ "5-hour usage" = "Utilizzo di 5 ore"; "7-day usage" = "Utilizzo di 7 giorni"; "Total usage" = "Utilizzo totale"; +"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 367556f6ec..5450527094 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1451,3 +1451,5 @@ "5-hour usage" = "5時間の使用量"; "7-day usage" = "7日間の使用量"; "Total usage" = "合計使用量"; +"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 ffcf6b0289..c2c4694371 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1418,3 +1418,5 @@ "5-hour usage" = "5시간 사용량"; "7-day usage" = "7일 사용량"; "Total usage" = "총 사용량"; +"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 acedb2d07c..8c6d008070 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1450,3 +1450,5 @@ "5-hour usage" = "5-uursgebruik"; "7-day usage" = "7-dagengebruik"; "Total usage" = "Totaal gebruik"; +"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 c940bcadf7..9ebff1f3be 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1454,3 +1454,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" = "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 11afc68c31..5df8ca2590 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1451,3 +1451,5 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 dias"; "Total usage" = "Uso total"; +"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 c716b6ce5b..fe49292d0a 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1452,3 +1452,5 @@ "5-hour usage" = "Использование за 5 часов"; "7-day usage" = "Использование за 7 дней"; "Total usage" = "Общее использование"; +"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 efd9b28737..54788af8b7 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1449,3 +1449,5 @@ "5-hour usage" = "5-timmarsanvändning"; "7-day usage" = "7-dagarsanvändning"; "Total usage" = "Total användning"; +"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 0195b3350a..01d882f2a3 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1454,3 +1454,5 @@ "5-hour usage" = "การใช้งาน 5 ชั่วโมง"; "7-day usage" = "การใช้งาน 7 วัน"; "Total usage" = "การใช้งานทั้งหมด"; +"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 90428f108e..78ca5c77eb 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1452,3 +1452,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 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 c7d2ef38c6..62a2b5a208 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1450,3 +1450,5 @@ "5-hour usage" = "Використання за 5 годин"; "7-day usage" = "Використання за 7 днів"; "Total usage" = "Загальне використання"; +"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 5d2830f6d9..d9a4c504e9 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1451,3 +1451,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" = "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 3dab7bfa60..1c7705209c 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1429,3 +1429,5 @@ "5-hour usage" = "5 小时用量"; "7-day usage" = "7 天用量"; "Total usage" = "总用量"; +"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 da1c20ea16..6c0e09d311 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1481,3 +1481,5 @@ "5-hour usage" = "5 小時用量"; "7-day usage" = "7 天用量"; "Total usage" = "總用量"; +"claude_oauth_keychain_access_revoked" = "Claude Code 輪替權杖後撤銷了 Claude 鑰匙圈的存取權。按一下「重新整理」以重新授權,或將 Claude 使用量來源切換為 CLI/Web。"; +"claude_showing_last_known_usage" = "正在顯示於 %@ 擷取的最後已知使用量。"; 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..5ce24c22ac --- /dev/null +++ b/Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift @@ -0,0 +1,147 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + nonisolated static func isClaudeConsumerAutoPipeline( + provider: UsageProvider, + context: ProviderFetchContext, + hasAdminAPIKey: 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 && + !removedTokenAccountAuthority + } + + 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 + } + + func recordProviderFetchSuccessErrorState(provider: UsageProvider) { + self.errors[provider.instanceID] = nil + self.clearClaudeHistoryFallbackEligibility(provider: provider) + } + + @discardableResult + func prepareClaudeHistoryFallback( + provider: UsageProvider, + 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 + 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 { + // 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, + 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) + // 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 + } + + 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..2bfeb3c752 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? @@ -369,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) } } @@ -480,6 +481,12 @@ extension UsageStore { generation: generation)) let outcomeContext = ProviderRefreshOutcomeContext( generation: generation, + claudeUsesConsumerAutoPipeline: Self.isClaudeConsumerAutoPipeline( + provider: provider, + context: fetchContext, + hasAdminAPIKey: claudeHasAdminAPIKey, + hasTokenAccount: tokenAccount != nil, + removedTokenAccountAuthority: tokenAccountPreparation.removesAccountAuthority), codexExpectedGuard: codexExpectedGuard, tokenAccount: tokenAccount, priorTokenAccountSnapshot: priorTokenAccountSnapshot, @@ -745,7 +752,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 +1310,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 +1330,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 +1414,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 +1495,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 +1555,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+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/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..2af77ca337 100644 --- a/Tests/CodexBarTests/ClaudeResilienceTests.swift +++ b/Tests/CodexBarTests/ClaudeResilienceTests.swift @@ -1191,6 +1191,96 @@ extension ClaudeResilienceTests { } } + @Test + 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) + + 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 +1412,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/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 490125945e..d15d2dea65 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: 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: 933, + 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: 935, + 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: 1466, + 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."), @@ -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: 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: 691, + 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: 725, + 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: 757, + 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: 809, + 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: 858, + 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: 985, + 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: 1325, + 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: 1369, + line: 1381, 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: 1397, 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: 1480, 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, 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".