Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/CodexBar/UsageStore+BackgroundRefresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ extension UsageStore {
self.kiloScopeSnapshots = []
}
if provider == .claude {
self.widgetUsagePreservationBlockedProviders.insert(provider)
self.clearClaudeSwapAccountState()
}
self.clearTokenSnapshot(for: provider)
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/UsageStore+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ extension UsageStore {
}
self.lastKnownResetSnapshots[provider] = backfilled
self.snapshots[provider] = backfilled
self.widgetUsagePreservationBlockedProviders.remove(provider)
Comment thread
ChenZiHong-Gavin marked this conversation as resolved.
if provider == .deepseek {
self.clearDeepSeekProfileTransition()
}
Expand Down Expand Up @@ -969,6 +970,7 @@ extension UsageStore {
}

private func clearClaudeCredentialDerivedStateForCredentialSwapNow() {
self.widgetUsagePreservationBlockedProviders.insert(.claude)
self.snapshots.removeValue(forKey: .claude)
self.lastKnownResetSnapshots.removeValue(forKey: .claude)
self.errors[.claude] = nil
Expand Down Expand Up @@ -1049,6 +1051,7 @@ extension UsageStore {
self.lastSourceLabels.removeValue(forKey: provider)
self.errors[provider] = nil
self.knownLimitsAvailabilityByProvider[provider] = .unavailable
self.widgetUsagePreservationBlockedProviders.insert(provider)
self.failureGates[provider]?.reset()
return
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/UsageStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1554,6 +1554,7 @@ extension UsageStore {
accountDiscriminatorOverride: provider == .claude ? warningAccountDiscriminator : nil)
self.lastKnownResetSnapshots[provider] = backfilled
self.snapshots[provider] = backfilled
self.widgetUsagePreservationBlockedProviders.remove(provider)
if provider == .deepseek {
self.clearDeepSeekProfileTransition()
}
Expand Down
111 changes: 100 additions & 11 deletions Sources/CodexBar/UsageStore+WidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import WidgetKit

extension UsageStore {
func persistWidgetSnapshot(reason: String) {
let snapshot = self.makeWidgetSnapshot()
// A fresh process has token-cost data before a user-authorized Claude OAuth refresh can run.
// Keep the last queued snapshot in memory so back-to-back writes cannot race the on-disk cache.
let previousSnapshot = self.lastQueuedWidgetSnapshot ?? {
#if DEBUG
// Snapshot-save overrides must stay isolated from a developer's real app-group data.
guard self._test_widgetSnapshotSaveOverride == nil else { return nil }
#endif
return WidgetSnapshotStore.load()
}()
let snapshot = self.makeWidgetSnapshot(previousSnapshot: previousSnapshot)
self.lastQueuedWidgetSnapshot = snapshot
let previousTask = self.widgetSnapshotPersistTask
self.widgetSnapshotPersistTask = Task { @MainActor in
_ = await previousTask?.result
Expand All @@ -25,11 +35,14 @@ extension UsageStore {
}
}

private func makeWidgetSnapshot() -> WidgetSnapshot {
private func makeWidgetSnapshot(previousSnapshot: WidgetSnapshot?) -> WidgetSnapshot {
let now = Date()
let enabledProviders = self.enabledProviders()
let entries = UsageProvider.allCases.compactMap { provider in
self.makeWidgetEntry(for: provider, now: now)
self.makeWidgetEntry(
for: provider,
now: now,
previousEntry: previousSnapshot?.entries.first { $0.provider == provider })
}
return WidgetSnapshot(
entries: entries,
Expand All @@ -38,10 +51,38 @@ extension UsageStore {
generatedAt: now)
}

private func makeWidgetEntry(for provider: UsageProvider, now: Date) -> WidgetSnapshot.ProviderEntry? {
private func makeWidgetEntry(
for provider: UsageProvider,
now: Date,
previousEntry: WidgetSnapshot.ProviderEntry?) -> WidgetSnapshot.ProviderEntry?
{
let snapshot = self.snapshots[provider]
let storedTokenSnapshot = self.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot
guard snapshot != nil || (provider == .claude && storedTokenSnapshot != nil) else { return nil }
let claudeQuotaOwnerKey: String? = if provider == .claude,
let account = self.settings.effectiveSelectedTokenAccount(for: provider)
{
self.tokenAccountSnapshotCacheKey(provider: provider, account: account)
} else {
nil
}
let preservedClaudeUsage: PreservedClaudeWidgetUsage? = if provider == .claude,
snapshot == nil,
!self.widgetUsagePreservationBlockedProviders
.contains(provider),
Comment thread
ChenZiHong-Gavin marked this conversation as resolved.
self.knownLimitsAvailabilityByProvider[provider]?
.isUnavailable != true
{
Self.preservedClaudeWidgetUsage(
from: previousEntry,
expectedQuotaOwnerKey: claudeQuotaOwnerKey)
} else {
nil
}
guard snapshot != nil ||
(provider == .claude && (storedTokenSnapshot != nil || preservedClaudeUsage != nil))
else {
return nil
}

let tokenSnapshot = storedTokenSnapshot
let dailyUsage = tokenSnapshot?.daily.map { entry in
Expand All @@ -52,7 +93,9 @@ extension UsageStore {
} ?? []

let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot, provider: provider)
let usageRows = snapshot.map { self.widgetUsageRows(provider: provider, snapshot: $0, now: now) } ?? []
let usageRows = snapshot.map {
self.widgetUsageRows(provider: provider, snapshot: $0, now: now)
} ?? preservedClaudeUsage?.usageRows ?? []

let creditsRemaining: Double?
let codeReviewRemaining: Double?
Expand All @@ -75,19 +118,65 @@ extension UsageStore {
} else {
nil
}
let quotaOwnerKey: String? = if provider == .claude {
snapshot != nil ? claudeQuotaOwnerKey : preservedClaudeUsage?.quotaOwnerKey
} else {
nil
}

return WidgetSnapshot.ProviderEntry(
provider: provider,
updatedAt: snapshot?.updatedAt ?? tokenSnapshot?.updatedAt ?? now,
primary: snapshot?.primary,
secondary: snapshot?.secondary,
tertiary: snapshot?.tertiary,
updatedAt: snapshot?.updatedAt ?? preservedClaudeUsage?.updatedAt ?? tokenSnapshot?.updatedAt ?? now,
primary: snapshot?.primary ?? preservedClaudeUsage?.primary,
secondary: snapshot?.secondary ?? preservedClaudeUsage?.secondary,
tertiary: snapshot?.tertiary ?? preservedClaudeUsage?.tertiary,
usageRows: usageRows,
creditsRemaining: creditsRemaining,
codeReviewRemainingPercent: codeReviewRemaining,
tokenUsage: tokenUsage,
dailyUsage: dailyUsage,
providerCost: providerCost)
providerCost: providerCost,
quotaOwnerKey: quotaOwnerKey)
}

private struct PreservedClaudeWidgetUsage {
let updatedAt: Date
let primary: RateWindow?
let secondary: RateWindow?
let tertiary: RateWindow?
let usageRows: [WidgetSnapshot.WidgetUsageRowSnapshot]?
let quotaOwnerKey: String?
}

private nonisolated static func preservedClaudeWidgetUsage(
from entry: WidgetSnapshot.ProviderEntry?,
expectedQuotaOwnerKey: String?) -> PreservedClaudeWidgetUsage?
{
guard let entry, entry.provider == .claude else { return nil }
guard entry.quotaOwnerKey == expectedQuotaOwnerKey else { return nil }

let primary = entry.primary?.isSyntheticPlaceholder == true ? nil : entry.primary
let secondary = entry.secondary?.isSyntheticPlaceholder == true ? nil : entry.secondary
let tertiary = entry.tertiary?.isSyntheticPlaceholder == true ? nil : entry.tertiary
let usageRows = entry.usageRows?.filter { row in
guard row.window?.isSyntheticPlaceholder != true else { return false }
return switch row.id {
case "primary": primary != nil
case "secondary": secondary != nil
case "tertiary": tertiary != nil
default: row.percentLeft != nil
}
}
guard primary != nil || secondary != nil || tertiary != nil || usageRows?.isEmpty == false else {
return nil
}
return PreservedClaudeWidgetUsage(
updatedAt: entry.updatedAt,
primary: primary,
secondary: secondary,
tertiary: tertiary,
usageRows: usageRows,
quotaOwnerKey: entry.quotaOwnerKey)
}

nonisolated static func widgetTokenUsageSummary(
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ final class UsageStore {
@ObservationIgnored var _test_startupConnectivityRetrySleepOverride: (@MainActor (
TimeInterval) async throws -> Void)?
@ObservationIgnored var widgetSnapshotPersistTask: Task<Void, Never>?
@ObservationIgnored var lastQueuedWidgetSnapshot: WidgetSnapshot?
@ObservationIgnored var widgetUsagePreservationBlockedProviders: Set<UsageProvider> = []

@ObservationIgnored let codexFetcher: UsageFetcher
@ObservationIgnored let claudeFetcher: any ClaudeUsageFetching
Expand Down
5 changes: 4 additions & 1 deletion Sources/CodexBarCore/WidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public struct WidgetSnapshot: Codable, Sendable {
public let tokenUsage: TokenUsageSummary?
public let dailyUsage: [DailyUsagePoint]
public let providerCost: ProviderCostSnapshot?
public let quotaOwnerKey: String?

public init(
provider: UsageProvider,
Expand All @@ -39,7 +40,8 @@ public struct WidgetSnapshot: Codable, Sendable {
codeReviewRemainingPercent: Double?,
tokenUsage: TokenUsageSummary?,
dailyUsage: [DailyUsagePoint],
providerCost: ProviderCostSnapshot? = nil)
providerCost: ProviderCostSnapshot? = nil,
quotaOwnerKey: String? = nil)
{
self.provider = provider
self.updatedAt = updatedAt
Expand All @@ -52,6 +54,7 @@ public struct WidgetSnapshot: Codable, Sendable {
self.tokenUsage = tokenUsage
self.dailyUsage = dailyUsage
self.providerCost = providerCost
self.quotaOwnerKey = quotaOwnerKey
}
}

Expand Down
164 changes: 164 additions & 0 deletions Tests/CodexBarTests/UsageStoreWidgetSnapshotAccountTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar

@MainActor
struct UsageStoreWidgetSnapshotAccountTests {
@Test
func `widget snapshot does not preserve Claude quota after selected account changes`() async throws {
let suite = "UsageStoreWidgetSnapshotTests-claude-account-change-drops-quota"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)

let settings = SettingsStore(
userDefaults: defaults,
configStore: testConfigStore(suiteName: suite),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore())
settings.statusChecksEnabled = false
settings.addTokenAccount(provider: .claude, label: "Primary", token: "primary-token")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "secondary-token")
settings.setActiveTokenAccountIndex(0, for: .claude)

let store = UsageStore(
fetcher: UsageFetcher(environment: [:]),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
let accounts = settings.tokenAccounts(for: .claude)
let primaryAccount = try #require(accounts.first)
let quotaOwnerKey = store.tokenAccountSnapshotCacheKey(provider: .claude, account: primaryAccount)
let quotaUpdatedAt = Date(timeIntervalSince1970: 1_800_000_000)
let tokenUpdatedAt = quotaUpdatedAt.addingTimeInterval(60)
let quota = RateWindow(
usedPercent: 28,
windowMinutes: 300,
resetsAt: quotaUpdatedAt.addingTimeInterval(3600),
resetDescription: nil)
store.lastQueuedWidgetSnapshot = WidgetSnapshot(
entries: [
WidgetSnapshot.ProviderEntry(
provider: .claude,
updatedAt: quotaUpdatedAt,
primary: quota,
secondary: nil,
tertiary: nil,
usageRows: [
WidgetSnapshot.WidgetUsageRowSnapshot(
id: "primary",
title: "Session",
percentLeft: 72),
],
creditsRemaining: nil,
codeReviewRemainingPercent: nil,
tokenUsage: nil,
dailyUsage: [],
quotaOwnerKey: quotaOwnerKey),
],
enabledProviders: [.claude],
generatedAt: quotaUpdatedAt)

settings.setActiveTokenAccountIndex(1, for: .claude)
store._setTokenSnapshotForTesting(
CostUsageTokenSnapshot(
sessionTokens: 4300,
sessionCostUSD: 1.50,
last30DaysTokens: 43000,
last30DaysCostUSD: 13.50,
daily: [],
updatedAt: tokenUpdatedAt),
provider: .claude)

var widgetSnapshots: [WidgetSnapshot] = []
store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) }
defer { store._test_widgetSnapshotSaveOverride = nil }

store.persistWidgetSnapshot(reason: "claude-account-change-drops-quota-test")
await store.widgetSnapshotPersistTask?.value

let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude })
#expect(entry.primary == nil)
#expect(entry.usageRows?.isEmpty == true)
#expect(entry.quotaOwnerKey == nil)
#expect(entry.tokenUsage?.sessionTokens == 4300)
}

@Test
func `stacked Claude account success re-enables quota preservation`() async throws {
let suite = "UsageStoreWidgetSnapshotTests-claude-stacked-success-unblocks-quota"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)

let settings = SettingsStore(
userDefaults: defaults,
configStore: testConfigStore(suiteName: suite),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore())
settings.statusChecksEnabled = false
settings.multiAccountMenuLayout = .stacked
settings.addTokenAccount(provider: .claude, label: "Primary", token: "primary-token")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "secondary-token")
settings.setActiveTokenAccountIndex(0, for: .claude)

let store = UsageStore(
fetcher: UsageFetcher(environment: [:]),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
let account = try #require(settings.effectiveSelectedTokenAccount(for: .claude))
let quotaUpdatedAt = Date(timeIntervalSince1970: 1_800_000_000)
let tokenUpdatedAt = quotaUpdatedAt.addingTimeInterval(60)
let quota = RateWindow(
usedPercent: 28,
windowMinutes: 300,
resetsAt: quotaUpdatedAt.addingTimeInterval(3600),
resetDescription: nil)
let outcome = ProviderFetchOutcome(
result: .success(ProviderFetchResult(
usage: UsageSnapshot(
primary: quota,
secondary: nil,
updatedAt: quotaUpdatedAt),
credits: nil,
dashboard: nil,
sourceLabel: "fixture",
strategyID: "fixture.api-token",
strategyKind: .apiToken)),
attempts: [])

store.widgetUsagePreservationBlockedProviders.insert(.claude)
await store.applySelectedOutcome(
outcome,
provider: .claude,
account: account,
fallbackSnapshot: nil)
#expect(!store.widgetUsagePreservationBlockedProviders.contains(.claude))

var widgetSnapshots: [WidgetSnapshot] = []
store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) }
defer { store._test_widgetSnapshotSaveOverride = nil }

store.persistWidgetSnapshot(reason: "claude-stacked-success-primes-quota-test")
await store.widgetSnapshotPersistTask?.value
let freshEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude })
#expect(freshEntry.primary == quota)
#expect(freshEntry.quotaOwnerKey != nil)

store.snapshots.removeValue(forKey: .claude)
store._setTokenSnapshotForTesting(
CostUsageTokenSnapshot(
sessionTokens: 4300,
sessionCostUSD: 1.50,
last30DaysTokens: 43000,
last30DaysCostUSD: 13.50,
daily: [],
updatedAt: tokenUpdatedAt),
provider: .claude)
store.persistWidgetSnapshot(reason: "claude-stacked-success-preserves-quota-test")
await store.widgetSnapshotPersistTask?.value

let preservedEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude })
#expect(preservedEntry.primary == quota)
#expect(preservedEntry.quotaOwnerKey == freshEntry.quotaOwnerKey)
#expect(preservedEntry.tokenUsage?.sessionTokens == 4300)
}
}
Loading