mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-18 13:34:24 +00:00
The Codex plan tab reused the shared connect, no-credentials and reconnect views whose copy was hardcoded for Claude, so the Codex tab showed Connect Claude subscription. Worse, the no-credentials Try Again button always re-ran the Claude bootstrap. The three views now take title, message and action from the call site. The oauth usage endpoint gained a limits array carrying model-scoped weekly buckets (currently Fable). Parse weekly_scoped entries generically by their display name and show them in the Claude hover popover and the plan tab, with the usual pace projection. Parser now has test coverage using the captured live response shape.
1456 lines
60 KiB
Swift
1456 lines
60 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
private let cacheTTLSeconds: TimeInterval = 30
|
|
private let interactiveRefreshResetSeconds: TimeInterval = 120
|
|
private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod"
|
|
|
|
struct CachedPayload {
|
|
let payload: MenubarPayload
|
|
let fetchedAt: Date
|
|
var isFresh: Bool { Date().timeIntervalSince(fetchedAt) < cacheTTLSeconds }
|
|
}
|
|
|
|
struct PayloadCacheKey: Hashable {
|
|
let scope: MenubarScope
|
|
let period: Period
|
|
let provider: ProviderFilter
|
|
let day: String?
|
|
let days: Set<String>
|
|
|
|
init(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil, days: Set<String> = []) {
|
|
self.scope = scope
|
|
self.period = period
|
|
self.provider = provider
|
|
self.day = days.count <= 1 ? (day ?? days.first) : nil
|
|
self.days = days.count > 1 ? days : []
|
|
}
|
|
|
|
var label: String {
|
|
if !days.isEmpty, let first = days.min(), let last = days.max() {
|
|
return "\(first)..\(last)"
|
|
}
|
|
return day.map { "Day(\($0))" } ?? period.rawValue
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class AppStore {
|
|
var selectedProvider: ProviderFilter = .all
|
|
var selectedPeriod: Period = .today
|
|
var selectedScope: MenubarScope = MenubarScope.savedMenubarScope()
|
|
var selectedDays: Set<String> = []
|
|
var activeScope: MenubarScope { effectiveSelectedScope }
|
|
|
|
private var effectiveSelectedScope: MenubarScope {
|
|
selectedDays.count > 1 ? .local : selectedScope
|
|
}
|
|
|
|
var selectedDay: String? {
|
|
guard selectedDays.count == 1 else { return nil }
|
|
return selectedDays.first
|
|
}
|
|
private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() {
|
|
didSet { menubarPeriod.persistAsMenubarDefault() }
|
|
}
|
|
private(set) var menubarScope: MenubarScope = MenubarScope.savedMenubarScope() {
|
|
didSet { menubarScope.persistAsMenubarDefault() }
|
|
}
|
|
var selectedInsight: InsightMode = .trend
|
|
var accentPreset: AccentPreset = ThemeState.shared.preset {
|
|
didSet { ThemeState.shared.preset = accentPreset }
|
|
}
|
|
var showingAccentPicker: Bool = false
|
|
var currency: String = "USD"
|
|
/// Which Settings tab to show; lets the menu's "About CodeBurn" item jump
|
|
/// straight to the About tab even when the Settings window is reused.
|
|
var settingsTab: String = "general"
|
|
var displayMetric: DisplayMetric = DisplayMetric(rawValue: UserDefaults.standard.string(forKey: "CodeBurnDisplayMetric") ?? "") ?? .cost {
|
|
didSet { UserDefaults.standard.set(displayMetric.rawValue, forKey: "CodeBurnDisplayMetric") }
|
|
}
|
|
var dailyBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyBudget") {
|
|
didSet { UserDefaults.standard.set(dailyBudget, forKey: "CodeBurnDailyBudget") }
|
|
}
|
|
// Token-denominated daily budget, used when the display metric is token-based.
|
|
// Stored separately from the cost budget so switching metric never reinterprets
|
|
// a dollar threshold as a token count (or vice versa).
|
|
var dailyTokenBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyTokenBudget") {
|
|
didSet { UserDefaults.standard.set(dailyTokenBudget, forKey: "CodeBurnDailyTokenBudget") }
|
|
}
|
|
|
|
/// True when the menubar metric counts tokens rather than cost.
|
|
var isTokenMetric: Bool { displayMetric == .tokens || displayMetric == .totalTokens }
|
|
|
|
/// Active daily-budget threshold for the current metric: a token count when
|
|
/// tracking tokens, otherwise USD cost. 0 means the alert is off.
|
|
var activeDailyBudget: Double { isTokenMetric ? dailyTokenBudget : dailyBudget }
|
|
|
|
/// Today's total in the active metric (USD cost, or input+output tokens),
|
|
/// or nil when today's payload has not loaded yet.
|
|
var todayMetricTotal: Double? {
|
|
guard let current = todayPayload?.current else { return nil }
|
|
return isTokenMetric ? Double(current.inputTokens + current.outputTokens) : current.cost
|
|
}
|
|
|
|
/// True when today's usage has reached or passed the active daily budget.
|
|
var isOverDailyBudget: Bool {
|
|
guard activeDailyBudget > 0, let total = todayMetricTotal else { return false }
|
|
return total >= activeDailyBudget
|
|
}
|
|
|
|
var shouldShowDailyBudgetWarning: Bool {
|
|
isOverDailyBudget && activeScope == .local
|
|
}
|
|
|
|
/// The active daily-budget threshold formatted for display (tokens, or USD).
|
|
/// The cost budget is defined in USD (matching the "$" presets and field), so
|
|
/// it is not run through the display-currency conversion here.
|
|
var dailyBudgetLabel: String {
|
|
isTokenMetric ? "\(activeDailyBudget.asCompactTokens()) tokens" : activeDailyBudget.asUSD()
|
|
}
|
|
|
|
var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } }
|
|
var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 }
|
|
var hasAttemptedCurrentKeyLoad: Bool {
|
|
attemptedKeys.contains(currentKey) ||
|
|
(effectiveSelectedScope == .combined && attemptedKeys.contains(localCurrentKey))
|
|
}
|
|
var lastError: String? { lastErrorByKey[currentKey] }
|
|
private var loadingCountsByKey: [PayloadCacheKey: Int] = [:]
|
|
private var loadingStartedAtByKey: [PayloadCacheKey: Date] = [:]
|
|
private var attemptedKeys: Set<PayloadCacheKey> = []
|
|
private var lastErrorByKey: [PayloadCacheKey: String] = [:]
|
|
var subscription: SubscriptionUsage?
|
|
var subscriptionError: String?
|
|
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
|
|
var capacityEstimates: [String: CapacityEstimate] = [:]
|
|
|
|
var codexUsage: CodexUsage?
|
|
var codexError: String?
|
|
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
|
|
|
|
/// Generation tokens for the in-flight refresh tasks. Incremented on every
|
|
/// disconnect / reset so a fetch that started before the disconnect cannot
|
|
/// resume after the await and re-populate the freshly-cleared state.
|
|
private var claudeRefreshGen: Int = 0
|
|
private var codexRefreshGen: Int = 0
|
|
|
|
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
|
private var cacheDate: String = ""
|
|
private var switchTask: Task<Void, Never>?
|
|
private var payloadRefreshGeneration: UInt64 = 0
|
|
#if DEBUG
|
|
private var refreshSuppressedForTesting = false
|
|
#endif
|
|
/// Tracks the last successful fetch timestamp per key for stuck-loading
|
|
/// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt`
|
|
/// is authoritative there. This map persists across cache wipes (day
|
|
/// rollover, etc.) so we can distinguish "fresh install, never fetched"
|
|
/// from "cache was wiped 10 minutes ago and we still haven't refilled".
|
|
private var lastSuccessByKey: [PayloadCacheKey: Date] = [:]
|
|
|
|
static let dayFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
formatter.timeZone = .current
|
|
return formatter
|
|
}()
|
|
|
|
static func dayString(from date: Date) -> String {
|
|
dayFormatter.string(from: date)
|
|
}
|
|
|
|
private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval {
|
|
guard let last = lastSuccessByKey[key] else { return .infinity }
|
|
return Date().timeIntervalSince(last)
|
|
}
|
|
|
|
private var todayAllKey: PayloadCacheKey {
|
|
PayloadCacheKey(scope: .local, period: .today, provider: .all, day: nil)
|
|
}
|
|
|
|
private var menubarStatusKey: PayloadCacheKey {
|
|
PayloadCacheKey(scope: .local, period: menubarPeriod, provider: .all, day: nil)
|
|
}
|
|
|
|
private var currentKey: PayloadCacheKey {
|
|
PayloadCacheKey(scope: effectiveSelectedScope, period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays)
|
|
}
|
|
|
|
private var localCurrentKey: PayloadCacheKey {
|
|
PayloadCacheKey(scope: .local, period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays)
|
|
}
|
|
|
|
private var periodAllKey: PayloadCacheKey {
|
|
PayloadCacheKey(scope: .local, period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)
|
|
}
|
|
|
|
var payload: MenubarPayload {
|
|
if effectiveSelectedScope == .combined {
|
|
let combinedPayload = cache[currentKey]?.payload
|
|
if let localPayload = cache[localCurrentKey]?.payload {
|
|
if let combined = combinedPayload?.combined {
|
|
return MenubarPayload(
|
|
generated: combinedPayload?.generated ?? localPayload.generated,
|
|
current: localPayload.current,
|
|
optimize: localPayload.optimize,
|
|
history: localPayload.history,
|
|
combined: combined
|
|
)
|
|
}
|
|
return localPayload
|
|
}
|
|
if let combinedPayload {
|
|
return combinedPayload
|
|
}
|
|
}
|
|
return cache[currentKey]?.payload ?? .empty
|
|
}
|
|
|
|
/// Today (across all providers) backs day-specific views in the popover.
|
|
var todayPayload: MenubarPayload? {
|
|
cache[todayAllKey]?.payload
|
|
}
|
|
|
|
var todayPayloadAgeSeconds: Int? {
|
|
guard let cached = cache[todayAllKey] else { return nil }
|
|
return Int(Date().timeIntervalSince(cached.fetchedAt))
|
|
}
|
|
|
|
var menubarPayloadAgeSeconds: Int? {
|
|
guard let cached = cache[menubarStatusKey] else { return nil }
|
|
return Int(Date().timeIntervalSince(cached.fetchedAt))
|
|
}
|
|
|
|
var needsStatusPayloadRefresh: Bool {
|
|
cache[menubarStatusKey]?.isFresh != true
|
|
}
|
|
|
|
var menubarPayload: MenubarPayload? {
|
|
cache[menubarStatusKey]?.payload
|
|
}
|
|
|
|
/// All-provider payload for the selected period. Used by the tab strip to show
|
|
/// per-provider costs that match the active period, not just today.
|
|
var periodAllPayload: MenubarPayload? {
|
|
cache[periodAllKey]?.payload
|
|
}
|
|
|
|
var isDayMode: Bool {
|
|
!selectedDays.isEmpty
|
|
}
|
|
|
|
var selectionLabel: String {
|
|
if selectedDays.count > 1, let first = selectedDays.min(), let last = selectedDays.max() {
|
|
return "\(selectedDays.count) days (\(first) .. \(last))"
|
|
}
|
|
return selectedDay.map { "Day (\($0))" } ?? selectedPeriod.rawValue
|
|
}
|
|
|
|
var trendPeriod: Period {
|
|
isDayMode ? .today : selectedPeriod
|
|
}
|
|
|
|
var hasCachedData: Bool {
|
|
cache[currentKey] != nil || (effectiveSelectedScope == .combined && cache[localCurrentKey] != nil)
|
|
}
|
|
|
|
var hasStaleLoading: Bool {
|
|
let now = Date()
|
|
return loadingStartedAtByKey.values.contains {
|
|
now.timeIntervalSince($0) > loadingWatchdogSeconds
|
|
}
|
|
}
|
|
|
|
var hasStaleInteractivePayload: Bool {
|
|
staleInteractivePayloadAgeSeconds != nil
|
|
}
|
|
|
|
var hasMissingInteractivePayloadWithoutAttempt: Bool {
|
|
!hasCachedData && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad
|
|
}
|
|
|
|
var shouldResetInteractiveRefreshPipeline: Bool {
|
|
hasStaleLoading || hasStaleInteractivePayload || hasMissingInteractivePayloadWithoutAttempt
|
|
}
|
|
|
|
var staleInteractivePayloadAgeSeconds: Int? {
|
|
let keys = Set([
|
|
currentKey,
|
|
localCurrentKey,
|
|
todayAllKey,
|
|
periodAllKey,
|
|
])
|
|
let staleAges = keys.compactMap { key -> TimeInterval? in
|
|
guard let cached = cache[key] else { return nil }
|
|
let age = Date().timeIntervalSince(cached.fetchedAt)
|
|
return age > interactiveRefreshResetSeconds ? age : nil
|
|
}
|
|
return staleAges.max().map(Int.init)
|
|
}
|
|
|
|
var needsInteractivePayloadRefresh: Bool {
|
|
var requiredKeys: Set<PayloadCacheKey> = [currentKey, todayAllKey, periodAllKey]
|
|
if effectiveSelectedScope == .combined {
|
|
requiredKeys.insert(localCurrentKey)
|
|
}
|
|
return requiredKeys.contains { cache[$0]?.isFresh != true } || hasStaleLoading
|
|
}
|
|
|
|
/// True if any cached payload reports at least one provider. Used to keep the
|
|
/// AgentTabStrip visible across period/provider switches even when the current
|
|
/// key's payload is briefly empty (e.g. immediately after a `switchTo` and
|
|
/// before the new fetch lands).
|
|
var hasAnyProvidersInCache: Bool {
|
|
cache.values.contains { !$0.payload.current.providers.isEmpty }
|
|
}
|
|
|
|
#if DEBUG
|
|
func setCachedPayloadForTesting(_ payload: MenubarPayload,
|
|
scope: MenubarScope = .local,
|
|
period: Period,
|
|
provider: ProviderFilter,
|
|
day: String? = nil,
|
|
days: Set<String> = [],
|
|
fetchedAt: Date) {
|
|
cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
|
|
}
|
|
|
|
func cachedPayloadForTesting(scope: MenubarScope = .local,
|
|
period: Period,
|
|
provider: ProviderFilter,
|
|
day: String? = nil,
|
|
days: Set<String> = []) -> MenubarPayload? {
|
|
cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)]?.payload
|
|
}
|
|
|
|
func setLastErrorForTesting(_ error: String,
|
|
scope: MenubarScope = .local,
|
|
period: Period,
|
|
provider: ProviderFilter,
|
|
day: String? = nil,
|
|
days: Set<String> = []) {
|
|
lastErrorByKey[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)] = error
|
|
}
|
|
|
|
func seedInFlightForTesting(scope: MenubarScope = .local,
|
|
period: Period,
|
|
provider: ProviderFilter,
|
|
day: String? = nil,
|
|
insertedAt: Date) {
|
|
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] = insertedAt
|
|
}
|
|
|
|
func isInFlightForTesting(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil) -> Bool {
|
|
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] != nil
|
|
}
|
|
|
|
func suppressRefreshesForTesting() {
|
|
refreshSuppressedForTesting = true
|
|
}
|
|
#endif
|
|
|
|
var findingsCount: Int {
|
|
payload.optimize.findingCount
|
|
}
|
|
|
|
/// Switch to a period. Cancels any in-flight switch and fetches provider-specific +
|
|
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
|
|
func switchTo(period: Period) {
|
|
selectedPeriod = period
|
|
selectedDays = []
|
|
startInteractiveSelectionRefresh()
|
|
}
|
|
|
|
func switchToYesterday() {
|
|
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
|
|
switchTo(day: yesterday)
|
|
}
|
|
|
|
func switchTo(day: Date) {
|
|
let clamped = min(Calendar.current.startOfDay(for: day), Calendar.current.startOfDay(for: Date()))
|
|
selectedDays = [Self.dayString(from: clamped)]
|
|
startInteractiveSelectionRefresh()
|
|
}
|
|
|
|
func switchTo(days: Set<String>) {
|
|
selectedDays = days
|
|
startInteractiveSelectionRefresh()
|
|
}
|
|
|
|
func shiftSelectedDay(by delta: Int) {
|
|
let base = selectedDayDate ?? Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
|
|
let shifted = Calendar.current.date(byAdding: .day, value: delta, to: base) ?? base
|
|
switchTo(day: shifted)
|
|
}
|
|
|
|
var selectedDayDate: Date? {
|
|
guard let selectedDay else { return nil }
|
|
return Self.dayFormatter.date(from: selectedDay)
|
|
}
|
|
|
|
var canShiftSelectedDayForward: Bool {
|
|
guard let selectedDayDate else { return false }
|
|
return Calendar.current.startOfDay(for: selectedDayDate) < Calendar.current.startOfDay(for: Date())
|
|
}
|
|
|
|
func setMenubarPeriod(_ period: Period) {
|
|
guard Period.menubarMetricCases.contains(period) else { return }
|
|
guard menubarPeriod != period else { return }
|
|
menubarPeriod = period
|
|
Task { [weak self] in
|
|
await self?.refreshQuietly(period: period)
|
|
}
|
|
}
|
|
|
|
func setMenubarScope(_ scope: MenubarScope) {
|
|
let shouldResetProvider = scope == .combined && selectedProvider != .all
|
|
guard menubarScope != scope || selectedScope != scope || shouldResetProvider else { return }
|
|
menubarScope = scope
|
|
selectedScope = scope
|
|
if shouldResetProvider {
|
|
selectedProvider = .all
|
|
}
|
|
#if DEBUG
|
|
if refreshSuppressedForTesting { return }
|
|
#endif
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.refreshSelectionQuietly(scope: self.effectiveSelectedScope, force: true)
|
|
}
|
|
}
|
|
|
|
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
|
|
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data
|
|
/// in parallel so the tab strip costs stay in sync with the hero.
|
|
func switchTo(provider: ProviderFilter) {
|
|
selectedProvider = provider
|
|
startInteractiveSelectionRefresh()
|
|
}
|
|
|
|
func switchTo(scope: MenubarScope) {
|
|
let shouldResetProvider = scope == .combined && selectedProvider != .all
|
|
guard selectedScope != scope || shouldResetProvider else { return }
|
|
selectedScope = scope
|
|
if shouldResetProvider {
|
|
selectedProvider = .all
|
|
}
|
|
startInteractiveSelectionRefresh()
|
|
}
|
|
|
|
private func startInteractiveSelectionRefresh() {
|
|
switchTask?.cancel()
|
|
resetLoadingState()
|
|
#if DEBUG
|
|
if refreshSuppressedForTesting { return }
|
|
#endif
|
|
let period = selectedPeriod
|
|
let provider = selectedProvider
|
|
let scope = effectiveSelectedScope
|
|
let day = selectedDay
|
|
let days = selectedDays
|
|
let key = PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)
|
|
let localKey = PayloadCacheKey(scope: .local, period: period, provider: provider, day: day, days: days)
|
|
let allKey = PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, days: days)
|
|
lastErrorByKey[key] = nil
|
|
switchTask = Task {
|
|
if scope == .combined {
|
|
async let local: Void = refresh(key: localKey, includeOptimize: false, force: false, showLoading: false)
|
|
async let combined: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true)
|
|
if provider == .all {
|
|
_ = await (local, combined)
|
|
} else {
|
|
async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false)
|
|
_ = await (local, combined, all)
|
|
}
|
|
} else if provider == .all {
|
|
await refresh(key: key, includeOptimize: false, force: true, showLoading: true)
|
|
} else {
|
|
async let main: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true)
|
|
async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false)
|
|
_ = await (main, all)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var inFlightKeys: [PayloadCacheKey: Date] = [:]
|
|
|
|
func resetLoadingState() {
|
|
payloadRefreshGeneration &+= 1
|
|
loadingCountsByKey.removeAll()
|
|
loadingStartedAtByKey.removeAll()
|
|
inFlightKeys.removeAll()
|
|
attemptedKeys.removeAll()
|
|
}
|
|
|
|
func resetRefreshState(clearCache: Bool = false) {
|
|
switchTask?.cancel()
|
|
switchTask = nil
|
|
resetLoadingState()
|
|
attemptedKeys.removeAll()
|
|
lastErrorByKey.removeAll()
|
|
if clearCache {
|
|
cache.removeAll()
|
|
}
|
|
}
|
|
|
|
private let loadingWatchdogSeconds: TimeInterval = 60
|
|
|
|
@discardableResult
|
|
func clearStaleLoadingIfNeeded() -> Bool {
|
|
let now = Date()
|
|
let staleLoading = loadingStartedAtByKey.filter {
|
|
now.timeIntervalSince($0.value) > loadingWatchdogSeconds
|
|
}
|
|
let staleInFlight = inFlightKeys.filter { (key, insertedAt) in
|
|
now.timeIntervalSince(insertedAt) > loadingWatchdogSeconds &&
|
|
loadingStartedAtByKey[key] == nil
|
|
}
|
|
guard !staleLoading.isEmpty || !staleInFlight.isEmpty else { return false }
|
|
|
|
for (key, started) in staleLoading {
|
|
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
|
|
Int(now.timeIntervalSince(started)), key.label, key.provider.rawValue)
|
|
loadingCountsByKey[key] = nil
|
|
loadingStartedAtByKey[key] = nil
|
|
inFlightKeys[key] = nil
|
|
if cache[key] == nil {
|
|
lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background."
|
|
}
|
|
}
|
|
for (key, insertedAt) in staleInFlight {
|
|
NSLog("CodeBurn: orphaned in-flight key stuck for %ds on %@/%@ — clearing",
|
|
Int(now.timeIntervalSince(insertedAt)), key.label, key.provider.rawValue)
|
|
inFlightKeys[key] = nil
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func beginLoading(for key: PayloadCacheKey) {
|
|
if loadingCountsByKey[key, default: 0] == 0 {
|
|
loadingStartedAtByKey[key] = Date()
|
|
}
|
|
loadingCountsByKey[key, default: 0] += 1
|
|
}
|
|
|
|
private func finishLoading(for key: PayloadCacheKey) {
|
|
guard let count = loadingCountsByKey[key], count > 0 else { return }
|
|
if count == 1 {
|
|
loadingCountsByKey[key] = nil
|
|
loadingStartedAtByKey[key] = nil
|
|
} else {
|
|
loadingCountsByKey[key] = count - 1
|
|
}
|
|
}
|
|
|
|
private func currentCacheDate() -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter.string(from: Date())
|
|
}
|
|
|
|
private func invalidateStaleDayCache() {
|
|
let today = currentCacheDate()
|
|
if cacheDate != today {
|
|
payloadRefreshGeneration &+= 1
|
|
cache.removeAll()
|
|
loadingCountsByKey.removeAll()
|
|
loadingStartedAtByKey.removeAll()
|
|
inFlightKeys.removeAll()
|
|
attemptedKeys.removeAll()
|
|
lastErrorByKey.removeAll()
|
|
cacheDate = today
|
|
NSLog("CodeBurn: reset menubar payload cache for new day %@", today)
|
|
}
|
|
}
|
|
|
|
func invalidateCache() {
|
|
cache.removeAll()
|
|
}
|
|
|
|
func recoverFromStuckLoading() async {
|
|
guard prepareStuckLoadingRecovery() else { return }
|
|
await refresh(includeOptimize: false, force: true, showLoading: true)
|
|
}
|
|
|
|
/// Decides whether stuck-loading recovery should kick off a fresh fetch for
|
|
/// the current key, preparing the loading bookkeeping when it can.
|
|
///
|
|
/// A quiet refresh torn down across sleep/wake (or a generation reset) can
|
|
/// leave an orphaned `inFlightKeys` entry behind. Without clearing stale
|
|
/// state first the in-flight guard would bail on every retry, trapping the
|
|
/// popover on the spinner forever. A healthy in-flight fetch (younger than
|
|
/// the watchdog) is still respected so recovery never kills it.
|
|
@discardableResult
|
|
func prepareStuckLoadingRecovery() -> Bool {
|
|
_ = clearStaleLoadingIfNeeded()
|
|
let key = currentKey
|
|
guard inFlightKeys[key] == nil else { return false }
|
|
loadingCountsByKey[key] = nil
|
|
loadingStartedAtByKey[key] = nil
|
|
return true
|
|
}
|
|
|
|
func setRecoveryExhausted(for label: String) {
|
|
lastErrorByKey[currentKey] = "Could not load \(label). Check that the codeburn CLI is installed and working."
|
|
}
|
|
|
|
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
|
|
if effectiveSelectedScope == .combined {
|
|
async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: includeOptimize, force: force)
|
|
async let combined: Void = refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading)
|
|
_ = await (local, combined)
|
|
} else {
|
|
await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading)
|
|
}
|
|
}
|
|
|
|
private func refreshSelectionQuietly(scope: MenubarScope, force: Bool = false) async {
|
|
let scopedKey = PayloadCacheKey(
|
|
scope: scope,
|
|
period: selectedPeriod,
|
|
provider: selectedProvider,
|
|
day: selectedDay,
|
|
days: selectedDays
|
|
)
|
|
if scope == .combined {
|
|
async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: false, force: false)
|
|
async let combined: Void = refreshQuietly(key: scopedKey, includeOptimize: false, force: force)
|
|
_ = await (local, combined)
|
|
} else {
|
|
await refreshQuietly(key: scopedKey, includeOptimize: false, force: force)
|
|
}
|
|
}
|
|
|
|
private func refresh(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
|
|
invalidateStaleDayCache()
|
|
let cacheDateAtStart = cacheDate
|
|
let generationAtStart = payloadRefreshGeneration
|
|
if Task.isCancelled { return }
|
|
if !force, cache[key]?.isFresh == true { return }
|
|
if inFlightKeys[key] != nil { return }
|
|
inFlightKeys[key] = Date()
|
|
attemptedKeys.insert(key)
|
|
lastErrorByKey[key] = nil
|
|
let didShowLoading = showLoading || cache[key] == nil
|
|
if didShowLoading {
|
|
beginLoading(for: key)
|
|
}
|
|
// Diagnostic anchor: if this key has been empty for a long time (the
|
|
// popover would currently be showing "Loading..."), log how stale the
|
|
// miss is so the next time a user reports a stuck-loading bug we have
|
|
// a concrete data point — "no successful fetch for (today, claude)
|
|
// in 14 minutes" beats squinting at unified-log noise. We deliberately
|
|
// skip the first-attempt case (no prior success ever, finite check
|
|
// below filters .infinity) — that's just the cold path, not a bug.
|
|
let staleSeconds = staleSecondsForKey(key)
|
|
if staleSeconds.isFinite, staleSeconds > 120 {
|
|
NSLog("CodeBurn: refresh attempt for stale key \(key.label)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago")
|
|
}
|
|
defer {
|
|
let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration
|
|
inFlightKeys[key] = nil
|
|
if didShowLoading {
|
|
finishLoading(for: key)
|
|
}
|
|
if abandonedAttempt && cache[key] == nil && lastErrorByKey[key] == nil {
|
|
attemptedKeys.remove(key)
|
|
}
|
|
}
|
|
do {
|
|
let fresh = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: includeOptimize, scope: key.scope)
|
|
if generationAtStart != payloadRefreshGeneration {
|
|
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
|
|
return
|
|
}
|
|
if Task.isCancelled {
|
|
// Distinguish cancellation (user switched tabs mid-fetch) from
|
|
// the silent-no-result path. Without this log, a cancelled
|
|
// fetch leaves cache empty + lastError nil and the user sees
|
|
// perpetual loading with nothing in the diagnostics.
|
|
NSLog("CodeBurn: fetch for \(key.label)/\(key.provider.rawValue) cancelled before result was applied")
|
|
return
|
|
}
|
|
// Day-rollover race guard: if the calendar date changed during the
|
|
// fetch, this payload was computed against yesterday's date and
|
|
// would pollute today's freshly-cleared cache. Drop it; the next
|
|
// tick will refetch with today's data.
|
|
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
|
invalidateStaleDayCache()
|
|
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — calendar rolled mid-fetch")
|
|
return
|
|
}
|
|
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
|
lastSuccessByKey[key] = Date()
|
|
lastErrorByKey[key] = nil
|
|
} catch {
|
|
if Task.isCancelled { return }
|
|
NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)")
|
|
if includeOptimize, cache[key] == nil {
|
|
do {
|
|
let fallback = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: false, scope: key.scope)
|
|
guard !Task.isCancelled else { return }
|
|
if generationAtStart != payloadRefreshGeneration { return }
|
|
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
|
invalidateStaleDayCache()
|
|
return
|
|
}
|
|
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
|
|
lastSuccessByKey[key] = Date()
|
|
lastErrorByKey[key] = nil
|
|
return
|
|
} catch {
|
|
if Task.isCancelled { return }
|
|
NSLog("CodeBurn: fallback fetch also failed: \(error)")
|
|
}
|
|
}
|
|
lastErrorByKey[key] = String(describing: error)
|
|
}
|
|
|
|
let allKey = PayloadCacheKey(scope: .local, period: key.period, provider: .all, day: key.day, days: key.days)
|
|
if key != allKey, cache[allKey]?.isFresh != true {
|
|
await refreshQuietly(key: allKey, includeOptimize: false, force: false)
|
|
}
|
|
}
|
|
|
|
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
|
|
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
|
|
/// Always uses the .all provider since the menubar badge shows total spend.
|
|
func refreshQuietly(period: Period, day: String? = nil, force: Bool = false) async {
|
|
await refreshQuietly(key: PayloadCacheKey(scope: .local, period: period, provider: .all, day: day), includeOptimize: false, force: force)
|
|
}
|
|
|
|
private func refreshQuietly(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false) async {
|
|
invalidateStaleDayCache()
|
|
if !force, cache[key]?.isFresh == true { return }
|
|
if inFlightKeys[key] != nil { return }
|
|
inFlightKeys[key] = Date()
|
|
attemptedKeys.insert(key)
|
|
let cacheDateAtStart = cacheDate
|
|
let generationAtStart = payloadRefreshGeneration
|
|
if key.day == nil && key.period == .today, let age = todayPayloadAgeSeconds, age > 120 {
|
|
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
|
|
}
|
|
defer {
|
|
inFlightKeys[key] = nil
|
|
}
|
|
do {
|
|
let fresh = try await DataClient.fetch(
|
|
period: key.period,
|
|
day: key.day,
|
|
days: key.days,
|
|
provider: key.provider,
|
|
includeOptimize: includeOptimize,
|
|
scope: key.scope
|
|
)
|
|
if generationAtStart != payloadRefreshGeneration {
|
|
NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch")
|
|
return
|
|
}
|
|
// Same day-rollover guard as refresh(): drop yesterday's payload if
|
|
// the calendar rolled over during the fetch.
|
|
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
|
invalidateStaleDayCache()
|
|
return
|
|
}
|
|
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
|
lastSuccessByKey[key] = Date()
|
|
lastErrorByKey[key] = nil
|
|
} catch {
|
|
NSLog("CodeBurn: quiet refresh failed for \(key.label): \(error)")
|
|
if key.scope == .combined {
|
|
lastErrorByKey[key] = String(describing: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// User-initiated. Reads Claude's source (this is what triggers the macOS keychain
|
|
func activateClaudeFromDormant() async {
|
|
guard case .dormant = subscriptionLoadState else { return }
|
|
await bootstrapSubscription()
|
|
}
|
|
|
|
func activateCodexFromDormant() async {
|
|
guard case .dormant = codexLoadState else { return }
|
|
await bootstrapCodex()
|
|
}
|
|
|
|
func bootstrapSubscription() async {
|
|
subscriptionLoadState = .bootstrapping
|
|
do {
|
|
let usage = try await ClaudeSubscriptionService.bootstrap()
|
|
subscription = usage
|
|
subscriptionError = nil
|
|
subscriptionLoadState = .loaded
|
|
await captureSnapshots(for: usage)
|
|
} catch let err as ClaudeSubscriptionService.FetchError {
|
|
applyFetchError(err)
|
|
} catch {
|
|
subscriptionError = String(describing: error)
|
|
subscriptionLoadState = .failed
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
if case .dormant = subscriptionLoadState { return false }
|
|
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 {
|
|
if case .dormant = codexLoadState { return false }
|
|
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 let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) {
|
|
let worst = [
|
|
usage.fiveHourPercent,
|
|
usage.sevenDayPercent,
|
|
usage.sevenDayOpusPercent,
|
|
usage.sevenDaySonnetPercent,
|
|
].compactMap { $0 }.max() ?? 0
|
|
if worst > 0 { providers.append(("Claude", worst)) }
|
|
}
|
|
if let usage = codexUsage, shouldIncludeCachedQuota(loadState: codexLoadState) {
|
|
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)
|
|
}
|
|
|
|
private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool {
|
|
switch loadState {
|
|
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials:
|
|
return false
|
|
case .loading, .loaded, .failed, .terminalFailure, .transientFailure:
|
|
return true
|
|
}
|
|
}
|
|
|
|
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, .dormant, .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))
|
|
}
|
|
for scoped in usage.scopedWeekly {
|
|
details.append(.init(label: "Weekly · \(scoped.label)", percent: scoped.percent / 100, resetsAt: scoped.resetsAt))
|
|
}
|
|
}
|
|
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, .dormant, .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,
|
|
/// which the CapacityEstimator uses to derive the absolute token capacity per tier.
|
|
private func captureSnapshots(for usage: SubscriptionUsage) async {
|
|
let now = Date()
|
|
let history = payload.history.daily
|
|
|
|
let captures: [(key: String, percent: Double?, resetsAt: Date?, effective: Double?)] = [
|
|
("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, nil),
|
|
("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt,
|
|
effectiveTokensInLast7Days(history: history, asOf: now)),
|
|
("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, nil),
|
|
("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, nil),
|
|
]
|
|
for capture in captures {
|
|
guard let percent = capture.percent, let resetsAt = capture.resetsAt else { continue }
|
|
await SubscriptionSnapshotStore.record(SubscriptionSnapshot(
|
|
windowKey: capture.key,
|
|
percent: percent,
|
|
resetsAt: resetsAt,
|
|
capturedAt: now,
|
|
effectiveTokens: capture.effective
|
|
))
|
|
}
|
|
|
|
await refreshCapacityEstimates()
|
|
}
|
|
|
|
/// Sum effective tokens (input + 5*output + cache_creation + 0.1*cache_read) across the
|
|
/// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired
|
|
/// with the API-reported percent for capacity estimation.
|
|
private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.timeZone = .current
|
|
let cutoff = f.string(from: now.addingTimeInterval(-7 * 86400))
|
|
return history
|
|
.filter { $0.date >= cutoff }
|
|
.reduce(0.0) { $0 + $1.effectiveTokens }
|
|
}
|
|
|
|
/// Run CapacityEstimator over each window's accumulated snapshots. Only snapshots with a
|
|
/// non-nil effectiveTokens contribute. Result lives in capacityEstimates dict for UI gating.
|
|
private func refreshCapacityEstimates() async {
|
|
var next: [String: CapacityEstimate] = [:]
|
|
for key in ["seven_day", "seven_day_opus", "seven_day_sonnet"] {
|
|
let snaps = await SubscriptionSnapshotStore.snapshots(for: key)
|
|
let capacitySnaps = snaps.compactMap { s -> CapacitySnapshot? in
|
|
guard let effective = s.effectiveTokens, effective > 0 else { return nil }
|
|
return CapacitySnapshot(percent: s.percent, effectiveTokens: effective, capturedAt: s.capturedAt)
|
|
}
|
|
if let estimate = CapacityEstimator.estimate(capacitySnaps) {
|
|
next[key] = estimate
|
|
}
|
|
}
|
|
capacityEstimates = next
|
|
}
|
|
}
|
|
|
|
enum SupportedCurrency: String, CaseIterable, Identifiable {
|
|
case USD, GBP, EUR, AUD, CAD, NZD, JPY, CNY, CHF, INR, BRL, SEK, SGD, HKD, KRW, MXN, ZAR, DKK
|
|
var id: String { rawValue }
|
|
var displayName: String {
|
|
switch self {
|
|
case .USD: "US Dollar"
|
|
case .GBP: "British Pound"
|
|
case .EUR: "Euro"
|
|
case .AUD: "Australian Dollar"
|
|
case .CAD: "Canadian Dollar"
|
|
case .NZD: "New Zealand Dollar"
|
|
case .JPY: "Japanese Yen"
|
|
case .CNY: "Chinese Yuan"
|
|
case .CHF: "Swiss Franc"
|
|
case .INR: "Indian Rupee"
|
|
case .BRL: "Brazilian Real"
|
|
case .SEK: "Swedish Krona"
|
|
case .SGD: "Singapore Dollar"
|
|
case .HKD: "Hong Kong Dollar"
|
|
case .KRW: "South Korean Won"
|
|
case .MXN: "Mexican Peso"
|
|
case .ZAR: "South African Rand"
|
|
case .DKK: "Danish Krone"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ProviderFilter: String, CaseIterable, Identifiable {
|
|
case all = "All"
|
|
case claude = "Claude"
|
|
case cline = "Cline"
|
|
case codex = "Codex"
|
|
case cursor = "Cursor"
|
|
case cursorAgent = "Cursor Agent"
|
|
case copilot = "Copilot"
|
|
case devin = "Devin"
|
|
case droid = "Droid"
|
|
case gemini = "Gemini"
|
|
case ibmBob = "IBM Bob"
|
|
case kiro = "Kiro"
|
|
case kimi = "Kimi"
|
|
case kiloCode = "KiloCode"
|
|
case openclaw = "OpenClaw"
|
|
case opencode = "OpenCode"
|
|
case pi = "Pi"
|
|
case qwen = "Qwen"
|
|
case omp = "OMP"
|
|
case rooCode = "Roo Code"
|
|
case crush = "Crush"
|
|
case antigravity = "Antigravity"
|
|
case goose = "Goose"
|
|
case grok = "Grok"
|
|
case hermes = "Hermes"
|
|
case zcode = "ZCode"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var providerKeys: [String] {
|
|
switch self {
|
|
case .cursor: ["cursor"]
|
|
case .cursorAgent: ["cursor-agent", "cursor agent"]
|
|
case .cline: ["cline"]
|
|
case .rooCode: ["roo-code", "roo code"]
|
|
case .kiloCode: ["kilo-code", "kilocode"]
|
|
case .ibmBob: ["ibm-bob", "ibm bob"]
|
|
case .openclaw: ["openclaw"]
|
|
case .antigravity: ["antigravity"]
|
|
case .goose: ["goose"]
|
|
case .grok: ["grok", "grok build"]
|
|
case .hermes: ["hermes", "hermes agent"]
|
|
default: [rawValue.lowercased()]
|
|
}
|
|
}
|
|
|
|
var cliArg: String {
|
|
switch self {
|
|
case .all: "all"
|
|
case .claude: "claude"
|
|
case .cline: "cline"
|
|
case .codex: "codex"
|
|
case .cursor: "cursor"
|
|
case .cursorAgent: "cursor-agent"
|
|
case .copilot: "copilot"
|
|
case .devin: "devin"
|
|
case .droid: "droid"
|
|
case .gemini: "gemini"
|
|
case .ibmBob: "ibm-bob"
|
|
case .kiloCode: "kilo-code"
|
|
case .kiro: "kiro"
|
|
case .kimi: "kimi"
|
|
case .openclaw: "openclaw"
|
|
case .opencode: "opencode"
|
|
case .pi: "pi"
|
|
case .qwen: "qwen"
|
|
case .omp: "omp"
|
|
case .rooCode: "roo-code"
|
|
case .crush: "crush"
|
|
case .antigravity: "antigravity"
|
|
case .goose: "goose"
|
|
case .grok: "grok"
|
|
case .hermes: "hermes"
|
|
case .zcode: "zcode"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let codeBurnSubscriptionDisconnected = Notification.Name("com.codeburn.subscriptionDisconnected")
|
|
}
|
|
|
|
enum SubscriptionLoadState: Sendable, Equatable {
|
|
case notBootstrapped // no Keychain access yet — waiting for user to click Connect
|
|
case dormant // previously bootstrapped; keychain not yet accessed this session
|
|
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 DisplayMetric: String {
|
|
case cost, tokens, totalTokens, credits, iconOnly
|
|
}
|
|
|
|
enum InsightMode: String, CaseIterable, Identifiable {
|
|
case plan = "Plan"
|
|
case trend = "Trend"
|
|
case forecast = "Forecast"
|
|
case calendar = "Calendar"
|
|
case pulse = "Pulse"
|
|
case stats = "Stats"
|
|
case optimize = "Optimize"
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
enum Period: String, CaseIterable, Identifiable {
|
|
case today = "Today"
|
|
case sevenDays = "7 Days"
|
|
case thirtyDays = "30 Days"
|
|
case month = "Month"
|
|
case all = "6 Months"
|
|
|
|
var id: String { rawValue }
|
|
|
|
/// Maps to the CLI's `--period` argument values.
|
|
var cliArg: String {
|
|
switch self {
|
|
case .today: "today"
|
|
case .sevenDays: "week"
|
|
case .thirtyDays: "30days"
|
|
case .month: "month"
|
|
case .all: "all"
|
|
}
|
|
}
|
|
|
|
static let menubarMetricCases: [Period] = [.today, .sevenDays, .month, .all]
|
|
|
|
var menubarMetricLabel: String {
|
|
switch self {
|
|
case .today: "Today"
|
|
case .sevenDays: "Week"
|
|
case .thirtyDays: "30 Days"
|
|
case .month: "Month"
|
|
case .all: "6 Months"
|
|
}
|
|
}
|
|
|
|
var menubarDefaultsValue: String {
|
|
switch self {
|
|
case .today: "today"
|
|
case .sevenDays: "week"
|
|
case .thirtyDays: "30days"
|
|
case .month: "month"
|
|
case .all: "sixMonths"
|
|
}
|
|
}
|
|
|
|
init(menubarDefaultsValue: String?) {
|
|
switch menubarDefaultsValue {
|
|
case "today": self = .today
|
|
case "week", "sevenDays": self = .sevenDays
|
|
case "month": self = .month
|
|
case "sixMonths", "all": self = .all
|
|
default: self = .today
|
|
}
|
|
}
|
|
|
|
static func savedMenubarPeriod(defaults: UserDefaults = .standard) -> Period {
|
|
Period(menubarDefaultsValue: defaults.string(forKey: menubarPeriodDefaultsKey))
|
|
}
|
|
|
|
func persistAsMenubarDefault(defaults: UserDefaults = .standard) {
|
|
let period = Period.menubarMetricCases.contains(self) ? self : Period.today
|
|
defaults.set(period.menubarDefaultsValue, forKey: menubarPeriodDefaultsKey)
|
|
}
|
|
|
|
func menubarSuffix(compact: Bool) -> String {
|
|
switch self {
|
|
case .today: ""
|
|
case .sevenDays: compact ? "/wk" : " / wk"
|
|
case .thirtyDays: compact ? "/30d" : " / 30d"
|
|
case .month: compact ? "/mo" : " / mo"
|
|
case .all: compact ? "/6mo" : " / 6mo"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values
|
|
/// are formatted dozens of times per popover refresh. These shared instances avoid thousands of
|
|
/// allocations per frame while SwiftUI's Observation framework still triggers redraws when
|
|
/// CurrencyState.shared mutates.
|
|
private let groupedDecimalFormatter: NumberFormatter = {
|
|
let f = NumberFormatter()
|
|
f.numberStyle = .decimal
|
|
f.groupingSeparator = ","
|
|
f.decimalSeparator = "."
|
|
f.maximumFractionDigits = 2
|
|
f.minimumFractionDigits = 2
|
|
return f
|
|
}()
|
|
|
|
private let thousandsFormatter: NumberFormatter = {
|
|
let f = NumberFormatter()
|
|
f.numberStyle = .decimal
|
|
f.groupingSeparator = ","
|
|
return f
|
|
}()
|
|
|
|
@MainActor extension Double {
|
|
func asCurrency() -> String {
|
|
let state = CurrencyState.shared
|
|
let converted = self * state.rate
|
|
return state.symbol + (groupedDecimalFormatter.string(from: NSNumber(value: converted)) ?? "\(converted)")
|
|
}
|
|
|
|
func asCompactCurrency() -> String {
|
|
let state = CurrencyState.shared
|
|
return String(format: "\(state.symbol)%.2f", self * state.rate)
|
|
}
|
|
|
|
func asCompactCurrencyWhole() -> String {
|
|
let state = CurrencyState.shared
|
|
return "\(state.symbol)\(Int((self * state.rate).rounded()))"
|
|
}
|
|
|
|
func asCompactTokens() -> String {
|
|
let n = self
|
|
if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) }
|
|
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
|
|
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
|
|
return String(format: "%.0f", n)
|
|
}
|
|
|
|
/// Formats a raw USD amount with a "$" and grouping, without applying the
|
|
/// display-currency rate. Used for the USD-denominated daily budget.
|
|
func asUSD() -> String {
|
|
"$" + (groupedDecimalFormatter.string(from: NSNumber(value: self)) ?? "\(Int(self))")
|
|
}
|
|
}
|
|
|
|
extension Int {
|
|
func asThousandsSeparated() -> String {
|
|
thousandsFormatter.string(from: NSNumber(value: self)) ?? "\(self)"
|
|
}
|
|
}
|