diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 00b4283..2d91fc8 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -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? @@ -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 { diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 65811e4..b4d596e 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -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")? diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift new file mode 100644 index 0000000..544eb5b --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -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.. 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(_ body: () throws -> T) rethrows -> T { + lock(); defer { unlock() } + return try body() + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift new file mode 100644 index 0000000..cd3ddb0 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -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) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift new file mode 100644 index 0000000..15441b5 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -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 + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift new file mode 100644 index 0000000..6a97bc5 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -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 + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift new file mode 100644 index 0000000..719b117 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -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: " 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) + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift new file mode 100644 index 0000000..c76f6ba --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -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)%" + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift deleted file mode 100644 index 3f71e30..0000000 --- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift +++ /dev/null @@ -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.. 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" - } -} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift new file mode 100644 index 0000000..3701d25 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift @@ -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) } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift index 931154a..9357ee9 100644 --- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift @@ -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()) } diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 33f7b15..9844e33 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -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 + } } } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 2e2dc3a..3374bd9 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -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 diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 6e3719f..28bd8a9 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -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 diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift new file mode 100644 index 0000000..a4c3585 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -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() + } +}