Live quota bar inside AgentTab + Claude OAuth refresh gate (#255)
Some checks are pending
CI / semgrep (push) Waiting to run

* Gate Claude OAuth refresh attempts on terminal failures

Anthropic returns invalid_grant (HTTP 400) when the user's refresh token has
been revoked or rotated, typically after they re-ran claude login on another
device. The previous code rethrew the raw error every refresh cycle, leaving
the Plan UI stuck on a Swift error string and pummeling Anthropic's token
endpoint forever.

The new SubscriptionRefreshGate captures a fingerprint of
~/.claude/.credentials.json on terminal failure and stops trying until that
fingerprint changes (the user re-logs-in). Transient 5xx/network failures
get exponential backoff capped at 6 hours.

Two new SubscriptionError cases let the UI distinguish "user must reconnect"
from "Anthropic is flaky right now" and show a clean reconnect CTA instead
of raw HTTP guts.

* Inline live-quota progress bar inside each AgentTab chip

When a provider exposes a live quota source, the AgentTab chip grows by ~3pt
to host a thin weekly-utilization bar directly under the label. Hovering the
chip reveals a popover with all four Anthropic windows (5-hour, weekly, weekly
Opus, weekly Sonnet) plus reset countdowns. Click still switches the tab as
before.

Today only Claude has a quota source (the existing /api/oauth/usage path);
other providers' chips render unchanged. The QuotaSummary abstraction lets
us bolt on Cursor/Copilot/Codex meters in follow-up commits.

Subscription is now refreshed eagerly on the periodic loop so the bar lights
up without forcing the user to open a deep view first. The previous
SubscriptionRefreshGate keeps a dead refresh token from spamming Anthropic.

Adds two new SubscriptionLoadState cases (terminalFailure, transientFailure)
so the deep Plan view shows a "reconnect" message instead of a raw Swift
error string when the user's claude login expired.

* Replace SubscriptionClient with credential-store + service architecture

The previous SubscriptionClient never persisted refreshed access tokens, so
every 30s tick read the expired token from Keychain, refreshed it (1 call),
fetched usage with the new token (2nd call), and threw the new token away —
3 API calls per cycle, which burned through Anthropic's per-account rate
budget and produced the 429s and `invalid_grant` loops users were seeing.

The replacement mirrors CodexBar's proven pattern:

- ClaudeCredentialStore owns the credential lifecycle. Bootstrap is strictly
  user-initiated (Connect button in the Plan tab); the menubar does not touch
  Claude's keychain at startup. After bootstrap, refreshed tokens — including
  rotated refresh tokens — are persisted to a local cache file under
  ~/Library/Application Support/CodeBurn (mode 0600). Using a file instead of
  our own keychain item means rebuild signature changes don't trigger a
  startup keychain prompt; the only prompt the user ever sees is the one for
  Claude Code-credentials on Connect.

- ClaudeUsageFetcher (folded into the service) is a pure /api/oauth/usage
  call with one allowed 401-recovery roundtrip. 429s record an explicit
  backoff window honouring Retry-After.

- ClaudeSubscriptionService orchestrates bootstrap / refresh / disconnect,
  applies the 429 backoff, and surfaces terminal vs transient failures so
  the UI can show the right CTA.

- Reading Claude's keychain now tries the entry keyed by NSUserName() first
  and falls back to the unscoped query, so users who re-ran /login and ended
  up with two Claude Code-credentials items pick up the fresh one. This was
  the actual cause of "I logged in but the menubar still shows stale data".

User-facing additions:

- A proper Settings window (right-click → Settings…) with General / Claude /
  About tabs. Provider quota cadence is configurable (Manual / 1m / 2m / 5m /
  15m). New providers plug in as additional tabs.

- Plan tab: notBootstrapped → "Connect Claude subscription" CTA;
  terminalFailure → "Reconnect Claude" with the correct /login instruction
  for Claude Code 2.1; transientFailure preserves the last loaded view with
  a retrying badge.

- AgentTab quota bar slot is always reserved so chip height doesn't jitter
  when the user connects for the first time. Hover popover has 250ms enter
  / 150ms exit debounce so swiping across chips doesn't pop a popover for
  every chip touched.

- Disconnect requires confirmation, clears capacityEstimates and the
  subscription snapshot store so a reconnect under a different account
  doesn't surface "Based on last cycle" projections from the old account.

Validator findings applied: cadence anchor only updates on successful
refresh (not every attempt), refresh-token rotation persists in memory
before keychain write so a write failure doesn't lock the user out, server
error bodies are sanitized (token redaction + 240-char cap) before they
reach the UI or NSLog, and Refresh Now refreshes both the menubar payload
and quota.

* Add Codex live quota + multi-provider warning, with validator fixes

CodexCredentialStore reads ~/.codex/auth.json (ChatGPT-mode only) on
user-initiated Connect, caches under Application Support like Claude.
CodexSubscriptionService hits chatgpt.com/backend-api/wham/usage with
the bearer token + ChatGPT-Account-Id header, parses primary/secondary
windows, additional per-model rate limits (e.g. GPT-5.3-Codex-Spark),
and credits balance with a Double-or-String fallback.

Plan-tier enum captures the full ChatGPT plan list including prolite,
free_workspace, education, quorum, k12, plus an unknown(String) case
that preserves the raw plan name when OpenAI ships a tier we haven't
mapped yet.

Multi-provider warning system:
- Menubar flame tints from neutral to yellow (70%) → orange (90%) →
  red (100%) based on the worst-affected connected provider's worst
  window. Uses NSImage.SymbolConfiguration palette colors.
- Popover header gains a warning row when any provider is at 70%+.
  "Claude 79% of quota used", "Claude 79% · Codex 92%", or
  "Claude over limit (105%)" when severity hits .danger.
- Hover popover gains a plan-name badge in the top-right corner so
  users know which subscription is feeding the bar.
- Codex chip surfaces the credits balance and any non-zero per-model
  additional rate limits as footer rows.

Validator fixes applied in the same commit:

- Provider-specific reconnect / disconnected copy in QuotaDetailPopover
  (was hardcoded to Claude).
- Generation-token guard on refreshSubscriptionReportingSuccess and
  refreshCodexReportingSuccess so a Disconnect during an in-flight
  fetch can't resume after the await and re-populate the cleared state.
- Codex codexQuotaSummary promotes secondary to primary when only one
  window is returned, so free / guest tiers don't render an empty bar.
- Memory-cache TTL is now actually consulted in currentRecord (the
  isFresh check was dead code, leaving cached records valid forever).
- sanitizeForUI now redacts OpenAI sk-* keys, JWT tokens, and Bearer
  headers in addition to Claude sk-ant-*.
- Removed diagnostic NSLog that wrote raw chatgpt.com response bodies
  to the unified log.
- Codex Connect / Reconnect copy in Settings explains the auth.json
  prerequisite and the API-key vs ChatGPT-mode distinction.
- Disconnect dialogs now state explicitly that the auth.json /
  credentials keychain entry is left untouched.
- Plan badge in the popover gets line-limit + truncation + max-width
  so a long unknown plan name can't overflow the row.
- Renamed shadowing `let max` to `let worst` in aggregateQuotaStatus.

* Add Codex Plan tab + size plan badge to content

The Plan tab is now visible when the Codex chip is selected, mirroring
the Claude tab's deep view. CodexPlanInsight renders the user's plan
tier ("Pro Lite", "Plus", etc.), the primary and secondary rate-limit
windows with reset countdowns, and any non-zero per-model additional
limits (e.g. GPT-5.3-Codex-Spark) so power users see them.

The "On pace at reset" projection that Claude's Plan view shows is not
included here — that math feeds from local Claude per-message spend
extrapolated against API quota windows, and our local Codex spend is
not a 1:1 signal for the ChatGPT-subscription rate windows reported by
wham/usage. Wiring a Codex extrapolator is a follow-up.

Drop the maxWidth=90 frame on the plan badge in the hover popover. It
was stretching short labels like "Pro Lite" to fill the full 90pt slot;
fixedSize makes the badge hug the text. Plan names are bounded short
strings, so truncation is a non-issue in practice.
This commit is contained in:
Resham Joshi 2026-05-06 19:57:17 -07:00 committed by GitHub
parent afd0ee7011
commit efac2bfa15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2795 additions and 360 deletions

View file

@ -30,9 +30,19 @@ final class AppStore {
var lastError: String?
var subscription: SubscriptionUsage?
var subscriptionError: String?
var subscriptionLoadState: SubscriptionLoadState = .idle
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped
var capacityEstimates: [String: CapacityEstimate] = [:]
var codexUsage: CodexUsage?
var codexError: String?
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped
/// Generation tokens for the in-flight refresh tasks. Incremented on every
/// disconnect / reset so a fetch that started before the disconnect cannot
/// resume after the await and re-populate the freshly-cleared state.
private var claudeRefreshGen: Int = 0
private var codexRefreshGen: Int = 0
private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var cacheDate: String = ""
private var switchTask: Task<Void, Never>?
@ -189,28 +199,346 @@ final class AppStore {
}
}
/// Fetch Claude subscription usage. Sets subscription = nil on missing creds (API users / unauthenticated).
/// Triggered lazily when the user opens the Plan pill, so the Keychain prompt only fires on intent.
func refreshSubscription() async {
subscriptionLoadState = .loading
/// User-initiated. Reads Claude's source (this is what triggers the macOS keychain
/// prompt for `Claude Code-credentials`). Once successful, subsequent background
/// refreshes go through our own keychain item without prompting.
func bootstrapSubscription() async {
subscriptionLoadState = .bootstrapping
do {
let usage = try await SubscriptionClient.fetch()
let usage = try await ClaudeSubscriptionService.bootstrap()
subscription = usage
subscriptionError = nil
subscriptionLoadState = .loaded
await captureSnapshots(for: usage)
} catch SubscriptionError.noCredentials {
subscription = nil
subscriptionError = nil
subscriptionLoadState = .noCredentials
} catch let err as ClaudeSubscriptionService.FetchError {
applyFetchError(err)
} catch {
subscription = nil
subscriptionError = String(describing: error)
subscriptionLoadState = .failed
NSLog("CodeBurn: subscription fetch failed: \(error)")
}
}
/// Background refresh. No-op if the user has not yet connected. Never triggers
/// a keychain prompt uses our own keychain item exclusively.
func refreshSubscription() async {
_ = await refreshSubscriptionReportingSuccess()
}
/// Same as `refreshSubscription` but returns whether the fetch produced a
/// `.loaded` state, so the caller can anchor cadence timing on real success
/// rather than every attempt.
@discardableResult
func refreshSubscriptionReportingSuccess() async -> Bool {
guard ClaudeCredentialStore.isBootstrapCompleted else {
if subscriptionLoadState != .notBootstrapped {
subscriptionLoadState = .notBootstrapped
}
return false
}
let gen = claudeRefreshGen
if subscription == nil { subscriptionLoadState = .loading }
do {
guard let usage = try await ClaudeSubscriptionService.refreshIfBootstrapped() else {
return false
}
// Disconnect-during-fetch guard: if the user clicked Disconnect
// while we were awaiting Anthropic, the generation token will
// have advanced and we must drop this result instead of writing
// it back over the freshly-cleared state.
guard gen == claudeRefreshGen else { return false }
subscription = usage
subscriptionError = nil
subscriptionLoadState = .loaded
await captureSnapshots(for: usage)
return true
} catch let err as ClaudeSubscriptionService.FetchError {
guard gen == claudeRefreshGen else { return false }
applyFetchError(err)
return false
} catch {
guard gen == claudeRefreshGen else { return false }
subscriptionError = sanitizeForUI(String(describing: error))
subscriptionLoadState = .failed
return false
}
}
/// User-initiated disconnect clears our keychain item and bootstrap flag,
/// plus all derived state so a reconnect (potentially under a different
/// account or tier) starts clean. capacityEstimates and the snapshot store
/// would otherwise contaminate "Based on last cycle" projections.
func disconnectSubscription() {
ClaudeSubscriptionService.disconnect()
// Bump the generation token so any in-flight refreshSubscription that
// resumes after this point detects the disconnect and discards its
// result instead of re-populating the cleared state.
claudeRefreshGen &+= 1
subscription = nil
subscriptionError = nil
subscriptionLoadState = .notBootstrapped
capacityEstimates = [:]
Task.detached { await SubscriptionSnapshotStore.clearAll() }
// Notify the AppDelegate to clear its cadence-loop anchor so the next
// reconnect doesn't measure against a pre-disconnect timestamp.
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
}
// MARK: - Codex
func bootstrapCodex() async {
codexLoadState = .bootstrapping
do {
let usage = try await CodexSubscriptionService.bootstrap()
codexUsage = usage
codexError = nil
codexLoadState = .loaded
} catch let err as CodexSubscriptionService.FetchError {
applyCodexFetchError(err)
} catch {
codexError = sanitizeForUI(String(describing: error))
codexLoadState = .failed
}
}
func refreshCodex() async {
_ = await refreshCodexReportingSuccess()
}
@discardableResult
func refreshCodexReportingSuccess() async -> Bool {
guard CodexCredentialStore.isBootstrapCompleted else {
if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped }
return false
}
let gen = codexRefreshGen
if codexUsage == nil { codexLoadState = .loading }
do {
guard let usage = try await CodexSubscriptionService.refreshIfBootstrapped() else {
return false
}
guard gen == codexRefreshGen else { return false }
codexUsage = usage
codexError = nil
codexLoadState = .loaded
return true
} catch let err as CodexSubscriptionService.FetchError {
guard gen == codexRefreshGen else { return false }
applyCodexFetchError(err)
return false
} catch {
guard gen == codexRefreshGen else { return false }
codexError = sanitizeForUI(String(describing: error))
codexLoadState = .failed
return false
}
}
func disconnectCodex() {
CodexSubscriptionService.disconnect()
codexRefreshGen &+= 1
codexUsage = nil
codexError = nil
codexLoadState = .notBootstrapped
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
}
private func applyCodexFetchError(_ err: CodexSubscriptionService.FetchError) {
let sanitized = sanitizeForUI(err.errorDescription)
codexError = sanitized
if err.isTerminal {
codexLoadState = .terminalFailure(reason: sanitized)
} else if let retryAt = err.rateLimitRetryAt {
codexLoadState = .transientFailure(retryAt: retryAt)
} else if case .notBootstrapped = err {
codexLoadState = .notBootstrapped
} else if case let .bootstrapFailed(storeErr) = err, case .bootstrapNoSource = storeErr {
codexLoadState = .noCredentials
} else {
codexLoadState = .failed
}
}
private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) {
let sanitized = sanitizeForUI(err.errorDescription)
subscriptionError = sanitized
if err.isTerminal {
subscriptionLoadState = .terminalFailure(reason: sanitized)
} else if let retryAt = err.rateLimitRetryAt {
subscriptionLoadState = .transientFailure(retryAt: retryAt)
} else if case .notBootstrapped = err {
subscriptionLoadState = .notBootstrapped
} else if case let .bootstrapFailed(storeErr) = err, case .bootstrapNoSource = storeErr {
subscriptionLoadState = .noCredentials
} else {
subscriptionLoadState = .failed
}
}
/// Strip control characters and any token-shaped substrings from server-error
/// strings before they land in NSLog or the UI. Anthropic / OpenAI error
/// envelopes don't typically echo tokens, but we also surface this in
/// unified-log paths readable by other local users via `log stream`.
private func sanitizeForUI(_ s: String?) -> String? {
guard let s, !s.isEmpty else { return nil }
var cleaned = s.replacingOccurrences(of: "\u{0000}", with: "")
// Token-shaped redaction. Apply to all known auth-token formats so
// an error body that quotes the request/response token is masked.
let patterns: [(pattern: String, replacement: String)] = [
(#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"),
(#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"),
(#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"),
(#"(?i)Bearer\s+\S+"#, "Bearer ***"),
]
for entry in patterns {
cleaned = cleaned.replacingOccurrences(of: entry.pattern, with: entry.replacement, options: .regularExpression)
}
// Cap length so a runaway server body cannot fill stderr.
if cleaned.count > 240 { cleaned = String(cleaned.prefix(240)) + "" }
return cleaned
}
/// Snapshot of live quota state for a given provider. Returns nil when the user
/// has not connected yet the bar slot stays empty so we never trigger a
/// keychain prompt at startup. Once bootstrapped, the bar persists across all
/// subsequent states (loading / stale / transient failure / terminal failure)
/// so it doesn't flicker on every refresh tick.
/// Aggregate quota status across all connected providers, used by the menu
/// bar flame icon (color) and the popover warning row. Severity = worst
/// observed across any provider's worst window. Warning providers are
/// every connected provider at >= 70% utilization.
struct AggregateQuotaStatus {
let severity: QuotaSummary.Severity
let warnings: [(name: String, percent: Double)] // sorted desc by percent
}
var aggregateQuotaStatus: AggregateQuotaStatus {
var providers: [(name: String, percent: Double)] = []
if case .loaded = subscriptionLoadState, let usage = subscription {
let worst = [
usage.fiveHourPercent,
usage.sevenDayPercent,
usage.sevenDayOpusPercent,
usage.sevenDaySonnetPercent,
].compactMap { $0 }.max() ?? 0
if worst > 0 { providers.append(("Claude", worst)) }
}
if case .loaded = codexLoadState, let usage = codexUsage {
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
if worst > 0 { providers.append(("Codex", worst)) }
}
let worst = providers.map(\.percent).max() ?? 0
let severity = QuotaSummary.severity(for: worst / 100)
let sorted = providers.sorted { $0.percent > $1.percent }
let warnings = sorted.filter { $0.percent >= 70 }
return AggregateQuotaStatus(severity: severity, warnings: warnings)
}
func quotaSummary(for filter: ProviderFilter) -> QuotaSummary? {
switch filter {
case .claude: return claudeQuotaSummary(filter: filter)
case .codex: return codexQuotaSummary(filter: filter)
default: return nil
}
}
private func claudeQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
if case .notBootstrapped = subscriptionLoadState { return nil }
if case .bootstrapping = subscriptionLoadState { return nil }
if case .noCredentials = subscriptionLoadState { return nil }
let connection: QuotaSummary.Connection = {
switch subscriptionLoadState {
case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected
case .loading: return subscription == nil ? .loading : .stale
case .loaded: return .connected
case .failed: return subscription == nil ? .loading : .stale
case let .terminalFailure(reason): return .terminalFailure(reason: reason)
case .transientFailure: return .transientFailure
}
}()
var primary: QuotaSummary.Window?
var details: [QuotaSummary.Window] = []
if let usage = subscription {
if let pct = usage.fiveHourPercent {
details.append(.init(label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt))
}
if let pct = usage.sevenDayPercent {
let weekly = QuotaSummary.Window(label: "Weekly", percent: pct / 100, resetsAt: usage.sevenDayResetsAt)
primary = weekly
details.append(weekly)
}
if let pct = usage.sevenDayOpusPercent {
details.append(.init(label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt))
}
if let pct = usage.sevenDaySonnetPercent {
details.append(.init(label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt))
}
}
let plan = subscription?.tier.displayName
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: [])
}
private func codexQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
if case .notBootstrapped = codexLoadState { return nil }
if case .bootstrapping = codexLoadState { return nil }
if case .noCredentials = codexLoadState { return nil }
let connection: QuotaSummary.Connection = {
switch codexLoadState {
case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected
case .loading: return codexUsage == nil ? .loading : .stale
case .loaded: return .connected
case .failed: return codexUsage == nil ? .loading : .stale
case let .terminalFailure(reason): return .terminalFailure(reason: reason)
case .transientFailure: return .transientFailure
}
}()
var primary: QuotaSummary.Window?
var details: [QuotaSummary.Window] = []
if let usage = codexUsage {
if let w = usage.primary {
let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
primary = row
details.append(row)
}
if let w = usage.secondary {
let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
// Some Codex plans (free / guest tiers) only return a secondary
// window. Promote it to primary so the chip bar always has a
// data source instead of rendering as an empty track.
if primary == nil { primary = row }
details.append(row)
}
// Surface per-model additional rate limits (e.g. "GPT-5.3-Codex-Spark")
// only when the user has actually hit them. Skipping zero rows keeps
// the popover compact for the common case where the user only uses
// the main Codex window.
for extra in usage.additionalLimits {
if let p = extra.primary, p.usedPercent > 0 {
details.append(.init(label: "\(extra.name) · \(p.windowLabel)", percent: p.usedPercent / 100, resetsAt: p.resetsAt))
}
if let s = extra.secondary, s.usedPercent > 0 {
details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt))
}
}
}
let plan = codexUsage?.plan.displayName
var footerLines: [String] = []
if let balance = codexUsage?.creditsBalance, balance > 0 {
// Format as plain dollars; ChatGPT settles in USD regardless of
// the user's display-currency preference.
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "USD"
formatter.maximumFractionDigits = 2
let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)"
footerLines.append("Credits remaining · \(formatted)")
}
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines)
}
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
/// when the current window has just reset and projection from current data isn't meaningful.
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
@ -347,12 +675,19 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
}
}
extension Notification.Name {
static let codeBurnSubscriptionDisconnected = Notification.Name("com.codeburn.subscriptionDisconnected")
}
enum SubscriptionLoadState: Sendable, Equatable {
case idle // never tried, awaiting user intent
case loading // fetch in progress
case loaded // success; subscription is populated
case noCredentials // tried; user has no Claude OAuth (API user / not logged in)
case failed // tried; error occurred
case notBootstrapped // no Keychain access yet waiting for user to click Connect
case bootstrapping // user clicked Connect; reading Claude's keychain (PROMPTS)
case loading // background fetch in progress (subscription may already be populated)
case loaded // success; subscription is populated
case noCredentials // bootstrap tried; user has no Claude credentials at all
case failed // generic non-recoverable failure
case terminalFailure(reason: String?) // refresh-token invalid; user must reconnect
case transientFailure(retryAt: Date?) // 429 / network blip; backing off automatically
}
enum InsightMode: String, CaseIterable, Identifiable {

View file

@ -15,9 +15,12 @@ struct CodeBurnApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
// SwiftUI App needs at least one scene. Settings is invisible by default.
// The Settings scene gives us a real macOS Settings window with the
// standard , shortcut and the menubar "Settings" item. Provider tabs
// (Claude today, Codex/Cursor/etc. in follow-ups) live inside SettingsView.
Settings {
EmptyView()
SettingsView()
.environment(delegate.store)
}
}
}
@ -26,7 +29,7 @@ struct CodeBurnApp: App {
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
private let store = AppStore()
fileprivate let store = AppStore()
let updateChecker = UpdateChecker()
/// Held for the lifetime of the app to opt out of App Nap and Automatic Termination.
private var backgroundActivity: NSObjectProtocol?
@ -40,6 +43,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
NSApp.setActivationPolicy(.accessory)
}
private func observeSubscriptionDisconnect() {
NotificationCenter.default.addObserver(
forName: .codeBurnSubscriptionDisconnected,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.resetSubscriptionCadenceAnchor()
}
}
}
func applicationDidFinishLaunching(_ notification: Notification) {
ProcessInfo.processInfo.automaticTerminationSupportEnabled = false
ProcessInfo.processInfo.disableSuddenTermination()
@ -57,6 +72,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
setupDistributedNotificationListener()
installLaunchAgentIfNeeded()
registerLoginItemIfNeeded()
observeSubscriptionDisconnect()
Task { await updateChecker.checkIfNeeded() }
}
@ -233,9 +249,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}
fileprivate var lastSubscriptionRefreshAt: Date?
private func startRefreshLoop() {
refreshLoopTask?.cancel()
refreshLoopTask = Task { [weak self] in
// Provider refreshes only run when the user has explicitly connected.
// Each refresh is a no-op until its corresponding bootstrap flag is set.
if let self {
async let claude = self.store.refreshSubscriptionReportingSuccess()
async let codex = self.store.refreshCodexReportingSuccess()
if await claude { self.lastSubscriptionRefreshAt = Date() }
if await codex { self.lastCodexRefreshAt = Date() }
}
while !Task.isCancelled {
guard let self else { return }
// Skip the loop's tick if a wake / manual / distributed-
@ -251,11 +277,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
self.lastRefreshTime = Date()
self.refreshStatusButton()
}
// Cadence-driven live-quota refresh, anchored on LAST SUCCESS
// (not last attempt) so an intermittent failure doesn't reset
// the timer. Each provider has its own anchor so a Codex 429
// doesn't delay a due Claude refresh.
let cadence = SubscriptionRefreshCadence.current
if cadence != .manual {
let claudeElapsed = Date().timeIntervalSince(self.lastSubscriptionRefreshAt ?? .distantPast)
if claudeElapsed >= TimeInterval(cadence.rawValue) {
let succeeded = await self.store.refreshSubscriptionReportingSuccess()
if succeeded { self.lastSubscriptionRefreshAt = Date() }
}
let codexElapsed = Date().timeIntervalSince(self.lastCodexRefreshAt ?? .distantPast)
if codexElapsed >= TimeInterval(cadence.rawValue) {
let succeeded = await self.store.refreshCodexReportingSuccess()
if succeeded { self.lastCodexRefreshAt = Date() }
}
}
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
}
}
}
fileprivate var lastCodexRefreshAt: Date?
@MainActor
func refreshSubscriptionNow() {
Task { [weak self] in
guard let self else { return }
// "Refresh Now" should refresh the menubar payload AND every
// connected provider's live quota the user's intent is "make
// this match reality right now."
async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true)
async let claude: Bool = self.store.refreshSubscriptionReportingSuccess()
async let codex: Bool = self.store.refreshCodexReportingSuccess()
_ = await payload
if await claude { self.lastSubscriptionRefreshAt = Date() }
if await codex { self.lastCodexRefreshAt = Date() }
}
}
/// Reset the cadence anchor so the next loop tick re-evaluates from "now"
/// rather than measuring against a timestamp from the previous connection.
/// Triggered on disconnect of any provider the cost of clearing both
/// anchors is one extra refresh tick on the unaffected provider, far less
/// disruptive than waiting a full cadence after a reconnect.
@MainActor
func resetSubscriptionCadenceAnchor() {
lastSubscriptionRefreshAt = nil
lastCodexRefreshAt = nil
}
private func observeStore() {
// Read closure uses [weak self] so the implicit self capture from
// accessing store.* doesn't pin self for the lifetime of an
@ -270,6 +342,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// Track currency so the menubar title catches up immediately on
// currency switch instead of waiting for the next 30s payload tick.
_ = self.store.currency
// Track the live-quota state too so the flame icon re-tints on
// every subscription / codex usage update, not just every 30s.
_ = self.store.subscription
_ = self.store.subscriptionLoadState
_ = self.store.codexUsage
_ = self.store.codexLoadState
} onChange: { [weak self] in
DispatchQueue.main.async {
guard let self else { return }
@ -319,6 +397,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
/// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge
/// of the status item, the title starts at its own baseline), so we inline both so they
/// flow as one typographic unit with a single, controllable gap.
private static func flameTint(for severity: QuotaSummary.Severity) -> NSColor? {
switch severity {
case .normal: return nil // template, auto-adapt
case .warning: return NSColor.systemYellow // 70-90%
case .critical: return NSColor.systemOrange // 90-100%
case .danger: return NSColor.systemRed // 100%+
}
}
private func refreshStatusButton() {
guard let button = statusItem.button else { return }
// Skip while the popover is anchored to this button. Rewriting the
@ -334,10 +421,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
button.imagePosition = .noImage
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium)
let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
let baseConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
// Tint the flame based on the worst-affected connected provider's quota.
// Normal (<70%) keeps the template (auto white-on-dark / black-on-light);
// warning/critical/danger override with a fixed palette color so the
// user gets a glanceable signal even when the menu bar is busy.
let aggregate = store.aggregateQuotaStatus
let tint = Self.flameTint(for: aggregate.severity)
let flameConfig: NSImage.SymbolConfiguration
if let tint {
flameConfig = baseConfig.applying(.init(paletteColors: [tint]))
} else {
flameConfig = baseConfig
}
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
.withSymbolConfiguration(flameConfig)
flame?.isTemplate = true
flame?.isTemplate = (tint == nil)
let attachment = NSTextAttachment()
attachment.image = flame
@ -393,14 +492,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
if popover.isShown {
popover.performClose(sender)
} else {
NSApp.activate(ignoringOtherApps: true)
// Do NOT call NSApp.activate(ignoringOtherApps:) here. On macOS
// Tahoe an accessory app activating while a popover anchors to
// its NSStatusItem can race with the system menu bar's auto-hide
// logic and leave the user's apple-menu hidden until the popover
// closes. The popover's window takes keyboard focus on its own
// via makeKeyAndOrderFront, which is enough for keystrokes to
// reach the SwiftUI content.
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
if let window = popover.contentViewController?.view.window {
// Pin the popover's window above the status-bar layer but tag
// it as auxiliary so macOS Tahoe does not treat it as an
// app-level focus event that's what was hiding the system
// menu bar (Terminal's apple-logo / Shell / Edit / View row)
// every time the popover opened.
window.level = .statusBar
window.collectionBehavior.insert(.fullScreenAuxiliary)
window.collectionBehavior.insert(.canJoinAllSpaces)
window.makeKeyAndOrderFront(nil)
}
}
}
private func showContextMenu(from button: NSStatusBarButton) {
let menu = NSMenu()
let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(settingsItem)
let refreshNow = NSMenuItem(title: "Refresh Now", action: #selector(refreshNowAction), keyEquivalent: "r")
refreshNow.target = self
menu.addItem(refreshNow)
menu.addItem(.separator())
let updateItem = NSMenuItem(title: "Check for Updates", action: #selector(checkForUpdates), keyEquivalent: "")
updateItem.target = self
menu.addItem(updateItem)
@ -408,11 +533,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let quitItem = NSMenuItem(title: "Quit CodeBurn", action: #selector(quitApp), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
statusItem.menu = menu
button.performClick(nil)
statusItem.menu = nil
}
private var settingsWindowController: NSWindowController?
@objc private func openSettings() {
// Accessory-policy apps (no Dock icon, no main menu) don't get the
// SwiftUI Settings scene wired into the responder chain reliably, so
// the standard `showSettingsWindow:` selector silently no-ops. We host
// the SwiftUI view in our own NSWindowController instead.
if let controller = settingsWindowController {
NSApp.activate(ignoringOtherApps: true)
controller.window?.makeKeyAndOrderFront(nil)
return
}
let hosting = NSHostingController(
rootView: SettingsView().environment(store)
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 380),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.title = "CodeBurn Settings"
window.contentViewController = hosting
window.center()
window.isReleasedWhenClosed = false
let controller = NSWindowController(window: window)
settingsWindowController = controller
NSApp.activate(ignoringOtherApps: true)
controller.showWindow(nil)
}
@objc private func refreshNowAction() {
refreshSubscriptionNow()
}
private func codeburnAlertIcon() -> NSImage? {
let config = NSImage.SymbolConfiguration(pointSize: 32, weight: .medium)
guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?

View file

@ -0,0 +1,398 @@
import Foundation
import Security
/// Owns the lifecycle of Claude OAuth credentials end-to-end. Replaces
/// SubscriptionClient + SubscriptionRefreshGate with a model that mirrors
/// CodexBar's proven pattern:
///
/// 1. **Bootstrap is user-initiated.** The first read of Claude's keychain
/// entry which triggers a macOS keychain prompt only happens when
/// the user clicks "Connect" in the Plan tab. The menubar does not
/// touch Claude's keychain on launch.
///
/// 2. **We persist refreshed tokens.** When Anthropic returns a new access
/// token (or a rotated refresh token) we write it back to our own keychain
/// item. The next fetch uses it directly one API call per cycle, not
/// three. This was the root cause of "connect once, never updates": the
/// previous code refreshed on every tick because the new token was
/// thrown away.
///
/// 3. **Our own keychain item, not Claude's.** We bootstrap from Claude's
/// entry once, then maintain `com.codeburn.menubar.claude.oauth.v1` in
/// the user's keychain. Subsequent reads do not prompt because we own
/// that item's ACL.
///
/// 4. **In-memory cache (5 min)** so back-to-back reads in the same refresh
/// cycle don't even hit the keychain.
enum ClaudeCredentialStore {
private static let bootstrapCompletedKey = "codeburn.claude.bootstrapCompleted"
private static let inMemoryTTL: TimeInterval = 5 * 60
private static let proactiveRefreshMargin: TimeInterval = 5 * 60
private static let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private static let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")!
private static let claudeKeychainService = "Claude Code-credentials"
private static let credentialsRelativePath = ".claude/.credentials.json"
private static let maxCredentialBytes = 64 * 1024
/// Local cache file. Stored under Application Support with 0600 permissions
/// so only the current user can read it. We deliberately do NOT use the
/// macOS Keychain for our own cache: keychain ACLs are bound to the binary
/// code signature, so reading our own item triggers a prompt every time the
/// binary changes (debug rebuilds, app updates with re-signing). Putting the
/// cache in a plain file means the only Keychain prompt our user ever sees
/// is the initial Connect read of Claude Code's own keychain entry.
/// Threat model: same as ~/.claude/.credentials.json (also plaintext).
private static let cacheFilename = "claude-credentials.v1.json"
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
struct CachedRecord {
let record: CredentialRecord
let cachedAt: Date
var isFresh: Bool { Date().timeIntervalSince(cachedAt) < ClaudeCredentialStore.inMemoryTTL }
}
struct CredentialRecord: Codable, Equatable {
let accessToken: String
let refreshToken: String?
let expiresAt: Date?
let rateLimitTier: String?
}
enum StoreError: Error, LocalizedError {
case bootstrapNoSource // neither file nor Claude keychain has credentials
case bootstrapDecodeFailed
case keychainWriteFailed(OSStatus)
case keychainReadFailed(OSStatus)
case refreshHTTPError(Int, String?)
case refreshNetworkError(Error)
case refreshDecodeFailed
case noRefreshToken
var errorDescription: String? {
switch self {
case .bootstrapNoSource:
return "No Claude credentials found. Sign in with `claude` first."
case .bootstrapDecodeFailed:
return "Claude credentials are malformed."
case let .keychainWriteFailed(status):
return "Could not write to keychain (status \(status))."
case let .keychainReadFailed(status):
return "Could not read from keychain (status \(status))."
case let .refreshHTTPError(code, body):
return "Token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case let .refreshNetworkError(err):
return "Token refresh network error: \(err.localizedDescription)"
case .refreshDecodeFailed:
return "Token refresh response was malformed."
case .noRefreshToken:
return "No refresh token available; reconnect required."
}
}
/// True when the failure means the user must re-authenticate (re-run
/// `claude` or click Reconnect). Used by the UI to distinguish between
/// "try again later" and "you must act".
var isTerminal: Bool {
if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 {
let lower = body?.lowercased() ?? ""
if lower.contains("invalid_grant") || lower.contains("invalid_client") || lower.contains("invalid_token") {
return true
}
return true // 4xx other than rate-limiting is terminal too
}
if case .noRefreshToken = self { return true }
return false
}
}
// MARK: - Bootstrap state
/// True once the user has explicitly connected (clicked Connect in the Plan
/// tab AND we successfully read their credentials). Persists across launches.
static var isBootstrapCompleted: Bool {
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
}
/// Reset bootstrap state. Used when the user explicitly wants to disconnect
/// or when the refresh token has been revoked terminally.
static func resetBootstrap() {
lock.withLock { memoryCache = nil }
deleteOurCache()
isBootstrapCompleted = false
}
// MARK: - Public API
/// User-initiated entry point. Reads from Claude's source (PROMPTS for the
/// keychain on first use), writes to our own keychain item, marks bootstrap
/// as completed.
@discardableResult
static func bootstrap() throws -> CredentialRecord {
let record = try readClaudeSource()
try writeOurCache(record: record)
isBootstrapCompleted = true
cacheInMemory(record)
return record
}
/// Silent read for background refresh cycles. Reads only from our cache /
/// keychain item never prompts. Returns nil if not bootstrapped.
static func currentRecord() throws -> CredentialRecord? {
guard isBootstrapCompleted else { return nil }
// Honour the in-memory TTL: a stale cached record can mask a token
// that another process (e.g. claude /login again) has just rotated
// on disk. Re-read the file when the cache passes the TTL.
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
return cached.record
}
if let stored = try readOurCache() {
cacheInMemory(stored)
return stored
}
// Bootstrap flag is set but our cache file is missing most likely
// a fresh install resetting state, or the user manually deleted the
// file. Force re-bootstrap on next user action.
isBootstrapCompleted = false
return nil
}
/// Returns a token guaranteed to be either fresh or just-refreshed. If the
/// current token expires within `proactiveRefreshMargin`, refreshes ahead
/// of time and persists the new token.
static func freshAccessToken() async throws -> String? {
guard let record = try currentRecord() else { return nil }
if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin {
let updated = try await refreshAndPersist(record: record)
return updated.accessToken
}
return record.accessToken
}
/// Called after an explicit 401. Refreshes, persists, returns the new token.
static func refreshAfter401() async throws -> String {
guard let record = try currentRecord() else { throw StoreError.noRefreshToken }
let updated = try await refreshAndPersist(record: record)
return updated.accessToken
}
static func subscriptionTier() throws -> String? {
try currentRecord()?.rateLimitTier
}
// MARK: - Bootstrap source
private static func readClaudeSource() throws -> CredentialRecord {
if let fromFile = try? readClaudeFile() { return fromFile }
if let fromKeychain = try readClaudeKeychain() { return fromKeychain }
throw StoreError.bootstrapNoSource
}
private static func readClaudeFile() throws -> CredentialRecord? {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
return try parseClaudeBlob(data: sanitizeClaudeBlob(data))
}
/// Reads Claude's keychain credentials. The CLI has historically written
/// entries under different account names older versions used "agentseal"
/// (a hardcoded company-style identifier) while Claude Code 2.1.x writes
/// under `$USER` (NSUserName()). After a user re-runs `/login`, both
/// entries can coexist and `SecItemCopyMatching` with kSecMatchLimitOne
/// often returns the older stale one. We try the user-keyed entry first
/// (the modern format), then fall back to the unscoped query for older
/// installations.
private static func readClaudeKeychain() throws -> CredentialRecord? {
if let record = try readClaudeKeychain(account: NSUserName()) {
return record
}
return try readClaudeKeychain(account: nil)
}
private static func readClaudeKeychain(account: String?) throws -> CredentialRecord? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: claudeKeychainService,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
if let account { query[kSecAttrAccount as String] = account }
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw StoreError.keychainReadFailed(status)
}
return try parseClaudeBlob(data: sanitizeClaudeBlob(data))
}
/// Claude Code's keychain writer line-wraps long values (newline + leading
/// spaces) mid-token, producing JSON with literal control chars inside string
/// values. Strip those plus pretty-print indentation between fields so the
/// JSON parser succeeds.
private static func sanitizeClaudeBlob(_ data: Data) -> Data {
guard var s = String(data: data, encoding: .utf8) else { return data }
s = s.replacingOccurrences(of: "\r", with: "")
if let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: []) {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
s = regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "")
}
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
return s.data(using: .utf8) ?? data
}
private static func parseClaudeBlob(data: Data) throws -> CredentialRecord {
struct Root: Decodable { let claudeAiOauth: OAuth? }
struct OAuth: Decodable {
let accessToken: String?
let refreshToken: String?
let expiresAt: Double?
let rateLimitTier: String?
}
do {
let root = try JSONDecoder().decode(Root.self, from: data)
guard let oauth = root.claudeAiOauth,
let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
else { throw StoreError.bootstrapDecodeFailed }
return CredentialRecord(
accessToken: token,
refreshToken: oauth.refreshToken,
expiresAt: oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) },
rateLimitTier: oauth.rateLimitTier
)
} catch {
throw StoreError.bootstrapDecodeFailed
}
}
// MARK: - Local cache file (no keychain involvement)
private static func cacheFileURL() -> URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return support
.appendingPathComponent("CodeBurn", isDirectory: true)
.appendingPathComponent(cacheFilename)
}
private static func readOurCache() throws -> CredentialRecord? {
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try Data(contentsOf: url)
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
}
private static func writeOurCache(record: CredentialRecord) throws {
let url = cacheFileURL()
let dir = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
let data = try JSONEncoder().encode(record)
// Atomic temp-rename so a crash mid-write cannot leave a half-file.
let tmp = url.appendingPathExtension("tmp-\(UUID().uuidString.prefix(8))")
try data.write(to: tmp)
// 0600 owner read/write only. Mirrors ~/.claude/.credentials.json's
// permission posture; nothing extra to protect since this is just a
// cached copy of credentials the user already has on disk in cleartext.
try? FileManager.default.setAttributes([.posixPermissions: NSNumber(value: Int16(0o600))], ofItemAtPath: tmp.path)
if FileManager.default.fileExists(atPath: url.path) {
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
} else {
try FileManager.default.moveItem(at: tmp, to: url)
}
}
private static func deleteOurCache() {
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func cacheInMemory(_ record: CredentialRecord) {
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
}
// MARK: - Refresh
private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord {
guard let refreshToken = record.refreshToken, !refreshToken.isEmpty else {
throw StoreError.noRefreshToken
}
var request = URLRequest(url: refreshURL)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "refresh_token", value: refreshToken),
URLQueryItem(name: "client_id", value: oauthClientID),
]
request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8)
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw StoreError.refreshNetworkError(error)
}
guard let http = response as? HTTPURLResponse else {
throw StoreError.refreshHTTPError(-1, nil)
}
guard http.statusCode == 200 else {
let body = String(data: data, encoding: .utf8)
throw StoreError.refreshHTTPError(http.statusCode, body)
}
struct RefreshResponse: Decodable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
}
}
guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else {
throw StoreError.refreshDecodeFailed
}
// Anthropic may rotate the refresh token. If it did, the OLD one is
// already invalid server-side discarding the new one would lock
// the user out permanently. So we cache the new record in memory
// BEFORE attempting the keychain write, and if the write fails we
// still return the new record (memory cache will serve subsequent
// calls inside the 5-min TTL while we keep retrying the persist).
let updated = CredentialRecord(
accessToken: decoded.accessToken,
refreshToken: decoded.refreshToken ?? record.refreshToken,
expiresAt: decoded.expiresIn.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt,
rateLimitTier: record.rateLimitTier
)
cacheInMemory(updated)
do {
try writeOurCache(record: updated)
} catch {
// Best effort surface to logs but do not abandon the rotated
// token. Next refresh will retry persistence; UI will continue
// working from the in-memory cache.
NSLog("CodeBurn: cache write failed during refresh rotation: %@", String(describing: error))
}
return updated
}
}
private extension NSLock {
func withLock<T>(_ body: () throws -> T) rethrows -> T {
lock(); defer { unlock() }
return try body()
}
}

View file

@ -0,0 +1,234 @@
import Foundation
/// Orchestrates "given a credential record, fetch live quota from Anthropic
/// and surface a result the UI can render". All token persistence lives in
/// `ClaudeCredentialStore`; the only state this service holds is the
/// 429 backoff window for the usage endpoint.
enum ClaudeSubscriptionService {
private static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
private static let betaHeader = "oauth-2025-04-20"
private static let userAgent = "claude-code/2.1.0"
private static let usageBlockedUntilKey = "codeburn.claude.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case notBootstrapped
case bootstrapFailed(ClaudeCredentialStore.StoreError)
case rateLimited(retryAt: Date)
case usageHTTPError(Int, String?)
case usageDecodeFailed
case network(Error)
case credential(ClaudeCredentialStore.StoreError)
var errorDescription: String? {
switch self {
case .notBootstrapped:
return "Connect Claude in the Plan tab to start tracking quota."
case let .bootstrapFailed(err):
return err.errorDescription
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "Anthropic rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code, body):
return "Quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case .usageDecodeFailed:
return "Quota response was malformed."
case let .network(err):
return "Network error: \(err.localizedDescription)"
case let .credential(err):
return err.errorDescription
}
}
/// True when the user must take action (re-run claude/login or click
/// Reconnect). Drives the red "Reconnect" UI path.
var isTerminal: Bool {
if case let .credential(err) = self { return err.isTerminal }
if case let .bootstrapFailed(err) = self { return err.isTerminal }
return false
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
// MARK: - Public API
/// User-initiated. Reads Claude's keychain (PROMPTS), copies to our keychain,
/// then fetches usage. Idempotent safe to call again to "reconnect".
static func bootstrap() async throws -> SubscriptionUsage {
let record: ClaudeCredentialStore.CredentialRecord
do {
record = try ClaudeCredentialStore.bootstrap()
} catch let err as ClaudeCredentialStore.StoreError {
throw FetchError.bootstrapFailed(err)
}
return try await fetchWithRecord(initial: record)
}
/// Background refresh. Never prompts. Returns nil if not yet bootstrapped.
static func refreshIfBootstrapped() async throws -> SubscriptionUsage? {
guard ClaudeCredentialStore.isBootstrapCompleted else {
return nil
}
// Honour an outstanding rate-limit window we recorded a 429 recently
// and Anthropic told us when to come back.
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
do {
let token = try await ClaudeCredentialStore.freshAccessToken()
guard let token else { throw FetchError.notBootstrapped }
return try await fetch(token: token, allowOne401Recovery: true)
} catch let err as ClaudeCredentialStore.StoreError {
throw FetchError.credential(err)
} catch let err as FetchError {
throw err
}
}
/// Reset everything used on user-initiated disconnect.
static func disconnect() {
ClaudeCredentialStore.resetBootstrap()
clearUsageBlock()
}
// MARK: - Internal
private static func fetchWithRecord(initial record: ClaudeCredentialStore.CredentialRecord) async throws -> SubscriptionUsage {
do {
return try await fetch(token: record.accessToken, allowOne401Recovery: true)
} catch let err as FetchError {
throw err
} catch let err as ClaudeCredentialStore.StoreError {
throw FetchError.credential(err)
}
}
private static func fetch(token: String, allowOne401Recovery: Bool) async throws -> SubscriptionUsage {
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw FetchError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1, nil)
}
switch http.statusCode {
case 200:
clearUsageBlock()
do {
let decoded = try JSONDecoder().decode(UsageResponse.self, from: data)
let tier = try ClaudeCredentialStore.subscriptionTier()
return mapResponse(decoded, rawTier: tier)
} catch {
throw FetchError.usageDecodeFailed
}
case 401:
if allowOne401Recovery {
let newToken = try await ClaudeCredentialStore.refreshAfter401()
return try await fetch(token: newToken, allowOne401Recovery: false)
}
throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8))
case 429:
let body = String(data: data, encoding: .utf8)
let retryAfter = parseRetryAfter(body: body)
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
throw FetchError.rateLimited(retryAt: until)
default:
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
}
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
@discardableResult
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
private static func parseRetryAfter(body: String?) -> Int? {
guard let body, let data = body.data(using: .utf8) else { return nil }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let n = json["retry_after"] as? Int { return n }
if let s = json["retry_after"] as? String, let n = Int(s) { return n }
}
return nil
}
// MARK: - Response mapping
private struct UsageResponse: Decodable {
let fiveHour: Window?
let sevenDay: Window?
let sevenDayOpus: Window?
let sevenDaySonnet: Window?
enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case sevenDayOpus = "seven_day_opus"
case sevenDaySonnet = "seven_day_sonnet"
}
}
private struct Window: Decodable {
let utilization: Double?
let resetsAt: String?
enum CodingKeys: String, CodingKey {
case utilization
case resetsAt = "resets_at"
}
}
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
SubscriptionUsage(
tier: SubscriptionUsage.tier(from: rawTier),
rawTier: rawTier,
fiveHourPercent: r.fiveHour?.utilization,
fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
sevenDayPercent: r.sevenDay?.utilization,
sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
sevenDayOpusPercent: r.sevenDayOpus?.utilization,
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
fetchedAt: Date()
)
}
private static func parseDate(_ s: String?) -> Date? {
guard let s, !s.isEmpty else { return nil }
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = f.date(from: s) { return d }
f.formatOptions = [.withInternetDateTime]
return f.date(from: s)
}
}

View file

@ -0,0 +1,291 @@
import Foundation
/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors
/// ClaudeCredentialStore but reads from ~/.codex/auth.json Codex CLI
/// already stores its tokens as plaintext JSON in the home directory, so
/// no keychain prompt is involved on bootstrap. After the user clicks
/// Connect we cache a copy under ~/Library/Application Support/CodeBurn so
/// we keep using rotated tokens after refresh.
enum CodexCredentialStore {
private static let bootstrapCompletedKey = "codeburn.codex.bootstrapCompleted"
private static let inMemoryTTL: TimeInterval = 5 * 60
private static let proactiveRefreshMargin: TimeInterval = 5 * 60
private static let oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
private static let refreshURL = URL(string: "https://auth.openai.com/oauth/token")!
private static let codexAuthPath = ".codex/auth.json"
private static let maxCredentialBytes = 64 * 1024
private static let cacheFilename = "codex-credentials.v1.json"
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
struct CachedRecord {
let record: CredentialRecord
let cachedAt: Date
var isFresh: Bool { Date().timeIntervalSince(cachedAt) < CodexCredentialStore.inMemoryTTL }
}
struct CredentialRecord: Codable, Equatable {
let accessToken: String
let refreshToken: String
let idToken: String?
let accountId: String?
let expiresAt: Date?
}
enum StoreError: Error, LocalizedError {
case bootstrapNoSource
case bootstrapDecodeFailed
case bootstrapNotChatGPT // user is on API-key mode; we need ChatGPT mode for quota
case fileWriteFailed(String)
case refreshHTTPError(Int, String?)
case refreshNetworkError(Error)
case refreshDecodeFailed
case noRefreshToken
var errorDescription: String? {
switch self {
case .bootstrapNoSource:
return "No Codex credentials found at ~/.codex/auth.json. Run `codex` to sign in."
case .bootstrapDecodeFailed:
return "Codex credentials are malformed."
case .bootstrapNotChatGPT:
return "Codex is in API-key mode; live quota tracking is only available for ChatGPT subscriptions."
case let .fileWriteFailed(message):
return "Could not write to local cache: \(message)"
case let .refreshHTTPError(code, body):
return "Codex token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case let .refreshNetworkError(err):
return "Codex token refresh network error: \(err.localizedDescription)"
case .refreshDecodeFailed:
return "Codex token refresh response was malformed."
case .noRefreshToken:
return "No refresh token available; reconnect required."
}
}
/// True when the user must take action: rerun `codex` to re-authenticate
/// or switch from API-key to ChatGPT mode. Drives the red Reconnect path.
var isTerminal: Bool {
if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 {
let lower = body?.lowercased() ?? ""
if lower.contains("refresh_token_expired") ||
lower.contains("refresh_token_reused") ||
lower.contains("refresh_token_invalidated") ||
lower.contains("invalid_grant")
{
return true
}
return true
}
switch self {
case .noRefreshToken, .bootstrapNotChatGPT, .bootstrapNoSource: return true
default: return false
}
}
}
// MARK: - Bootstrap state
static var isBootstrapCompleted: Bool {
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
}
static func resetBootstrap() {
lock.withLock { memoryCache = nil }
deleteOurCache()
isBootstrapCompleted = false
}
// MARK: - Public API
@discardableResult
static func bootstrap() throws -> CredentialRecord {
let record = try readCodexAuth()
try writeOurCache(record: record)
isBootstrapCompleted = true
cacheInMemory(record)
return record
}
static func currentRecord() throws -> CredentialRecord? {
guard isBootstrapCompleted else { return nil }
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
return cached.record
}
if let stored = try readOurCache() {
cacheInMemory(stored)
return stored
}
isBootstrapCompleted = false
return nil
}
static func freshAccessToken() async throws -> String? {
guard let record = try currentRecord() else { return nil }
if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin {
let updated = try await refreshAndPersist(record: record)
return updated.accessToken
}
return record.accessToken
}
static func refreshAfter401() async throws -> String {
guard let record = try currentRecord() else { throw StoreError.noRefreshToken }
let updated = try await refreshAndPersist(record: record)
return updated.accessToken
}
// MARK: - Bootstrap source: ~/.codex/auth.json
private static func readCodexAuth() throws -> CredentialRecord {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath)
guard FileManager.default.fileExists(atPath: url.path) else {
throw StoreError.bootstrapNoSource
}
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
struct Root: Decodable {
let auth_mode: String?
let tokens: Tokens?
}
struct Tokens: Decodable {
let access_token: String?
let refresh_token: String?
let id_token: String?
let account_id: String?
}
do {
let root = try JSONDecoder().decode(Root.self, from: data)
// Live quota is only meaningful for ChatGPT-mode auth. API-key users
// have a different billing surface (/v1/usage) which we do not yet
// implement here.
guard root.auth_mode == "chatgpt" else {
throw StoreError.bootstrapNotChatGPT
}
guard let tokens = root.tokens,
let access = tokens.access_token?.trimmingCharacters(in: .whitespacesAndNewlines),
let refresh = tokens.refresh_token?.trimmingCharacters(in: .whitespacesAndNewlines),
!access.isEmpty, !refresh.isEmpty
else {
throw StoreError.bootstrapDecodeFailed
}
return CredentialRecord(
accessToken: access,
refreshToken: refresh,
idToken: tokens.id_token,
accountId: tokens.account_id,
expiresAt: nil // Codex CLI does not record expiresAt in auth.json
)
} catch let err as StoreError {
throw err
} catch {
throw StoreError.bootstrapDecodeFailed
}
}
// MARK: - Local cache file
private static func cacheFileURL() -> URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return support
.appendingPathComponent("CodeBurn", isDirectory: true)
.appendingPathComponent(cacheFilename)
}
private static func readOurCache() throws -> CredentialRecord? {
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try Data(contentsOf: url)
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
}
private static func writeOurCache(record: CredentialRecord) throws {
let url = cacheFileURL()
let dir = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
let data = try JSONEncoder().encode(record)
let tmp = url.appendingPathExtension("tmp-\(UUID().uuidString.prefix(8))")
do {
try data.write(to: tmp)
try? FileManager.default.setAttributes([.posixPermissions: NSNumber(value: Int16(0o600))], ofItemAtPath: tmp.path)
if FileManager.default.fileExists(atPath: url.path) {
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
} else {
try FileManager.default.moveItem(at: tmp, to: url)
}
} catch {
throw StoreError.fileWriteFailed(String(describing: error))
}
}
private static func deleteOurCache() {
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func cacheInMemory(_ record: CredentialRecord) {
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
}
// MARK: - Refresh
private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord {
guard !record.refreshToken.isEmpty else { throw StoreError.noRefreshToken }
var request = URLRequest(url: refreshURL)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"client_id": oauthClientID,
"grant_type": "refresh_token",
"refresh_token": record.refreshToken,
"scope": "openid profile email",
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw StoreError.refreshNetworkError(error)
}
guard let http = response as? HTTPURLResponse else {
throw StoreError.refreshHTTPError(-1, nil)
}
guard http.statusCode == 200 else {
let body = String(data: data, encoding: .utf8)
throw StoreError.refreshHTTPError(http.statusCode, body)
}
struct RefreshResponse: Decodable {
let access_token: String
let refresh_token: String?
let id_token: String?
let expires_in: Int?
}
guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else {
throw StoreError.refreshDecodeFailed
}
let updated = CredentialRecord(
accessToken: decoded.access_token,
refreshToken: decoded.refresh_token ?? record.refreshToken,
idToken: decoded.id_token ?? record.idToken,
accountId: record.accountId,
expiresAt: decoded.expires_in.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt
)
cacheInMemory(updated)
do {
try writeOurCache(record: updated)
} catch {
NSLog("CodeBurn: codex cache write failed during refresh rotation: %@", String(describing: error))
}
return updated
}
}

View file

@ -0,0 +1,214 @@
import Foundation
/// Mirror of ClaudeSubscriptionService for Codex (ChatGPT-mode). Hits
/// /backend-api/wham/usage with the bearer token from CodexCredentialStore,
/// applies an independent 429 backoff, and surfaces terminal vs transient
/// failures to the UI.
enum CodexSubscriptionService {
private static let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
private static let usageBlockedUntilKey = "codeburn.codex.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case notBootstrapped
case bootstrapFailed(CodexCredentialStore.StoreError)
case rateLimited(retryAt: Date)
case usageHTTPError(Int, String?)
case usageDecodeFailed
case network(Error)
case credential(CodexCredentialStore.StoreError)
var errorDescription: String? {
switch self {
case .notBootstrapped:
return "Connect Codex in Settings to start tracking quota."
case let .bootstrapFailed(err): return err.errorDescription
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "ChatGPT rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code, body):
return "Codex quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case .usageDecodeFailed: return "Codex quota response was malformed."
case let .network(err): return "Network error: \(err.localizedDescription)"
case let .credential(err): return err.errorDescription
}
}
var isTerminal: Bool {
if case let .credential(err) = self { return err.isTerminal }
if case let .bootstrapFailed(err) = self { return err.isTerminal }
return false
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
static func bootstrap() async throws -> CodexUsage {
let record: CodexCredentialStore.CredentialRecord
do {
record = try CodexCredentialStore.bootstrap()
} catch let err as CodexCredentialStore.StoreError {
throw FetchError.bootstrapFailed(err)
}
return try await fetchWithToken(record.accessToken, allowOne401Recovery: true)
}
static func refreshIfBootstrapped() async throws -> CodexUsage? {
guard CodexCredentialStore.isBootstrapCompleted else { return nil }
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
do {
let token = try await CodexCredentialStore.freshAccessToken()
guard let token else { throw FetchError.notBootstrapped }
return try await fetchWithToken(token, allowOne401Recovery: true)
} catch let err as CodexCredentialStore.StoreError {
throw FetchError.credential(err)
}
}
static func disconnect() {
CodexCredentialStore.resetBootstrap()
clearUsageBlock()
}
private static func fetchWithToken(_ token: String, allowOne401Recovery: Bool) async throws -> CodexUsage {
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
// chatgpt.com routes the rate_limit envelope per ChatGPT account. Without
// this header the response often comes back as a guest-shape document
// missing rate_limit entirely, which our decoder then fails on.
if let accountId = try? CodexCredentialStore.currentRecord()?.accountId, !accountId.isEmpty {
request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw FetchError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1, nil)
}
switch http.statusCode {
case 200:
clearUsageBlock()
do {
return try decodeUsage(data: data)
} catch {
// Do not log the response body it's user-account data from
// chatgpt.com and is readable by other local users via
// `log stream`. The decode error type alone is enough to
// bisect schema drift if needed.
NSLog("CodeBurn: codex usage decode failed: %@", String(describing: error))
throw FetchError.usageDecodeFailed
}
case 401:
if allowOne401Recovery {
let newToken = try await CodexCredentialStore.refreshAfter401()
return try await fetchWithToken(newToken, allowOne401Recovery: false)
}
throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8))
case 429:
let until = recordUsageRateLimit(retryAfterSeconds: nil)
throw FetchError.rateLimited(retryAt: until)
default:
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
}
}
private struct UsageDTO: Decodable {
let plan_type: String?
let rate_limit: RateLimit?
let additional_rate_limits: [AdditionalLimitDTO]?
let credits: Credits?
struct RateLimit: Decodable {
let primary_window: WindowDTO?
let secondary_window: WindowDTO?
}
struct AdditionalLimitDTO: Decodable {
let limit_name: String?
let rate_limit: RateLimit?
}
struct WindowDTO: Decodable {
let used_percent: Double?
let reset_at: Int?
let limit_window_seconds: Int?
}
// chatgpt.com sometimes serializes balance as a Double ("balance": 0.0)
// and other times as a String ("balance": "0.00"). Mirror CodexBar's
// resilient decode so a schema drift on either shape doesn't blow up
// the whole quota fetch.
struct Credits: Decodable {
let balance: Double?
enum CodingKeys: String, CodingKey { case balance }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let n = try? c.decode(Double.self, forKey: .balance) {
balance = n
} else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) {
balance = n
} else {
balance = nil
}
}
}
}
private static func decodeUsage(data: Data) throws -> CodexUsage {
let root = try JSONDecoder().decode(UsageDTO.self, from: data)
let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in
guard let name = dto.limit_name, !name.isEmpty else { return nil }
return CodexUsage.AdditionalLimit(
name: name,
primary: makeWindow(dto.rate_limit?.primary_window),
secondary: makeWindow(dto.rate_limit?.secondary_window)
)
}
return CodexUsage(
plan: CodexUsage.planType(from: root.plan_type),
primary: makeWindow(root.rate_limit?.primary_window),
secondary: makeWindow(root.rate_limit?.secondary_window),
additionalLimits: additional,
creditsBalance: root.credits?.balance,
fetchedAt: Date()
)
}
private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? {
guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else {
return nil
}
let resetsAt = dto.reset_at.map { Date(timeIntervalSince1970: TimeInterval($0)) }
return CodexUsage.Window(usedPercent: used, resetsAt: resetsAt, limitWindowSeconds: windowSeconds)
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
@discardableResult
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
}

View file

@ -0,0 +1,98 @@
import Foundation
/// Codex (ChatGPT-mode) live quota snapshot returned by /backend-api/wham/usage.
/// Two windows are exposed: primary (typically the 5-hour rolling window) and
/// secondary (typically the weekly window). Window size is dynamic per
/// account `limitWindowSeconds` tells us whether it's a 5-hour or 7-day
/// boundary so we can label correctly.
struct CodexUsage: Sendable, Equatable {
enum PlanType: Sendable, Equatable {
case guest, free, go, plus, pro, prolite, freeWorkspace, team
case business, education, quorum, k12, enterprise, edu
/// Captures any plan_type string OpenAI ships that we haven't enumerated
/// yet, so the Settings/Plan UI can still show "Plan: <raw>" instead of
/// a generic "Subscription" placeholder. Preserves forward compatibility
/// without requiring a CodeBurn update for every new tier.
case unknown(String)
var displayName: String {
switch self {
case .guest: "Guest"
case .free: "Free"
case .go: "Go"
case .plus: "Plus"
case .pro: "Pro"
case .prolite: "Pro Lite"
case .freeWorkspace: "Free Workspace"
case .team: "Team"
case .business: "Business"
case .education: "Education"
case .quorum: "Quorum"
case .k12: "K-12"
case .enterprise: "Enterprise"
case .edu: "Edu"
case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized
}
}
}
struct Window: Sendable, Equatable {
let usedPercent: Double // 0.0 ... 100.0
let resetsAt: Date?
let limitWindowSeconds: Int
/// Human label inferred from window size: 5h, 1d, 7d, etc.
var windowLabel: String {
switch limitWindowSeconds {
case 0..<3600: return "Hourly"
case 3600..<7200: return "Hour"
case 18000..<19000: return "5-hour"
case 86400..<87000: return "Daily"
case 604800..<605000: return "Weekly"
default:
let hours = limitWindowSeconds / 3600
if hours < 24 { return "\(hours)-hour" }
return "\(hours / 24)-day"
}
}
}
/// Additional per-model / per-feature quotas exposed by ChatGPT alongside
/// the main rate_limit (e.g. "GPT-5.3-Codex-Spark"). Each entry has its
/// own primary/secondary windows. Only ones with non-zero utilization are
/// surfaced in the popover so users on plans that don't touch these
/// features don't see clutter.
struct AdditionalLimit: Sendable, Equatable {
let name: String
let primary: Window?
let secondary: Window?
}
let plan: PlanType
let primary: Window?
let secondary: Window?
let additionalLimits: [AdditionalLimit]
let creditsBalance: Double?
let fetchedAt: Date
static func planType(from raw: String?) -> PlanType {
guard let raw = raw?.lowercased() else { return .unknown("") }
switch raw {
case "guest": return .guest
case "free": return .free
case "go": return .go
case "plus": return .plus
case "pro": return .pro
case "prolite", "pro_lite", "pro-lite": return .prolite
case "free_workspace": return .freeWorkspace
case "team": return .team
case "business": return .business
case "education": return .education
case "quorum": return .quorum
case "k12": return .k12
case "enterprise": return .enterprise
case "edu": return .edu
default: return .unknown(raw)
}
}
}

View file

@ -0,0 +1,75 @@
import Foundation
/// Per-provider live-quota snapshot consumed by the AgentTab progress bar
/// and the hover-detail popover. Today only Claude has a real quota source
/// (Anthropic /api/oauth/usage); future providers (Cursor, Copilot, etc.)
/// will plug in by producing the same struct from their own auth path.
struct QuotaSummary: Equatable {
enum Connection: Equatable {
case connected
case disconnected // no credentials present
case loading
case stale // had data once, current fetch is in flight
case transientFailure // backing off; show last-known data dimmed
case terminalFailure(reason: String?) // user must reconnect
}
let providerFilter: ProviderFilter
let connection: Connection
let primary: Window? // weekly utilization, the headline bar
let details: [Window] // 5h, weekly, opus, sonnet full hover card
/// Display label for the user's plan (e.g. "Max 20x", "Pro Lite"). Shown
/// in the top-right corner of the hover detail popover so users can
/// confirm at a glance which subscription is feeding the bar.
let planLabel: String?
/// Optional footer rows that the popover renders below the window list.
/// Used today only by Codex to surface the on-account credits balance,
/// but kept generic so future providers can add provider-specific facts
/// (e.g. "Anthropic incident in progress", "Cursor team seat").
let footerLines: [String]
struct Window: Equatable {
let label: String
let percent: Double // 0..1
let resetsAt: Date?
}
/// Color band thresholds for the inline chip bar and aggregate menubar
/// flame tint. Four tiers so the icon can step from "you're approaching
/// your limit" (yellow) through "you're about to hit the wall" (orange)
/// to "you're over" (red) matches what the user expects from a warning
/// indicator in the menu bar.
static func severity(for percent: Double) -> Severity {
if percent >= 1.0 { return .danger }
if percent >= 0.9 { return .critical }
if percent >= 0.7 { return .warning }
return .normal
}
enum Severity {
case normal // <70%
case warning // 70-90%
case critical // 90-100%
case danger // >=100%
}
}
extension QuotaSummary.Window {
/// Human-readable countdown like "2h 11m" or "3d 14h" or "now".
var resetsInLabel: String {
guard let resetsAt else { return "" }
let seconds = max(0, resetsAt.timeIntervalSinceNow)
if seconds < 60 { return "now" }
let minutes = Int(seconds / 60)
let hours = minutes / 60
let days = hours / 24
if days > 0 { return "\(days)d \(hours % 24)h" }
if hours > 0 { return "\(hours)h \(minutes % 60)m" }
return "\(minutes)m"
}
var percentLabel: String {
let pct = Int((percent * 100).rounded())
return "\(pct)%"
}
}

View file

@ -1,268 +0,0 @@
import Foundation
import Security
private let credentialsRelativePath = ".claude/.credentials.json"
private let keychainService = "Claude Code-credentials"
private let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")!
private let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
private let betaHeader = "oauth-2025-04-20"
private let userAgent = "claude-code/2.1.0"
private let requestTimeout: TimeInterval = 30
private let maxCredentialBytes = 64 * 1024
enum SubscriptionError: Error, LocalizedError {
case noCredentials
case credentialsInvalid
case refreshFailed(Int, String?)
case usageFetchFailed(Int, String?)
case decodeFailed(Error)
var errorDescription: String? {
switch self {
case .noCredentials: "No Claude OAuth credentials found"
case .credentialsInvalid: "Claude OAuth credentials malformed"
case let .refreshFailed(code, body): "Token refresh failed (\(code))\(body.map { ": \($0)" } ?? "")"
case let .usageFetchFailed(code, body): "Usage fetch failed (\(code))\(body.map { ": \($0)" } ?? "")"
case let .decodeFailed(err): "Decode failed: \(err.localizedDescription)"
}
}
}
struct SubscriptionClient {
static func fetch() async throws -> SubscriptionUsage {
let creds = try loadCredentials()
// Try the usage call with the existing token first. Only refresh on 401.
do {
let response = try await fetchUsage(token: creds.accessToken)
return mapResponse(response, rawTier: creds.rateLimitTier)
} catch SubscriptionError.usageFetchFailed(401, _) {
guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else {
throw SubscriptionError.usageFetchFailed(401, "no refresh token available")
}
let newToken = try await refreshAccessToken(refreshToken: refreshToken)
let response = try await fetchUsage(token: newToken)
return mapResponse(response, rawTier: creds.rateLimitTier)
}
}
// MARK: - Credentials
private static func loadCredentials() throws -> StoredCredentials {
if let data = try readFileCredentials() {
return try parseCredentials(data: sanitizeKeychainData(data))
}
if let creds = try readKeychainCredentials() {
return creds
}
throw SubscriptionError.noCredentials
}
private static func readFileCredentials() throws -> Data? {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
// SafeFile refuses to follow symlinks and caps the read, so a 6 GB /dev/urandom
// masquerading as the creds file can't blow up the app.
return try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
}
private static func readKeychainCredentials() throws -> StoredCredentials? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
NSLog("CodeBurn: keychain query failed status=\(status)")
return nil
}
return try parseCredentials(data: sanitizeKeychainData(data))
}
/// Claude Code's keychain writer line-wraps long string values (newline + leading spaces)
/// mid-token, producing JSON with literal control chars and stray spaces inside string
/// values. Replace every newline (CR/LF) plus the run of spaces/tabs that follows it.
/// Drops both the wrapping in tokens AND pretty-print indentation between fields (both
/// produce valid, compact JSON afterward).
private static func sanitizeKeychainData(_ data: Data) -> Data {
guard var s = String(data: data, encoding: .utf8) else { return data }
s = s.replacingOccurrences(of: "\r", with: "")
let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: [])
if let regex {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
s = regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "")
}
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
return s.data(using: .utf8) ?? data
}
/// Decodes the credential JSON blob. Never logs the blob contents or any slice of it --
/// even a partial access token reaching Console.app is a leak, and the byte-window
/// diagnostic that used to live here could overlap the `accessToken` field bytes.
private static func parseCredentials(data: Data) throws -> StoredCredentials {
do {
let root = try JSONDecoder().decode(CredentialsRoot.self, from: data)
guard let oauth = root.claudeAiOauth else { throw SubscriptionError.credentialsInvalid }
let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { throw SubscriptionError.credentialsInvalid }
let expiresAt = oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) }
return StoredCredentials(
accessToken: token,
refreshToken: oauth.refreshToken,
expiresAt: expiresAt,
rateLimitTier: oauth.rateLimitTier
)
} catch let err as SubscriptionError {
throw err
} catch {
throw SubscriptionError.decodeFailed(error)
}
}
// MARK: - Refresh
private static func refreshAccessToken(refreshToken: String) async throws -> String {
var request = URLRequest(url: refreshURL)
request.httpMethod = "POST"
request.timeoutInterval = requestTimeout
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "refresh_token", value: refreshToken),
URLQueryItem(name: "client_id", value: oauthClientID),
]
request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw SubscriptionError.refreshFailed(-1, nil)
}
guard http.statusCode == 200 else {
let body = String(data: data, encoding: .utf8)
throw SubscriptionError.refreshFailed(http.statusCode, body)
}
do {
let decoded = try JSONDecoder().decode(TokenRefreshResponse.self, from: data)
return decoded.accessToken
} catch {
throw SubscriptionError.decodeFailed(error)
}
}
// MARK: - Usage fetch
private static func fetchUsage(token: String) async throws -> UsageResponse {
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = requestTimeout
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw SubscriptionError.usageFetchFailed(-1, nil)
}
guard http.statusCode == 200 else {
let body = String(data: data, encoding: .utf8)
throw SubscriptionError.usageFetchFailed(http.statusCode, body)
}
do {
return try JSONDecoder().decode(UsageResponse.self, from: data)
} catch {
throw SubscriptionError.decodeFailed(error)
}
}
// MARK: - Mapping
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
SubscriptionUsage(
tier: SubscriptionUsage.tier(from: rawTier),
rawTier: rawTier,
fiveHourPercent: r.fiveHour?.utilization,
fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
sevenDayPercent: r.sevenDay?.utilization,
sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
sevenDayOpusPercent: r.sevenDayOpus?.utilization,
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
fetchedAt: Date()
)
}
private static func parseDate(_ s: String?) -> Date? {
guard let s, !s.isEmpty else { return nil }
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = f.date(from: s) { return d }
f.formatOptions = [.withInternetDateTime]
return f.date(from: s)
}
}
// MARK: - Internal models
private struct StoredCredentials {
let accessToken: String
let refreshToken: String?
let expiresAt: Date?
let rateLimitTier: String?
}
private struct CredentialsRoot: Decodable {
let claudeAiOauth: OAuthBlock?
}
private struct OAuthBlock: Decodable {
let accessToken: String?
let refreshToken: String?
let expiresAt: Double?
let rateLimitTier: String?
}
private struct TokenRefreshResponse: Decodable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
}
}
private struct UsageResponse: Decodable {
let fiveHour: Window?
let sevenDay: Window?
let sevenDayOpus: Window?
let sevenDaySonnet: Window?
enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case sevenDayOpus = "seven_day_opus"
case sevenDaySonnet = "seven_day_sonnet"
}
}
private struct Window: Decodable {
let utilization: Double?
let resetsAt: String?
enum CodingKeys: String, CodingKey {
case utilization
case resetsAt = "resets_at"
}
}

View file

@ -0,0 +1,42 @@
import Foundation
/// User-configurable cadence for /api/oauth/usage polling. Mirrors CodexBar's
/// "manual / 1m / 2m / 5m / 15m" preset set so users on tight rate-limit
/// budgets can dial it down and power users can dial it up. Stored as the raw
/// number of seconds in UserDefaults; `manual = 0` means "never auto-refresh".
enum SubscriptionRefreshCadence: Int, CaseIterable, Identifiable {
case manual = 0
case oneMinute = 60
case twoMinutes = 120
case fiveMinutes = 300
case fifteenMinutes = 900
var id: Int { rawValue }
var label: String {
switch self {
case .manual: return "Manual"
case .oneMinute: return "1 minute"
case .twoMinutes: return "2 minutes"
case .fiveMinutes: return "5 minutes"
case .fifteenMinutes: return "15 minutes"
}
}
static let defaultsKey = "codeburn.claude.refreshCadenceSeconds"
static let `default`: SubscriptionRefreshCadence = .twoMinutes
static var current: SubscriptionRefreshCadence {
get {
// UserDefaults.integer returns 0 when the key is missing that
// happens to alias `manual`, which is wrong for a fresh install.
// Probe with object(forKey:) so we can distinguish "never set"
// from "set to manual" and seed the default on first run.
if UserDefaults.standard.object(forKey: defaultsKey) == nil {
return .default
}
return SubscriptionRefreshCadence(rawValue: UserDefaults.standard.integer(forKey: defaultsKey)) ?? .default
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: defaultsKey) }
}
}

View file

@ -76,6 +76,13 @@ enum SubscriptionSnapshotStore {
/// Test seam: clear all snapshots.
static func resetForTesting() async {
await clearAll()
}
/// Wipe all snapshots from disk. Called when the user disconnects so the
/// "Based on last cycle" projections do not contaminate a reconnect under
/// a different account or tier.
static func clearAll() async {
await SnapshotLock.shared.run {
try? FileManager.default.removeItem(atPath: snapshotsPath())
}

View file

@ -13,7 +13,8 @@ struct AgentTabStrip: View {
AgentTab(
filter: filter,
cost: cost(for: filter),
isActive: store.selectedProvider == filter
isActive: store.selectedProvider == filter,
quota: store.quotaSummary(for: filter)
)
}
.buttonStyle(.plain)
@ -63,17 +64,45 @@ private struct AgentTab: View {
let filter: ProviderFilter
let cost: Double?
let isActive: Bool
let quota: QuotaSummary?
@State private var hoverPopoverShown = false
@State private var hoverEnterTask: DispatchWorkItem?
@State private var hoverExitTask: DispatchWorkItem?
/// Providers whose AgentTab chip reserves a 3pt bar slot underneath the
/// label, even when not yet connected. Driven by which providers we
/// actually implement live-quota fetching for in AppStore.quotaSummary.
static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool {
switch filter {
case .claude, .codex: return true
default: return false
}
}
var body: some View {
HStack(spacing: 5) {
Text(filter.rawValue)
.font(.system(size: 11.5, weight: .medium))
.tracking(-0.05)
if let cost, cost > 0 {
Text(cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
.tracking(-0.2)
VStack(spacing: 3) {
HStack(spacing: 5) {
Text(filter.rawValue)
.font(.system(size: 11.5, weight: .medium))
.tracking(-0.05)
if let cost, cost > 0 {
Text(cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
.tracking(-0.2)
}
}
// Reserve the bar slot only for providers whose quota source we
// implement (Claude, Codex). Providers that will never have a bar
// (All / Cursor / Droid / Gemini / Copilot) skip the slot entirely
// so the text centers naturally and the chip stays compact.
// Reserving the slot for Claude/Codex prevents the strip from
// jumping by 6pt the moment the user clicks Connect.
if Self.providerSupportsQuota(filter) {
AgentTabQuotaBar(quota: quota, isActive: isActive)
.frame(height: 3)
.opacity(quota == nil ? 0 : 1)
}
}
.padding(.horizontal, 10)
@ -84,6 +113,229 @@ private struct AgentTab: View {
)
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.contentShape(Rectangle())
.onHover { hovering in
// Debounce: 250ms enter so swiping across chips doesn't pop a
// popover for every chip touched, and 150ms exit so cursor travel
// between chip and popover doesn't dismiss prematurely.
hoverEnterTask?.cancel()
hoverExitTask?.cancel()
if hovering, quota != nil {
let task = DispatchWorkItem { hoverPopoverShown = true }
hoverEnterTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task)
} else {
let task = DispatchWorkItem { hoverPopoverShown = false }
hoverExitTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task)
}
}
.popover(isPresented: $hoverPopoverShown) {
if let quota {
QuotaDetailPopover(quota: quota)
}
}
}
}
/// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota
/// source. Width matches the chip; color shifts green amber red at 70% / 90%.
private struct AgentTabQuotaBar: View {
let quota: QuotaSummary?
let isActive: Bool
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(trackColor)
if let percent = filledFraction {
Capsule()
.fill(barColor)
.frame(width: max(2, geo.size.width * CGFloat(percent)))
.animation(.easeOut(duration: 0.25), value: percent)
}
if case .terminalFailure = quota?.connection {
// Hatched/red strip to telegraph "broken; reconnect needed".
Capsule()
.fill(Color.red.opacity(0.7))
}
}
}
}
private var filledFraction: Double? {
guard let pct = quota?.primary?.percent else { return nil }
return min(max(pct, 0), 1)
}
private var barColor: Color {
guard let pct = quota?.primary?.percent else { return .clear }
switch QuotaSummary.severity(for: pct) {
case .normal: return isActive ? Color.white : Color.green.opacity(0.85)
case .warning: return Color.yellow
case .critical: return Color.orange
case .danger: return Color.red
}
}
private var trackColor: Color {
isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18)
}
}
private struct QuotaDetailPopover: View {
let quota: QuotaSummary
var body: some View {
VStack(alignment: .leading, spacing: 8) {
switch quota.connection {
case .terminalFailure(let reason):
terminalFailureCard(reason: reason)
case .disconnected:
Text(disconnectedMessage)
.font(.system(size: 11))
.foregroundStyle(.secondary)
case .loading where quota.details.isEmpty:
Text("Loading…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
default:
rowsCard
}
}
.padding(12)
.frame(width: 260)
}
private var disconnectedMessage: String {
switch quota.providerFilter {
case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota."
case .claude: return "Sign in to Claude Code to track quota."
default: return "Sign in to track quota."
}
}
private var rowsCard: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text("\(quota.providerFilter.rawValue) usage")
.font(.system(size: 11, weight: .semibold))
if case .stale = quota.connection {
Text("stale")
.font(.system(size: 9.5))
.foregroundStyle(.secondary)
} else if case .transientFailure = quota.connection {
Text("retrying")
.font(.system(size: 9.5))
.foregroundStyle(.orange)
}
Spacer()
if let plan = quota.planLabel, !plan.isEmpty {
Text(plan)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.12))
)
// Size to content. Plan names are bounded short strings
// ("Max 20x", "Pro Lite", "Free Workspace"); a forced
// maxWidth was making short labels look stretched.
.fixedSize(horizontal: true, vertical: false)
}
}
ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in
QuotaDetailRow(window: w)
}
if !quota.footerLines.isEmpty {
Divider()
.padding(.top, 2)
ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
}
}
private func terminalFailureCard(reason: String?) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(reconnectTitle)
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.red)
Text(reason ?? defaultReconnectReason)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
Text(reconnectInstruction)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
private var reconnectTitle: String {
switch quota.providerFilter {
case .codex: return "Reconnect Codex"
default: return "Reconnect Claude"
}
}
private var defaultReconnectReason: String {
switch quota.providerFilter {
case .codex: return "Refresh token rejected by OpenAI."
default: return "Refresh token rejected by Anthropic."
}
}
private var reconnectInstruction: String {
switch quota.providerFilter {
case .codex: return "Run `codex login` in your terminal, then click Reconnect."
default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
}
}
}
private struct QuotaDetailRow: View {
let window: QuotaSummary.Window
var body: some View {
HStack(spacing: 8) {
Text(window.label)
.font(.system(size: 10.5))
.frame(width: 92, alignment: .leading)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule()
.fill(barColor)
.frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1))))
}
}
.frame(height: 4)
Text(window.percentLabel)
.font(.codeMono(size: 10.5, weight: .medium))
.frame(width: 36, alignment: .trailing)
if !window.resetsInLabel.isEmpty {
Text(window.resetsInLabel)
.font(.codeMono(size: 10))
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .trailing)
}
}
}
private var barColor: Color {
switch QuotaSummary.severity(for: window.percent) {
case .normal: return Color.green.opacity(0.85)
case .warning: return Color.yellow
case .critical: return Color.orange
case .danger: return Color.red
}
}
}

View file

@ -55,10 +55,14 @@ struct HeatmapSection: View {
}
private var visibleModes: [InsightMode] {
// Plan sources from Claude's OAuth usage endpoint, so it only makes sense when the
// Claude provider tab is selected. Hidden on All/Cursor/Codex/etc.
// Plan sources from a provider's OAuth usage endpoint. Currently
// implemented for Claude (Anthropic) and Codex (ChatGPT). Hidden on
// All / Cursor / Droid / Gemini / Copilot until those providers ship
// their own quota data sources.
InsightMode.allCases.filter { mode in
if mode == .plan { return store.selectedProvider == .claude }
if mode == .plan {
return store.selectedProvider == .claude || store.selectedProvider == .codex
}
return true
}
}
@ -72,7 +76,12 @@ struct HeatmapSection: View {
@ViewBuilder
private var content: some View {
switch store.selectedInsight {
case .plan: PlanInsight(usage: store.subscription)
case .plan:
if store.selectedProvider == .codex {
CodexPlanInsight()
} else {
PlanInsight(usage: store.subscription)
}
case .trend: TrendInsight(days: store.payload.history.daily)
case .forecast: ForecastInsight(days: store.payload.history.daily)
case .pulse: PulseInsight(payload: store.payload)
@ -891,28 +900,36 @@ private struct PlanInsight: View {
var body: some View {
Group {
switch store.subscriptionLoadState {
case .idle:
PlanIdleView()
case .loading:
case .notBootstrapped:
PlanConnectView { Task { await store.bootstrapSubscription() } }
case .bootstrapping:
PlanLoadingView()
case .loading:
if let usage {
loadedBody(usage: usage)
} else {
PlanLoadingView()
}
case .noCredentials:
PlanNoCredentialsView()
case .failed:
PlanFailedView(error: store.subscriptionError)
case .transientFailure:
if let usage {
loadedBody(usage: usage)
} else {
PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.")
}
case let .terminalFailure(reason):
PlanReconnectView(reason: reason) { Task { await store.bootstrapSubscription() } }
case .loaded:
if let usage {
loadedBody(usage: usage)
} else {
PlanNoCredentialsView()
PlanLoadingView()
}
}
}
.task {
// Lazy-trigger fetch the first time Plan is opened.
if store.subscriptionLoadState == .idle {
await store.refreshSubscription()
}
}
}
@ViewBuilder
@ -1010,26 +1027,6 @@ private struct PlanInsight: View {
// MARK: - Plan empty/loading/failure states
private struct PlanIdleView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "person.crop.circle.dashed")
.font(.system(size: 22))
.foregroundStyle(.tertiary)
Text("Loading your plan...")
.font(.system(size: 11.5, weight: .medium))
.foregroundStyle(.secondary)
Text("macOS may ask permission to read your Claude Code credentials.")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 260)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
private struct PlanLoadingView: View {
var body: some View {
VStack(spacing: 8) {
@ -1047,27 +1044,27 @@ private struct PlanNoCredentialsView: View {
@Environment(AppStore.self) private var store
var body: some View {
VStack(spacing: 8) {
VStack(spacing: 10) {
Image(systemName: "key.slash")
.font(.system(size: 20))
.font(.system(size: 24))
.foregroundStyle(.tertiary)
Text("No Claude subscription connected")
Text("No Claude credentials found")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Text("Sign in with Claude Code, then click Retry.")
Text("Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 260)
Button("Retry") {
Task { await store.refreshSubscription() }
.frame(maxWidth: 280)
Button("Try Again") {
Task { await store.bootstrapSubscription() }
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.padding(.vertical, 16)
}
}
@ -1103,6 +1100,175 @@ private struct PlanFailedView: View {
}
}
/// Shown the very first time a user opens the Plan tab. Clicking Connect is the
/// only path to triggering the macOS keychain prompt for Claude Code credentials
/// the menubar app does not touch the keychain at startup.
private struct PlanConnectView: View {
let onConnect: () -> Void
var body: some View {
VStack(spacing: 10) {
Image(systemName: "link.circle")
.font(.system(size: 26))
.foregroundStyle(Theme.brandAccent)
Text("Connect Claude subscription")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Text("CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
Button("Connect", action: onConnect)
.controlSize(.small)
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
}
}
/// Shown when the refresh token has been invalidated (typically because the user
/// re-authenticated on another device). Clicking the button re-runs bootstrap,
/// which reads Claude's credentials source again and writes a fresh copy to our
/// own keychain item.
private struct PlanReconnectView: View {
let reason: String?
let onReconnect: () -> Void
var body: some View {
VStack(spacing: 10) {
Image(systemName: "arrow.triangle.2.circlepath.circle")
.font(.system(size: 24))
.foregroundStyle(.red)
Text("Reconnect Claude")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Text(reason ?? "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
.lineLimit(3)
Button("Reconnect", action: onReconnect)
.controlSize(.small)
.buttonStyle(.borderedProminent)
.tint(.red)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
/// Plan tab for Codex. Mirrors PlanInsight's layout but reads from
/// store.codexUsage / store.codexLoadState. We deliberately skip the
/// "On pace at reset" projection here that math is fed by local
/// per-message Claude spend extrapolated against the API quota windows;
/// our local Codex spend isn't an apples-to-apples signal for the
/// ChatGPT-subscription rate windows reported by wham/usage. Add when
/// we wire a comparable extrapolator.
private struct CodexPlanInsight: View {
@Environment(AppStore.self) private var store
var body: some View {
Group {
switch store.codexLoadState {
case .notBootstrapped:
PlanConnectView { Task { await store.bootstrapCodex() } }
case .bootstrapping:
PlanLoadingView()
case .loading:
if let usage = store.codexUsage {
loadedBody(usage: usage)
} else {
PlanLoadingView()
}
case .noCredentials:
PlanNoCredentialsView()
case .failed:
PlanFailedView(error: store.codexError)
case .transientFailure:
if let usage = store.codexUsage {
loadedBody(usage: usage)
} else {
PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.")
}
case let .terminalFailure(reason):
PlanReconnectView(reason: reason) { Task { await store.bootstrapCodex() } }
case .loaded:
if let usage = store.codexUsage {
loadedBody(usage: usage)
} else {
PlanLoadingView()
}
}
}
}
@ViewBuilder
private func loadedBody(usage: CodexUsage) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(usage.plan.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt {
Text("Resets \(relativeReset(resetsAt))")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
if let primary = usage.primary {
UtilizationRow(
label: "\(primary.windowLabel) window",
percent: primary.usedPercent,
resetsAt: primary.resetsAt,
projection: nil
)
}
if let secondary = usage.secondary {
UtilizationRow(
label: "\(secondary.windowLabel) window",
percent: secondary.usedPercent,
resetsAt: secondary.resetsAt,
projection: nil
)
}
// Surface non-zero per-model rate limits (Codex Spark, etc.) so
// power users see them; idle ones stay collapsed.
ForEach(Array(usage.additionalLimits.enumerated()), id: \.offset) { _, limit in
if let p = limit.primary, p.usedPercent > 0 {
UtilizationRow(
label: "\(limit.name) · \(p.windowLabel)",
percent: p.usedPercent,
resetsAt: p.resetsAt,
projection: nil
)
}
if let s = limit.secondary, s.usedPercent > 0 {
UtilizationRow(
label: "\(limit.name) · \(s.windowLabel)",
percent: s.usedPercent,
resetsAt: s.resetsAt,
projection: nil
)
}
}
}
.padding(.horizontal, 14)
.padding(.top, 4)
.padding(.bottom, 8)
}
private func relativeReset(_ date: Date) -> String {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
}
private struct WindowProjection {
enum Source { case linear, historicalBaseline }
let percent: Double

View file

@ -207,24 +207,31 @@ private struct BurnFlame: View {
private struct Header: View {
@Environment(UpdateChecker.self) private var updateChecker
@Environment(AppStore.self) private var store
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 1) {
(
Text("Code").foregroundStyle(.primary)
+ Text("Burn").foregroundStyle(Theme.brandEmber)
)
.font(.system(size: 13, weight: .semibold))
.tracking(-0.15)
Text("AI Coding Cost Tracker")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
HStack {
VStack(alignment: .leading, spacing: 1) {
(
Text("Code").foregroundStyle(.primary)
+ Text("Burn").foregroundStyle(Theme.brandEmber)
)
.font(.system(size: 13, weight: .semibold))
.tracking(-0.15)
Text("AI Coding Cost Tracker")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
Spacer()
if updateChecker.updateAvailable {
UpdateBadge()
}
AccentPicker()
}
Spacer()
if updateChecker.updateAvailable {
UpdateBadge()
}
AccentPicker()
// Compact warning row when any connected provider crosses 70%.
// Lists all warning providers with their worst-window percent so
// the user knows whether to slow down on Claude, Codex, or both.
QuotaWarningRow(status: store.aggregateQuotaStatus)
}
.padding(.horizontal, 14)
.padding(.top, 10)
@ -232,6 +239,61 @@ private struct Header: View {
}
}
private struct QuotaWarningRow: View {
let status: AppStore.AggregateQuotaStatus
var body: some View {
if !status.warnings.isEmpty {
HStack(spacing: 6) {
Image(systemName: severityIcon)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(severityColor)
Text(message)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(severityColor)
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(severityColor.opacity(0.12))
)
}
}
private var message: String {
let parts = status.warnings.map { "\($0.name) \(Int($0.percent.rounded()))%" }
if parts.count == 1 {
// Reads "Claude over limit (105%)" when any provider exceeds the
// quota cap, instead of the awkward "Claude 105% of quota used".
if case .danger = status.severity {
return "\(status.warnings[0].name) over limit (\(Int(status.warnings[0].percent.rounded()))%)"
}
return "\(parts[0]) of quota used"
}
return parts.joined(separator: " · ")
}
private var severityColor: Color {
switch status.severity {
case .normal: return .secondary
case .warning: return .yellow
case .critical: return .orange
case .danger: return .red
}
}
private var severityIcon: String {
switch status.severity {
case .normal: return "info.circle"
case .warning: return "exclamationmark.circle"
case .critical: return "exclamationmark.triangle"
case .danger: return "octagon"
}
}
}
private struct AccentPicker: View {
@Environment(AppStore.self) private var store

View file

@ -0,0 +1,367 @@
import SwiftUI
/// macOS-standard tabbed Settings window. New per-provider sections (Codex,
/// Cursor, Copilot, etc.) plug in as additional tabs. Each tab owns its own
/// concerns; this top-level view only hosts the TabView shell.
struct SettingsView: View {
@Environment(AppStore.self) private var store
var body: some View {
TabView {
GeneralSettingsTab()
.tabItem { Label("General", systemImage: "gearshape") }
ClaudeSettingsTab()
.tabItem { Label("Claude", systemImage: "brain") }
CodexSettingsTab()
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
AboutSettingsTab()
.tabItem { Label("About", systemImage: "info.circle") }
}
.frame(width: 520, height: 400)
}
}
// MARK: - General
private struct GeneralSettingsTab: View {
@Environment(AppStore.self) private var store
var body: some View {
Form {
Section("Display") {
Picker("Currency", selection: Binding(
get: { store.currency },
set: { applyCurrency(code: $0) }
)) {
ForEach(["USD", "EUR", "GBP", "INR", "JPY", "AUD", "CAD"], id: \.self) { code in
Text(code).tag(code)
}
}
Picker("Accent", selection: Binding(
get: { store.accentPreset },
set: { store.accentPreset = $0 }
)) {
ForEach(AccentPreset.allCases) { preset in
Text(preset.rawValue).tag(preset)
}
}
}
}
.formStyle(.grouped)
.padding()
}
private func applyCurrency(code: String) {
let symbol = CurrencyState.symbolForCode(code)
Task {
let cached = await FXRateCache.shared.cachedRate(for: code)
if let cached {
store.currency = code
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
}
let fresh = await FXRateCache.shared.rate(for: code)
store.currency = code
CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol)
}
CLICurrencyConfig.persist(code: code)
}
}
// MARK: - Claude
private struct ClaudeSettingsTab: View {
@Environment(AppStore.self) private var store
var body: some View {
Form {
Section("Connection") {
ClaudeConnectionRow()
}
Section("Quota Refresh") {
Picker("Update every", selection: Binding(
get: { SubscriptionRefreshCadence.current },
set: { SubscriptionRefreshCadence.current = $0 }
)) {
ForEach(SubscriptionRefreshCadence.allCases) { cadence in
Text(cadence.label).tag(cadence)
}
}
.pickerStyle(.menu)
Text("Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Button("Refresh Now") {
Task { await store.refreshSubscription() }
}
}
}
.formStyle(.grouped)
.padding()
}
}
private struct ClaudeConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.subscriptionLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.subscriptionLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.subscriptionLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Reconnect required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load plan data"
}
}
private var stateDetail: String {
switch store.subscriptionLoadState {
case .loaded:
if let tier = store.subscription?.tier.displayName {
return "Plan: \(tier)"
}
return "Live quota tracked from Anthropic."
case .terminalFailure: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying."
case .bootstrapping: return "macOS may ask permission to read your credentials."
case .loading: return "Background refresh in progress."
case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota."
case .failed: return store.subscriptionError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.subscriptionLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Claude?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectSubscription()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking quota and delete its local copy of your Claude credentials. Your Claude Code keychain entry is untouched — Claude Code keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - Codex
private struct CodexSettingsTab: View {
@Environment(AppStore.self) private var store
var body: some View {
Form {
Section("Connection") {
CodexConnectionRow()
}
Section {
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
Text("How it works")
}
}
.formStyle(.grouped)
.padding()
}
}
private struct CodexConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.codexLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.codexLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.codexLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Reconnect required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load Codex quota"
}
}
private var stateDetail: String {
switch store.codexLoadState {
case .loaded:
if let plan = store.codexUsage?.plan.displayName {
return "Plan: \(plan)"
}
return "Live quota tracked from chatgpt.com."
case .terminalFailure:
// Be specific about the cause: the message we already surface in
// codexError will say "API-key mode" if that's the situation, so
// the generic "run codex login" hint covers both cases.
if let err = store.codexError, err.lowercased().contains("api-key") {
return "Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking."
}
return "Run `codex login` in your terminal to sign in again, then click Reconnect."
case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying."
case .bootstrapping: return "Reading ~/.codex/auth.json."
case .loading: return "Background refresh in progress."
case .notBootstrapped, .noCredentials:
return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json."
case .failed: return store.codexError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.codexLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Codex?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectCodex()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking quota and delete its local copy of your Codex credentials. Your ~/.codex/auth.json is untouched — Codex CLI keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - About
private struct AboutSettingsTab: View {
private let appVersion: String =
(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? ""
private let buildVersion: String =
(Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? ""
var body: some View {
VStack(spacing: 14) {
Image(systemName: "flame.fill")
.font(.system(size: 40))
.foregroundStyle(Theme.brandAccent)
Text("CodeBurn")
.font(.system(size: 18, weight: .semibold))
Text("AI Coding Cost Tracker")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text("Version \(appVersion) (\(buildVersion))")
.font(.codeMono(size: 11))
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Link("GitHub", destination: URL(string: "https://github.com/getagentseal/codeburn")!)
Link("Issues", destination: URL(string: "https://github.com/getagentseal/codeburn/issues")!)
}
.font(.system(size: 12))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}