90e6651cd7
* Detect Google's live Gemini consumer-tier shutdown Google answers the June 2026 consumer shutdown with an HTTP 200 loadCodeAssist body — no currentTier, the consumer tier listed under ineligibleTiers with UNSUPPORTED_CLIENT — and then fails retrieveUserQuota with a 403 SUBSCRIPTION_REQUIRED that carries no migration wording. The probe only scanned non-200 bodies for the migration signal, so the shutdown surfaced as a bare "HTTP 403", the Antigravity migration guidance never appeared, and the login action still deleted ~/.gemini/oauth_creds.json to launch a Gemini CLI sign-in that Google rejects. Read ineligibleTiers on the 200 body, and map the quota 403 to consumerTierDeprecated only when that flag was set and the account is not on standard-tier, so licensed Standard/Enterprise accounts keep their generic error. Record which sentinel fired in UsageStore.geminiMigrationObservation: either one keeps driving the settings action, while only Google's own response guards the login action — the local Antigravity handoff must still be able to relaunch Gemini CLI, which is the fix for that case. Claude-Session: https://claude.ai/code/session_019jCyMcSJBKdDW9UQ7xxqc6 * Let a named paid tier outrank the unsupported-client listing resolveAccountPlan treats paidTier.name as authoritative even when currentTier is missing, and GeminiStatusProbePlanTests pins that with `paid tier name survives missing current tier`. The new unsupported-client branch ran before that resolver, so a response carrying paidTier.name, no currentTier, and the ineligible consumer tier would have been read as a consumer shutdown — clearing the paid snapshot and arming the login guard. Fold the paid signal into isConsumerClientUnsupported so it gates the deprecation throw and the quota-403 mapping alike. Google's captured shutdown response carries no paidTier, so the consumer path is unchanged. Claude-Session: https://claude.ai/code/session_01SjKShndyQTKankQekQdBcJ * Keep Workspace accounts out of the consumer shutdown path resolveAccountPlan reads free-tier plus an `hd` claim as Workspace, and docs/gemini.md states Workspace and education accounts stay on Gemini. The unsupported-client branch runs before that resolver and never saw the claim, so a hosted-domain account carrying Google's ineligible consumer-tier entry could be classified as shut down — clearing its snapshot and arming the login guard against a sign-in that still works. Fold the hosted domain into isConsumerClientUnsupported next to the paid-tier signal, so both the loadCodeAssist throw and the quota-403 mapping skip accounts the June 2026 shutdown does not cover. The claim is already extracted before loadCodeAssistStatus runs, so it only needed threading through. Move the response parsing into the file's parsing extension to stay under type_body_length. Claude-Session: https://claude.ai/code/session_01SjKShndyQTKankQekQdBcJ * Warn instead of block when Gemini login hits the shutdown The login guard returned before the credential-clearing account switch, and the observation only clears on a successful Gemini refresh or a provider reset — neither reachable from the blocked action. A user moving off an affected consumer account to a Workspace, education, or Code Assist Standard account was stuck with migration guidance for the rest of the session, and the alert never said so. Offer "Switch Account…" beside Cancel, with Cancel as the default since confirming clears the stored credentials. Confirming re-runs the ordinary login without the guard. The observation is left alone, so the settings action stays available and the next attempt warns again. Claude-Session: https://claude.ai/code/session_01SjKShndyQTKankQekQdBcJ * Take main changelog; add #3139 entry --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1694 lines
83 KiB
Swift
1694 lines
83 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import Foundation
|
|
import Observation
|
|
import SweetCookieKit
|
|
|
|
// MARK: - Observation helpers
|
|
|
|
@MainActor
|
|
extension UsageStore {
|
|
var menuObservationToken: Int {
|
|
_ = self.snapshots
|
|
_ = self.errors
|
|
_ = self.diagnostics
|
|
_ = self.knownLimitsAvailabilityByProvider
|
|
_ = self.lastSourceLabels
|
|
_ = self.lastFetchAttempts
|
|
_ = (self.accountSnapshots, self.tokenAccountLiveStateProviders, self.codexAccountSnapshots)
|
|
_ = self.kiloScopeSnapshots
|
|
_ = self.claudeSwapAccountSnapshots
|
|
_ = self.claudeSwapLastError
|
|
_ = self.claudeSwapRevision
|
|
_ = self.tokenSnapshots
|
|
_ = self.tokenErrors
|
|
_ = self.tokenRefreshInFlight
|
|
_ = self.codexCostCatchUpActivity
|
|
_ = self.credits
|
|
_ = self.lastCreditsError
|
|
_ = self.openAIDashboard
|
|
_ = self.lastOpenAIDashboardError
|
|
_ = self.openAIDashboardRequiresLogin
|
|
_ = self.openAIDashboardAttachmentRevision
|
|
_ = self.versions
|
|
_ = self.isRefreshing
|
|
_ = self.hasForcedRefreshEnrichmentInFlight
|
|
_ = self.refreshingProviders
|
|
_ = self.pathDebugInfo
|
|
_ = self.statuses
|
|
_ = self.probeLogs
|
|
_ = self.historicalPaceRevision
|
|
_ = self.planUtilizationHistoryRevision
|
|
_ = self.providerStorageFootprints
|
|
return 0
|
|
}
|
|
|
|
var iconObservationToken: Int {
|
|
_ = self.snapshots
|
|
_ = self.claudeSwapAccountSnapshots
|
|
_ = self.claudeSwapRevision
|
|
_ = self.errors
|
|
_ = self.diagnostics
|
|
_ = self.knownLimitsAvailabilityByProvider
|
|
_ = self.credits
|
|
_ = self.lastCreditsError
|
|
_ = self.openAIDashboard
|
|
_ = self.lastOpenAIDashboardError
|
|
_ = self.openAIDashboardRequiresLogin
|
|
_ = self.refreshingProviders
|
|
_ = self.statuses
|
|
_ = self.tokenSnapshotPublications
|
|
_ = self.spendDashboardTokenPublications
|
|
_ = self.spendDashboardPublication.revision
|
|
_ = self.historicalPaceRevision
|
|
return 0
|
|
}
|
|
|
|
func observeSettingsChanges() {
|
|
withObservationTracking {
|
|
_ = self.backgroundWorkSettingsObservationToken
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.observeSettingsChanges()
|
|
self.invalidateProviderAvailabilityCache()
|
|
self.probeLogs = [:]
|
|
guard self.startupBehavior.automaticallyStartsBackgroundWork else { return }
|
|
self.startTimer()
|
|
self.updateProviderRuntimes()
|
|
let enabledNow = Set(self.settings.enabledProvidersOrdered(
|
|
metadataByProvider: self.providerMetadata))
|
|
if enabledNow != self.versionDetectionProviders {
|
|
self.detectVersions()
|
|
}
|
|
await self.refreshHistoricalDatasetIfNeeded()
|
|
await self.refreshForSettingsChange()
|
|
}
|
|
}
|
|
}
|
|
|
|
var backgroundWorkSettingsObservationToken: Int {
|
|
_ = self.settings.backgroundWorkSettingsRevision
|
|
return 0
|
|
}
|
|
|
|
var attachedOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? {
|
|
guard self.openAIDashboardAttachmentAuthorized else { return nil }
|
|
return self.openAIDashboard
|
|
}
|
|
|
|
private static func isRunningTestsProcess() -> Bool {
|
|
let environment = ProcessInfo.processInfo.environment
|
|
let testKeys = ["XCTestConfigurationFilePath", "XCTestSessionIdentifier", "SWIFT_TESTING_ENABLED"]
|
|
return testKeys.contains(where: { environment[$0] != nil }) || CommandLine.arguments.contains { argument in
|
|
argument.contains("xctest") || argument.contains("swift-testing")
|
|
}
|
|
}
|
|
|
|
/// Returns the login method (plan type) for the specified provider, if available.
|
|
private func loginMethod(for provider: UsageProvider) -> String? {
|
|
self.snapshots[provider.instanceID]?.loginMethod(for: provider)
|
|
}
|
|
|
|
/// Returns true if the Claude account appears to be a subscription (Max, Pro, Ultra, Team).
|
|
/// Returns false for API users or when plan cannot be determined.
|
|
func isClaudeSubscription() -> Bool {
|
|
// Provider-specific by design: Claude subscription plans choose its consumer dashboard account action.
|
|
Self.isSubscriptionPlan(self.loginMethod(for: .claude))
|
|
}
|
|
|
|
/// Determines if a login method string indicates a Claude subscription plan.
|
|
/// Known subscription indicators: Max, Pro, Ultra, Team (case-insensitive).
|
|
nonisolated static func isSubscriptionPlan(_ loginMethod: String?) -> Bool {
|
|
ClaudePlan.isSubscriptionLoginMethod(loginMethod)
|
|
}
|
|
|
|
var preferredSnapshot: UsageSnapshot? {
|
|
for provider in self.enabledProviders() {
|
|
if let snap = self.snapshots[provider] {
|
|
return snap
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class UsageStore {
|
|
nonisolated static let resetBoundaryRefreshGraceSeconds: TimeInterval = 30
|
|
nonisolated static let resetBoundaryRefreshMinimumDelaySeconds: TimeInterval = 5
|
|
|
|
private struct ProviderAvailabilityCacheEntry {
|
|
let available: Bool
|
|
let configRevision: Int
|
|
let expiresAt: Date
|
|
|
|
func isValid(now: Date, configRevision: Int) -> Bool {
|
|
self.configRevision == configRevision && self.expiresAt > now
|
|
}
|
|
}
|
|
|
|
struct AccountInfoCacheEntry {
|
|
let account: AccountInfo
|
|
let configRevision: Int
|
|
let expiresAt: Date
|
|
|
|
func isValid(now: Date, configRevision: Int) -> Bool {
|
|
self.configRevision == configRevision && self.expiresAt > now
|
|
}
|
|
}
|
|
|
|
enum CodexCreditsSource {
|
|
case none
|
|
case api
|
|
case dashboardWeb
|
|
}
|
|
|
|
var snapshots: [ProviderInstanceID: UsageSnapshot] = [:]
|
|
var errors: [ProviderInstanceID: String] = [:]
|
|
var diagnostics: [ProviderInstanceID: String] = [:]
|
|
var geminiMigrationObservation: GeminiMigrationObservation = .none
|
|
var knownLimitsAvailabilityByProvider: [ProviderInstanceID: UsageLimitsAvailability] = [:]
|
|
var lastSourceLabels: [ProviderInstanceID: String] = [:]
|
|
var lastFetchAttempts: [ProviderInstanceID: [ProviderFetchAttempt]] = [:]
|
|
var accountSnapshots: [ProviderInstanceID: [TokenAccountUsageSnapshot]] = [:]
|
|
var tokenAccountLiveStateProviders: Set<ProviderInstanceID> = []
|
|
var codexAccountSnapshots: [CodexAccountUsageSnapshot] = []
|
|
var kiloScopeSnapshots: [KiloScopeSnapshot] = []
|
|
var claudeSwapAccountSnapshots: [ProviderAccountUsageSnapshot] = []
|
|
var claudeSwapLastRefreshAt: Date?
|
|
var claudeSwapLastError: String?
|
|
var claudeSwapDetectedVersion: String?
|
|
var claudeSwapRevision: UInt64 = 0
|
|
@ObservationIgnored var claudeSwapRefreshTask: Task<Void, Never>?
|
|
@ObservationIgnored var claudeSwapTransientState = ClaudeSwapTransientState()
|
|
var tokenSnapshots: [ProviderInstanceID: CostUsageTokenSnapshot] = [:]
|
|
var tokenSnapshotPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:]
|
|
var tokenSnapshotPublicationRevisions: [ProviderInstanceID: UInt64] = [:]
|
|
var spendDashboardTokenPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:]
|
|
var spendDashboardTokenPublicationRevisions: [ProviderInstanceID: UInt64] = [:]
|
|
var spendDashboardPublication = SpendDashboardPublication.empty
|
|
@ObservationIgnored var sharedSpendDashboardControllerStorage: SpendDashboardController?
|
|
@ObservationIgnored var sharedSpendDashboardObservationStarted = false
|
|
@ObservationIgnored var sharedSpendDashboardObservationDebounceTask: Task<Void, Never>?
|
|
@ObservationIgnored var sharedSpendDashboardTokenPublicationDebounceTask: Task<Void, Never>?
|
|
var tokenErrors: [ProviderInstanceID: String] = [:]
|
|
var tokenRefreshInFlight: Set<ProviderInstanceID> = []
|
|
var codexCostCatchUpActivity: CodexCostCatchUpActivity?
|
|
var spendDashboardCodexCostCatchUpActivity: CodexCostCatchUpActivity?
|
|
var spendDashboardCodexCostCatchUpRevision: UInt64 = 0
|
|
var credits: CreditsSnapshot?
|
|
var lastCreditsError: String?
|
|
var openAIDashboard: OpenAIDashboardSnapshot?
|
|
var lastOpenAIDashboardError: String?
|
|
var openAIDashboardRequiresLogin: Bool = false
|
|
var openAIDashboardCookieImportStatus: String?
|
|
var openAIDashboardCookieImportDebugLog: String?
|
|
var versions: [ProviderInstanceID: String] = [:]
|
|
@ObservationIgnored var versionDetectionProviders: Set<ProviderInstanceID> = []
|
|
@ObservationIgnored private(set) var versionDetectionTask: Task<Void, Never>?
|
|
@ObservationIgnored var claudeVersionRefreshTask: Task<Void, Never>?
|
|
var isRefreshing = false
|
|
var hasForcedRefreshEnrichmentInFlight = false
|
|
var refreshingProviders: Set<ProviderInstanceID> = []
|
|
var debugForceAnimation = false
|
|
var pathDebugInfo: PathDebugSnapshot = .empty
|
|
var statuses: [ProviderInstanceID: ProviderStatus] = [:]
|
|
var statusComponents: [ProviderInstanceID: [ProviderStatusComponent]] = [:]
|
|
var probeLogs: [ProviderInstanceID: String] = [:]
|
|
var historicalPaceRevision: Int = 0
|
|
var planUtilizationHistoryRevision: Int = 0
|
|
var providerStorageFootprints: [ProviderInstanceID: ProviderStorageFootprint] = [:]
|
|
@ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot?
|
|
@ObservationIgnored var lastCreditsSnapshotAccountKey: String?
|
|
@ObservationIgnored var lastCreditsSource: CodexCreditsSource = .none
|
|
@ObservationIgnored var creditsFailureStreak: Int = 0
|
|
@ObservationIgnored var openAIDashboardAttachmentAuthorized: Bool = false {
|
|
didSet {
|
|
guard self.openAIDashboardAttachmentAuthorized != oldValue else { return }
|
|
self.openAIDashboardAttachmentRevision &+= 1
|
|
}
|
|
}
|
|
|
|
var openAIDashboardAttachmentRevision = 0
|
|
@ObservationIgnored var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot?
|
|
@ObservationIgnored var lastOpenAIDashboardAttachmentAuthorized: Bool = false
|
|
@ObservationIgnored var lastOpenAIDashboardTargetEmail: String?
|
|
@ObservationIgnored var lastOpenAIDashboardTargetIsolationKey: String?
|
|
@ObservationIgnored var lastOpenAIDashboardAttemptAt: Date?
|
|
@ObservationIgnored var lastOpenAIDashboardPageScrapeAt: Date?
|
|
@ObservationIgnored var lastOpenAIDashboardCookieImportAttemptAt: Date?
|
|
@ObservationIgnored var lastOpenAIDashboardCookieImportEmail: String?
|
|
@ObservationIgnored var lastCodexAccountScopedRefreshGuard: CodexAccountScopedRefreshGuard?
|
|
@ObservationIgnored var lastCodexUsagePublicationGuard: CodexAccountScopedRefreshGuard?
|
|
@ObservationIgnored var lastKnownLiveSystemCodexEmail: String?
|
|
@ObservationIgnored var openAIWebAccountDidChange: Bool = false
|
|
@ObservationIgnored var creditsRefreshTask: Task<Void, Never>?
|
|
@ObservationIgnored var creditsRefreshTaskKey: String?
|
|
@ObservationIgnored var openAIDashboardBackgroundRefreshTask: Task<Void, Never>?
|
|
@ObservationIgnored var openAIDashboardBackgroundRefreshTaskKey: String?
|
|
@ObservationIgnored var openAIDashboardRefreshTask: Task<Void, Never>?
|
|
@ObservationIgnored var openAIDashboardRefreshTaskKey: String?
|
|
@ObservationIgnored var openAIDashboardRefreshTaskToken: UUID?
|
|
@ObservationIgnored var _test_openAIDashboardCookieImportOverride: (@MainActor (
|
|
String?,
|
|
Bool,
|
|
ProviderCookieSource,
|
|
CookieHeaderCache.Scope?,
|
|
@escaping (String) -> Void) async throws -> OpenAIDashboardBrowserCookieImporter.ImportResult)?
|
|
@ObservationIgnored var _test_openAIDashboardLoaderOverride: (@MainActor (
|
|
String?,
|
|
@escaping (String) -> Void,
|
|
Bool,
|
|
TimeInterval) async throws -> OpenAIDashboardSnapshot)?
|
|
@ObservationIgnored var _test_codexCreditsLoaderOverride: (@MainActor () async throws -> CreditsSnapshot)?
|
|
@ObservationIgnored var _test_codexResetCreditsFetcherOverride: CodexResetCreditsFetcher?
|
|
@ObservationIgnored var _test_widgetSnapshotSaveOverride: (@MainActor (WidgetSnapshot) async -> Void)?
|
|
@ObservationIgnored var _test_providerRefreshOverride: (@MainActor (UsageProvider) async -> Void)?
|
|
@ObservationIgnored var _test_providerFetchOutcomeOverride: (@MainActor (
|
|
UsageProvider) async -> ProviderFetchOutcome)?
|
|
@ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)?
|
|
@ObservationIgnored var _test_tokenUsageSnapshotLoaderOverride: (@MainActor (
|
|
UsageProvider,
|
|
Bool,
|
|
Date,
|
|
String?,
|
|
Int) async throws -> CostUsageTokenSnapshot)?
|
|
@ObservationIgnored var _test_cachedCodexTokenSnapshotLoaderOverride: (@MainActor (
|
|
Date,
|
|
String?,
|
|
Int) async -> (
|
|
snapshot: CostUsageTokenSnapshot,
|
|
lastRefreshAt: Date?,
|
|
staleSnapshotUpdatedAt: Date?)?)?
|
|
@ObservationIgnored var _test_codexCostCatchUpStatusOverride: (@MainActor (
|
|
String?) async -> CostUsageFetcher.CodexScanCatchUpStatus)?
|
|
@ObservationIgnored var _test_codexCostCatchUpAdvanceOverride: (@MainActor (
|
|
Date,
|
|
String?,
|
|
Int) async throws -> CostUsageFetcher.CodexScanCatchUpStatus)?
|
|
@ObservationIgnored var _test_codexCostCatchUpSleepOverride: (@MainActor (
|
|
TimeInterval) async throws -> Void)?
|
|
@ObservationIgnored var _test_codexCostCatchUpResourceStateOverride: (@MainActor () -> (
|
|
powerSource: CodexCostCatchUpPowerSource,
|
|
lowPowerModeEnabled: Bool,
|
|
thermalState: ProcessInfo.ThermalState))?
|
|
@ObservationIgnored var _test_spendDashboardCodexCostCatchUpStatusOverride: (@MainActor (
|
|
CodexSpendScanRequest) async -> CostUsageFetcher.CodexScanCatchUpStatus)?
|
|
@ObservationIgnored var _test_spendDashboardCodexCostCatchUpAdvanceOverride: (@MainActor (
|
|
CodexSpendScanRequest,
|
|
Date,
|
|
Int) async throws -> CostUsageFetcher.CodexScanCatchUpStatus)?
|
|
@ObservationIgnored var _test_spendDashboardCodexCostCatchUpSleepOverride: (@MainActor (
|
|
TimeInterval) async throws -> Void)?
|
|
@ObservationIgnored var _test_spendDashboardCodexCostCatchUpResourceStateOverride: (@MainActor () -> (
|
|
powerSource: CodexCostCatchUpPowerSource,
|
|
lowPowerModeEnabled: Bool,
|
|
thermalState: ProcessInfo.ThermalState))?
|
|
@ObservationIgnored var _test_providerStatusFetchOverride: (@MainActor (
|
|
UsageProvider) async throws -> ProviderStatus)?
|
|
@ObservationIgnored var _test_forcedRefreshEnrichmentWaitObserver: (@MainActor () -> Void)?
|
|
@ObservationIgnored var _test_startupConnectivityRetryScheduled: (@MainActor (Int, TimeInterval) -> Void)?
|
|
@ObservationIgnored var _test_startupConnectivityRetrySleepOverride: (@MainActor (
|
|
TimeInterval) async throws -> Void)?
|
|
@ObservationIgnored var widgetSnapshotPersistTask: Task<Void, Never>?
|
|
@ObservationIgnored var lastQueuedWidgetSnapshot: WidgetSnapshot?
|
|
@ObservationIgnored let widgetSnapshotURL: URL?
|
|
@ObservationIgnored var widgetUsagePreservationBlockedProviders: Set<ProviderInstanceID> = []
|
|
|
|
@ObservationIgnored let codexFetcher: UsageFetcher
|
|
@ObservationIgnored let claudeFetcher: any ClaudeUsageFetching
|
|
@ObservationIgnored let costUsageFetcher: CostUsageFetcher
|
|
@ObservationIgnored let browserDetection: BrowserDetection
|
|
@ObservationIgnored private let registry: ProviderRegistry
|
|
@ObservationIgnored let settings: SettingsStore
|
|
@ObservationIgnored let environmentBase: [String: String]
|
|
@ObservationIgnored let pluginApprovalStore = ProviderPluginApprovalStore()
|
|
@ObservationIgnored let sessionQuotaNotifier: any SessionQuotaNotifying
|
|
@ObservationIgnored let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota)
|
|
// Provider-specific by design: OpenAI web and Augment runtime diagnostics have dedicated app-owned log streams.
|
|
@ObservationIgnored let openAIWebLogger = CodexBarLog.logger(LogCategories.provider(.openai, scope: "web"))
|
|
@ObservationIgnored private let tokenCostLogger = CodexBarLog.logger(LogCategories.tokenCost)
|
|
@ObservationIgnored let augmentLogger = CodexBarLog.logger(LogCategories.provider(.augment))
|
|
@ObservationIgnored let providerLogger = CodexBarLog.logger(LogCategories.providers)
|
|
@ObservationIgnored let adaptiveRefreshLogger = CodexBarLog.logger(LogCategories.adaptiveRefresh)
|
|
@ObservationIgnored var openAIWebDebugLines: [String] = []
|
|
@ObservationIgnored var failureGates: [ProviderInstanceID: ConsecutiveFailureGate] = [:]
|
|
@ObservationIgnored var tokenFailureGates: [ProviderInstanceID: ConsecutiveFailureGate] = [:]
|
|
@ObservationIgnored var providerSpecs: [UsageProvider: ProviderSpec] = [:]
|
|
@ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata]
|
|
@ObservationIgnored var providerRuntimes: [ProviderInstanceID: any ProviderRuntime] = [:]
|
|
@ObservationIgnored var providerRefreshCoordinator = ProviderRefreshCoordinator<ProviderInstanceID>()
|
|
@ObservationIgnored var providerRefreshPublicationContexts:
|
|
[ProviderInstanceID: ProviderRefreshPublicationContext] = [:]
|
|
@ObservationIgnored var providerCleanupRevisions: [ProviderInstanceID: UInt64] = [:]
|
|
@ObservationIgnored private var providerAvailabilityCache:
|
|
[ProviderInstanceID: ProviderAvailabilityCacheEntry] = [:]
|
|
@ObservationIgnored var accountInfoCache: [ProviderInstanceID: AccountInfoCacheEntry] = [:]
|
|
@ObservationIgnored private var timerTask: Task<Void, Never>?
|
|
/// In-memory only; resets on every launch.
|
|
@ObservationIgnored private(set) var lastMenuOpenAt: Date?
|
|
/// Latest local Codex/Claude transcript activity observed by the existing session scanner.
|
|
/// In-memory only; paths and session identities never enter the refresh policy.
|
|
@ObservationIgnored private(set) var lastCodingActivityAt: Date?
|
|
@ObservationIgnored var adaptiveRefreshScheduledAt: Date?
|
|
@ObservationIgnored var tokenRefreshSequenceTask: Task<Void, Never>?
|
|
@ObservationIgnored var tokenRefreshSequenceToken: UUID?
|
|
@ObservationIgnored var tokenRefreshSequenceProvider: ProviderInstanceID?
|
|
@ObservationIgnored var tokenRefreshSequenceIsForcedAllPass = false
|
|
@ObservationIgnored var pendingForcedTokenRefresh = false
|
|
@ObservationIgnored var lastForcedTokenRefreshStartedAt: Date?
|
|
@ObservationIgnored var tokenRefreshRetryProviders: Set<ProviderInstanceID> = []
|
|
@ObservationIgnored var codexCostCatchUpTask: Task<Void, Never>?
|
|
@ObservationIgnored var codexCostCatchUpToken: UUID?
|
|
@ObservationIgnored var codexCostCatchUpScopeSignature: String?
|
|
@ObservationIgnored var codexCostCatchUpMode: CodexCostCatchUpMode = .automatic
|
|
@ObservationIgnored var codexCostCatchUpStopRequested = false
|
|
@ObservationIgnored var codexCostCatchUpPassIsRunning = false
|
|
@ObservationIgnored var codexCostCatchUpRestartRequested = false
|
|
@ObservationIgnored var spendDashboardCodexCostCatchUpTask: Task<Void, Never>?
|
|
@ObservationIgnored var spendDashboardCodexCostCatchUpToken: UUID?
|
|
@ObservationIgnored var spendDashboardCodexCostCatchUpScopeSignature: String?
|
|
@ObservationIgnored var spendDashboardCodexCostCatchUpMode: CodexCostCatchUpMode = .automatic
|
|
@ObservationIgnored var spendDashboardCodexCostCatchUpStopRequested = false
|
|
@ObservationIgnored var spendDashboardCodexCostCatchUpPassIsRunning = false
|
|
@ObservationIgnored var spendDashboardCodexCostCatchUpRestartRequested = false
|
|
@ObservationIgnored var forcedRefreshEnrichmentTask: Task<Void, Never>?
|
|
@ObservationIgnored var forcedRefreshEnrichmentToken: UUID?
|
|
@ObservationIgnored var pendingForcedRefreshEnrichmentTask: Task<Void, Never>?
|
|
@ObservationIgnored var pendingForcedRefreshEnrichmentToken: UUID?
|
|
@ObservationIgnored var forcedRefreshEnrichmentGeneration: UInt64 = 0
|
|
@ObservationIgnored var requiredRefreshTask: Task<Bool, Never>?
|
|
@ObservationIgnored var requiredRefreshTaskToken: UUID?
|
|
@ObservationIgnored var pendingRequiredRefreshRequest: RequiredRefreshRequest?
|
|
@ObservationIgnored var requiredRefreshRequestGeneration: UInt64 = 0
|
|
@ObservationIgnored var requiredRefreshCompletedGeneration: UInt64 = 0
|
|
@ObservationIgnored var memoryPressureReliefTask: Task<Void, Never>?
|
|
@ObservationIgnored var startupConnectivityRetryTask: Task<Void, Never>?
|
|
@ObservationIgnored var startupConnectivityRetryNeeded = false
|
|
@ObservationIgnored var startupConnectivityRetryRefreshActive = false
|
|
@ObservationIgnored var storageRefreshTask: Task<Void, Never>?
|
|
@ObservationIgnored var storageRefreshGeneration: UInt64 = 0
|
|
@ObservationIgnored var storageRefreshInFlightSignature: String?
|
|
@ObservationIgnored var storageRefreshInFlightRequestKey: String?
|
|
@ObservationIgnored var lastStorageRefreshSignature: String?
|
|
@ObservationIgnored var lastStorageRefreshRequestKey: String?
|
|
@ObservationIgnored var lastStorageRefreshAt: Date?
|
|
@ObservationIgnored var managedCodexAccountsForStorageOverride: [ManagedCodexAccount]?
|
|
@ObservationIgnored private var pathDebugRefreshTask: Task<Void, Never>?
|
|
@ObservationIgnored var resetBoundaryRefreshTask: Task<Void, Never>?
|
|
@ObservationIgnored var scheduledResetBoundaryRefreshAt: Date?
|
|
@ObservationIgnored var attemptedResetBoundaryRefreshes: Set<Date> = []
|
|
@ObservationIgnored var codexPlanHistoryBackfillTask: Task<Void, Never>?
|
|
@ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore
|
|
@ObservationIgnored let planUtilizationHistoryStore: PlanUtilizationHistoryStore
|
|
@ObservationIgnored let codexAccountUsageSnapshotStore: (any CodexAccountUsageSnapshotStoring)?
|
|
@ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset?
|
|
@ObservationIgnored var codexHistoricalDatasetAccountKey: String?
|
|
@ObservationIgnored var lastKnownResetSnapshots: [ProviderInstanceID: UsageSnapshot] = [:]
|
|
/// A stable ambient Auto refresh failed after every live Claude source was exhausted, so persisted
|
|
/// plan-utilization history may safely supply a stale presentation snapshot for the same profile.
|
|
@ObservationIgnored var claudeHistoryFallbackEligible = false
|
|
@ObservationIgnored var deepseekProfileTransition: DeepSeekProfileTransition?
|
|
@ObservationIgnored var sessionQuotaTransitionStates: [ProviderInstanceID: SessionQuotaTransitionState] = [:]
|
|
@ObservationIgnored var codexSessionQuotaBaselineRequirement: CodexSessionQuotaBaselineRequirement?
|
|
var codexSessionQuotaBaselineRequired: Bool {
|
|
self.codexSessionQuotaBaselineRequirement != nil
|
|
}
|
|
|
|
@ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:]
|
|
@ObservationIgnored let hookRateLimiter = HookRateLimiter()
|
|
@ObservationIgnored var providerStatusHadIssue: [ProviderInstanceID: Bool] = [:]
|
|
/// Last observed usage fraction (0...1) per account and quota-warning lane, used
|
|
/// to detect upward crossings of a quota_low hook rule's own threshold.
|
|
@ObservationIgnored var quotaLowHookUsage: [QuotaWarningStateKey: Double] = [:]
|
|
@ObservationIgnored var quotaLowHookConfigRevision: Int?
|
|
@ObservationIgnored var predictivePaceWarningNotifiedKeys: Set<PredictivePaceWarningStateKey> = []
|
|
@ObservationIgnored var lastPermissionPromptNotificationAt: [ProviderInstanceID: Date] = [:]
|
|
@ObservationIgnored var lastTokenFetchAt: [ProviderInstanceID: Date] = [:]
|
|
@ObservationIgnored var lastTokenFetchScope: [ProviderInstanceID: String] = [:]
|
|
@ObservationIgnored var lastSpendDashboardTokenFetchAt: [ProviderInstanceID: Date] = [:]
|
|
@ObservationIgnored var lastSpendDashboardTokenFetchScope: [ProviderInstanceID: String] = [:]
|
|
@ObservationIgnored var spendDashboardTokenRefreshInFlight: Set<ProviderInstanceID> = []
|
|
@ObservationIgnored var planUtilizationHistory: [ProviderInstanceID: PlanUtilizationHistoryBuckets] = [:]
|
|
@ObservationIgnored var sessionEquivalentBurnCache: [ProviderInstanceID: SessionEquivalentBurnCacheEntry] = [:]
|
|
@ObservationIgnored var sessionEquivalentHistoryScanCount: Int = 0
|
|
|
|
/// Background load task; cleared on deinit and on the cancel test seam.
|
|
@ObservationIgnored var planUtilizationHistoryLoadTask: Task<Void, Never>?
|
|
/// Set once after the load completes. Gates mutation paths and sync menu
|
|
/// accessors so they cannot race the decode or write empty history back to disk.
|
|
@ObservationIgnored var planUtilizationHistoryLoaded: Bool = false
|
|
@ObservationIgnored var sessionLimitResetDetectorStates: [String: LimitResetDetectorState] = [:]
|
|
@ObservationIgnored var weeklyLimitResetDetectorStates: [String: LimitResetDetectorState] = [:]
|
|
@ObservationIgnored private var hasCompletedInitialRefresh: Bool = false
|
|
@ObservationIgnored private let providerAvailabilityCacheTTL: TimeInterval = 1
|
|
@ObservationIgnored let accountInfoCacheTTL: TimeInterval = 30
|
|
/// Energy/WidgetKit floor for expensive local-history scans and their additional snapshot publications.
|
|
/// Faster provider refreshes still update quota/status normally, but reuse token-cost history within this TTL.
|
|
static let minimumTokenFetchTTL: TimeInterval = 15 * 60
|
|
|
|
var tokenFetchTTL: TimeInterval? {
|
|
Self.tokenFetchTTL(
|
|
for: self.settings.refreshFrequency,
|
|
lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled)
|
|
}
|
|
|
|
static func tokenFetchTTL(
|
|
for frequency: RefreshFrequency,
|
|
lowPowerModeEnabled: Bool = false) -> TimeInterval?
|
|
{
|
|
let interval = frequency.usesAdaptivePolicy
|
|
? AdaptiveRefreshPolicy.nominalIntervalForHeuristics
|
|
: frequency.seconds
|
|
let widgetSafeInterval = interval.map { max($0, Self.minimumTokenFetchTTL) }
|
|
return BackgroundWorkPowerPolicy.automaticInterval(
|
|
widgetSafeInterval,
|
|
lowPowerModeEnabled: lowPowerModeEnabled)
|
|
}
|
|
|
|
@ObservationIgnored let tokenFetchTimeout: TimeInterval = 10 * 60
|
|
@ObservationIgnored let startupBehavior: StartupBehavior
|
|
@ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator
|
|
|
|
init(
|
|
fetcher: UsageFetcher,
|
|
browserDetection: BrowserDetection,
|
|
claudeFetcher: (any ClaudeUsageFetching)? = nil,
|
|
costUsageFetcher: CostUsageFetcher = CostUsageFetcher(),
|
|
settings: SettingsStore,
|
|
registry: ProviderRegistry = .shared,
|
|
historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(),
|
|
planUtilizationHistoryStore: PlanUtilizationHistoryStore? = nil,
|
|
codexAccountUsageSnapshotStore: (any CodexAccountUsageSnapshotStoring)? = nil,
|
|
sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(),
|
|
startupBehavior: StartupBehavior = .automatic,
|
|
environmentBase: [String: String] = ProcessInfo.processInfo.environment,
|
|
widgetSnapshotURL: URL? = nil,
|
|
planUtilizationHistoryLoadGateForTesting: PlanUtilizationHistoryLoadGate? = nil)
|
|
{
|
|
self.codexFetcher = fetcher
|
|
self.browserDetection = browserDetection
|
|
self.claudeFetcher = claudeFetcher ?? ClaudeUsageFetcher(browserDetection: browserDetection)
|
|
self.costUsageFetcher = costUsageFetcher
|
|
self.settings = settings
|
|
self.registry = registry
|
|
self.environmentBase = environmentBase
|
|
self.widgetSnapshotURL = widgetSnapshotURL
|
|
self.historicalUsageHistoryStore = historicalUsageHistoryStore
|
|
self.startupBehavior = startupBehavior.resolved(isRunningTests: Self.isRunningTestsProcess())
|
|
let planHistoryStore = Self.resolvedPlanHistoryStore(planUtilizationHistoryStore, startup: self.startupBehavior)
|
|
self.planUtilizationHistoryStore = planHistoryStore
|
|
self.sessionQuotaNotifier = sessionQuotaNotifier
|
|
self.codexAccountUsageSnapshotStore = codexAccountUsageSnapshotStore ??
|
|
(self.startupBehavior.automaticallyStartsBackgroundWork ? FileCodexAccountUsageSnapshotStore() : nil)
|
|
self.planUtilizationPersistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator(
|
|
store: planHistoryStore)
|
|
self.providerMetadata = registry.metadata
|
|
self
|
|
.failureGates = Dictionary(
|
|
uniqueKeysWithValues: UsageProvider.allCases
|
|
.map { ($0.instanceID, ConsecutiveFailureGate()) })
|
|
self.tokenFailureGates = Dictionary(
|
|
uniqueKeysWithValues: UsageProvider.allCases
|
|
.map { ($0.instanceID, ConsecutiveFailureGate()) })
|
|
self.providerSpecs = registry.specs(
|
|
settings: settings,
|
|
metadata: self.providerMetadata,
|
|
codexFetcher: fetcher,
|
|
claudeFetcher: self.claudeFetcher,
|
|
browserDetection: browserDetection,
|
|
environmentBase: environmentBase)
|
|
self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in
|
|
implementation.makeRuntime().map { (implementation.id.instanceID, $0) }
|
|
})
|
|
self.startPlanUtilizationHistoryLoad(
|
|
gate: planUtilizationHistoryLoadGateForTesting,
|
|
enabled: self.startupBehavior.automaticallyStartsBackgroundWork)
|
|
self.sessionLimitResetDetectorStates = Self.loadLimitResetDetectorStates(
|
|
from: settings.userDefaults,
|
|
defaultsKey: Self.sessionLimitResetDetectorDefaultsKey,
|
|
logName: "session")
|
|
self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults)
|
|
if let codexAccountUsageSnapshotStore = self.codexAccountUsageSnapshotStore {
|
|
self.codexAccountSnapshots = codexAccountUsageSnapshotStore.load(
|
|
for: self.freshCodexVisibleAccountsForSnapshotHydration())
|
|
}
|
|
self.logStartupState()
|
|
self.bindSettings()
|
|
self.pathDebugInfo = PathDebugSnapshot(
|
|
codexBinary: nil,
|
|
claudeBinary: nil,
|
|
geminiBinary: nil,
|
|
effectivePATH: PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling]),
|
|
loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":"))
|
|
guard self.startupBehavior.automaticallyStartsBackgroundWork else { return }
|
|
self.hydrateCachedTokenSnapshots()
|
|
self.startSharedSpendDashboardPublication()
|
|
self.detectVersions()
|
|
self.updateProviderRuntimes()
|
|
Task { @MainActor [weak self] in
|
|
self?.schedulePathDebugInfoRefresh()
|
|
}
|
|
LoginShellPathCache.shared.captureOnce { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.schedulePathDebugInfoRefresh()
|
|
}
|
|
}
|
|
Task { @MainActor [weak self] in
|
|
await self?.refreshHistoricalDatasetIfNeeded()
|
|
}
|
|
Task { await self.refresh(enrichmentMode: .automatic) }
|
|
self.startTimer()
|
|
}
|
|
|
|
var iconStyle: IconStyle {
|
|
let enabled = self.enabledProviders()
|
|
if enabled.count > 1 {
|
|
return .combined
|
|
}
|
|
if let provider = enabled.first?.firstPartyProvider {
|
|
return self.style(for: provider)
|
|
}
|
|
// Provider-specific by design: Codex is the historical empty-enabled-set icon fallback.
|
|
return .codex
|
|
}
|
|
|
|
var isStale: Bool {
|
|
for provider in self.enabledProviders() where self.errors[provider] != nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func enabledProviders() -> [ProviderInstanceID] {
|
|
// Use cached enablement to avoid repeated UserDefaults lookups in animation ticks.
|
|
let enabled = self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata)
|
|
let now = Date()
|
|
return enabled.filter { self.isEnabledProviderInstance($0, now: now) }
|
|
}
|
|
|
|
/// Enabled providers without availability filtering. Used for display (switcher, merge-icons).
|
|
func enabledProvidersForDisplay() -> [ProviderInstanceID] {
|
|
self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata)
|
|
}
|
|
|
|
/// Providers that should actually participate in background refresh/status/token work.
|
|
func enabledProvidersForBackgroundWork() -> [ProviderInstanceID] {
|
|
self.enabledProviders()
|
|
}
|
|
|
|
func metadata(for provider: UsageProvider) -> ProviderMetadata {
|
|
self.providerMetadata[provider]!
|
|
}
|
|
|
|
var codexBrowserCookieOrder: BrowserCookieImportOrder {
|
|
self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder
|
|
}
|
|
|
|
func snapshot(for instanceID: ProviderInstanceID) -> UsageSnapshot? {
|
|
self.snapshots[instanceID]
|
|
}
|
|
|
|
/// The snapshot the menu-bar indicator should render for a provider instance.
|
|
/// When the claude-swap adapter owns Claude account presentation, the active
|
|
/// account's snapshot drives the bar so the indicator agrees with the account
|
|
/// cards shown in the menu; the ambient provider snapshot remains the fallback
|
|
/// whenever the adapter is disabled, below its presentation threshold, or the
|
|
/// active account has no usable usage windows.
|
|
func menuBarSnapshot(for instanceID: ProviderInstanceID) -> UsageSnapshot? {
|
|
// Provider-specific by design: claude-swap adapter owns Claude menu-bar presentation; see #2731.
|
|
self.claudeSwapMenuBarSnapshotOverride(for: instanceID) ?? self.snapshot(for: instanceID)
|
|
}
|
|
|
|
func sourceLabel(for provider: UsageProvider) -> String {
|
|
var label = self.lastSourceLabels[provider.instanceID] ?? ""
|
|
if label.isEmpty {
|
|
let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
|
|
let modes = descriptor.fetchPlan.sourceModes
|
|
if modes.count == 1, let mode = modes.first {
|
|
label = mode.rawValue
|
|
} else {
|
|
let context = ProviderSourceLabelContext(
|
|
provider: provider,
|
|
settings: self.settings,
|
|
store: self,
|
|
descriptor: descriptor)
|
|
label = ProviderCatalog.implementation(for: provider)?
|
|
.defaultSourceLabel(context: context)
|
|
?? "auto"
|
|
}
|
|
}
|
|
|
|
let context = ProviderSourceLabelContext(
|
|
provider: provider,
|
|
settings: self.settings,
|
|
store: self,
|
|
descriptor: ProviderDescriptorRegistry.descriptor(for: provider))
|
|
return ProviderCatalog.implementation(for: provider)?
|
|
.decorateSourceLabel(context: context, baseLabel: label)
|
|
?? label
|
|
}
|
|
|
|
func fetchAttempts(for provider: UsageProvider) -> [ProviderFetchAttempt] {
|
|
self.lastFetchAttempts[provider.instanceID] ?? []
|
|
}
|
|
|
|
func style(for provider: UsageProvider) -> IconStyle {
|
|
self.providerSpecs[provider]?.style ?? .codex
|
|
}
|
|
|
|
func isStale(provider: UsageProvider) -> Bool {
|
|
self.errors[provider.instanceID] != nil
|
|
}
|
|
|
|
func knownLimitsAvailability(for provider: UsageProvider) -> UsageLimitsAvailability? {
|
|
self.knownLimitsAvailabilityByProvider[provider.instanceID]
|
|
}
|
|
|
|
func hasSatisfiedUsageFetch(for provider: UsageProvider) -> Bool {
|
|
self.snapshot(for: provider.instanceID) != nil ||
|
|
self.knownLimitsAvailability(for: provider)?.isUnavailable == true
|
|
}
|
|
|
|
func needsUsageRefreshRetry(for provider: UsageProvider) -> Bool {
|
|
self.isStale(provider: provider) || !self.hasSatisfiedUsageFetch(for: provider)
|
|
}
|
|
|
|
func isEnabled(_ provider: UsageProvider) -> Bool {
|
|
let enabled = self.settings.isProviderEnabledCached(
|
|
provider: provider,
|
|
metadataByProvider: self.providerMetadata)
|
|
guard enabled else { return false }
|
|
return self.isProviderAvailable(provider)
|
|
}
|
|
|
|
func isProviderAvailable(_ provider: UsageProvider) -> Bool {
|
|
self.isProviderAvailable(provider, now: Date())
|
|
}
|
|
|
|
func isProviderAvailable(_ provider: UsageProvider, now: Date) -> Bool {
|
|
guard provider != .codex else { return true }
|
|
|
|
let configRevision = self.settings.configRevision
|
|
if let cached = self.providerAvailabilityCache[provider.instanceID],
|
|
cached.isValid(now: now, configRevision: configRevision)
|
|
{
|
|
return cached.available
|
|
}
|
|
|
|
// Availability should mirror the effective fetch environment, including token-account overrides.
|
|
// Otherwise providers (notably token-account-backed API providers) can fetch successfully but be
|
|
// hidden from the menu because their credentials are not in ProcessInfo's environment.
|
|
let environment = ProviderRegistry.makeEnvironment(
|
|
base: self.environmentBase,
|
|
provider: provider,
|
|
settings: self.settings,
|
|
tokenOverride: nil)
|
|
let context = ProviderAvailabilityContext(
|
|
provider: provider,
|
|
settings: self.settings,
|
|
environment: environment)
|
|
let available = ProviderCatalog.implementation(for: provider)?
|
|
.isAvailable(context: context)
|
|
?? true
|
|
self.providerAvailabilityCache[provider.instanceID] = ProviderAvailabilityCacheEntry(
|
|
available: available,
|
|
configRevision: configRevision,
|
|
expiresAt: now.addingTimeInterval(self.providerAvailabilityCacheTTL))
|
|
return available
|
|
}
|
|
|
|
private func invalidateProviderAvailabilityCache() {
|
|
self.providerAvailabilityCache.removeAll(keepingCapacity: true)
|
|
}
|
|
|
|
#if DEBUG
|
|
@ObservationIgnored private(set) var completedRefreshCountForTesting = 0
|
|
#endif
|
|
|
|
@discardableResult
|
|
func runRefresh(
|
|
enrichmentMode: RefreshEnrichmentMode = .automatic,
|
|
startupConnectivityRetryAttempt: Int?,
|
|
coalesceProviderRefreshesOverride: Bool? = nil,
|
|
waitForRefreshAvailability: Bool = false) async -> Bool
|
|
{
|
|
if enrichmentMode == .automatic, waitForRefreshAvailability {
|
|
return await self.enqueueRequiredRefresh(
|
|
startupConnectivityRetryAttempt: startupConnectivityRetryAttempt,
|
|
coalesceProviderRefreshesOverride: coalesceProviderRefreshesOverride)
|
|
}
|
|
|
|
guard !self.isRefreshing else { return false }
|
|
guard enrichmentMode != .automatic || !self.hasForcedRefreshEnrichmentInFlight else { return false }
|
|
let forcedBackgroundGeneration: UInt64?
|
|
if enrichmentMode == .forcedBackground {
|
|
self.forcedRefreshEnrichmentGeneration &+= 1
|
|
forcedBackgroundGeneration = self.forcedRefreshEnrichmentGeneration
|
|
} else {
|
|
forcedBackgroundGeneration = nil
|
|
}
|
|
self.prepareRefreshState()
|
|
let refreshPhase = Self.refreshPhase(hasCompletedInitialRefresh: self.hasCompletedInitialRefresh)
|
|
let openAIWebRefreshPhase = Self.openAIWebRefreshPhase(
|
|
providerRefreshPhase: refreshPhase,
|
|
startupConnectivityRetryAttempt: startupConnectivityRetryAttempt)
|
|
let allowsStartupConnectivityRetry = refreshPhase == .startup || startupConnectivityRetryAttempt != nil
|
|
self.startupConnectivityRetryRefreshActive = allowsStartupConnectivityRetry
|
|
self.startupConnectivityRetryNeeded = false
|
|
let displayEnabledProviders = self.enabledProvidersForDisplay()
|
|
let enabledProviderSet = Set(displayEnabledProviders)
|
|
let refreshProviders = self.enabledProvidersForBackgroundWork()
|
|
let availableRefreshProviders = Set(self.enabledProviders())
|
|
let refreshStartedAt = Date()
|
|
|
|
let completedRefresh = await ProviderRefreshContext.$current.withValue(refreshPhase) {
|
|
self.isRefreshing = true
|
|
defer {
|
|
self.isRefreshing = false
|
|
self.hasCompletedInitialRefresh = true
|
|
self.startupConnectivityRetryRefreshActive = false
|
|
}
|
|
|
|
self.clearDisabledProviderState(enabledProviders: enabledProviderSet)
|
|
self.clearUnavailableProviderState(
|
|
displayEnabledProviders: enabledProviderSet,
|
|
availableProviders: availableRefreshProviders)
|
|
self.scheduleStorageFootprintRefresh(for: displayEnabledProviders.compactMap(\.firstPartyProvider))
|
|
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for instanceID in refreshProviders {
|
|
guard let provider = instanceID.firstPartyProvider else {
|
|
group.addTask { await self.refreshUserPlugin(instanceID) }
|
|
continue
|
|
}
|
|
group.addTask {
|
|
await self.refreshProvider(
|
|
provider,
|
|
coalesceIfRefreshing: coalesceProviderRefreshesOverride ??
|
|
(ProviderInteractionContext.current == .background))
|
|
}
|
|
if availableRefreshProviders.contains(provider.instanceID) {
|
|
group.addTask { await self.refreshProviderStatus(provider) }
|
|
}
|
|
}
|
|
if enrichmentMode == .forcedForeground {
|
|
group.addTask { await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) }
|
|
}
|
|
}
|
|
guard !Task.isCancelled else { return false }
|
|
|
|
if enrichmentMode == .automatic {
|
|
self.scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt)
|
|
}
|
|
|
|
if enrichmentMode == .forcedForeground {
|
|
await self.refreshTokenUsageSequenceNow(force: true)
|
|
} else if enrichmentMode == .automatic {
|
|
// Token-cost usage can be slow; run it outside regular/menu-open refreshes so we don't block UI.
|
|
self.scheduleTokenRefresh()
|
|
}
|
|
|
|
// OpenAI web scrape depends on the current Codex account email (which can change after login/account
|
|
// switch). Run this after Codex usage refresh so we don't accidentally scrape with stale credentials.
|
|
if enrichmentMode == .forcedBackground {
|
|
// Account ownership must fail closed before the responsive foreground pass returns;
|
|
// only the expensive dashboard fetch belongs in the deferred enrichment tail.
|
|
self.syncOpenAIWebState()
|
|
} else {
|
|
await self.refreshOpenAIWebAfterProviderRefresh(
|
|
force: enrichmentMode == .forcedForeground,
|
|
refreshPhase: openAIWebRefreshPhase)
|
|
}
|
|
|
|
if enrichmentMode == .forcedForeground, self.openAIDashboardRequiresLogin {
|
|
// Provider-specific by design: failed OpenAI attachment retries Codex usage before credits enrichment.
|
|
await self.refreshProvider(.codex)
|
|
await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt)
|
|
}
|
|
|
|
self.persistWidgetSnapshot(reason: "refresh")
|
|
if let forcedBackgroundGeneration {
|
|
self.enqueueForcedRefreshEnrichment(
|
|
generation: forcedBackgroundGeneration,
|
|
refreshStartedAt: refreshStartedAt,
|
|
openAIWebRefreshPhase: openAIWebRefreshPhase)
|
|
}
|
|
return true
|
|
}
|
|
|
|
guard completedRefresh else { return false }
|
|
|
|
self.scheduleResetBoundaryRefreshIfNeeded(
|
|
normalRefreshInterval: self.normalRefreshIntervalForHeuristics())
|
|
|
|
if allowsStartupConnectivityRetry {
|
|
self.completeStartupConnectivityRetryPass(currentAttempt: startupConnectivityRetryAttempt ?? 0)
|
|
}
|
|
if refreshPhase == .startup {
|
|
self.scheduleMemoryPressureRelief()
|
|
}
|
|
#if DEBUG
|
|
self.completedRefreshCountForTesting += 1
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
/// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot.
|
|
func replayLoadingAnimation(duration: TimeInterval = 3) {
|
|
let current = self.preferredSnapshot
|
|
self.snapshots.removeAll()
|
|
self.debugForceAnimation = true
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .seconds(duration))
|
|
if let current, let provider = self.enabledProviders().first {
|
|
self.snapshots[provider] = current
|
|
}
|
|
self.debugForceAnimation = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func bindSettings() {
|
|
self.observeSettingsChanges()
|
|
}
|
|
|
|
#if DEBUG
|
|
@ObservationIgnored private(set) var refreshTimerSleepOverrideForTesting: Duration?
|
|
@ObservationIgnored private(set) var fixedRefreshIntervalForTesting: TimeInterval?
|
|
@ObservationIgnored var adaptiveRefreshComputedIntervalForTesting: TimeInterval?
|
|
|
|
/// Sets this store's timer sleep override and restarts the timer with it applied, so tests can
|
|
/// observe multiple fixed/adaptive ticks without waiting real minutes. The reason/delay a tick
|
|
/// computes and logs is unaffected; only how long it sleeps before acting on that decision
|
|
/// changes. Instance-scoped (not a shared global) so concurrently running tests, each with their
|
|
/// own `UsageStore`, cannot clobber one another's override.
|
|
func restartTimerWithSleepOverrideForTesting(_ duration: Duration?) {
|
|
self.refreshTimerSleepOverrideForTesting = duration
|
|
self.startTimer()
|
|
}
|
|
#endif
|
|
|
|
private func startTimer(preservingResetBoundaryRefresh: Bool = false) {
|
|
self.timerTask?.cancel()
|
|
self.adaptiveRefreshScheduledAt = nil
|
|
#if DEBUG
|
|
self.fixedRefreshIntervalForTesting = nil
|
|
self.adaptiveRefreshComputedIntervalForTesting = nil
|
|
#endif
|
|
if !preservingResetBoundaryRefresh {
|
|
self.cancelResetBoundaryRefresh()
|
|
}
|
|
|
|
let frequency = self.settings.refreshFrequency
|
|
guard frequency != .manual else { return }
|
|
|
|
if frequency.usesAdaptivePolicy {
|
|
// Background poller so the menu stays responsive; canceled when settings change or store
|
|
// deallocates. Delay is recomputed before every tick from live power/thermal state and the
|
|
// in-memory menu-open signal; the policy itself stays pure (Input is built here). `self` is
|
|
// only strongly held for the brief, synchronous decision computation below, never across
|
|
// the sleep — a weak reference lets the store deallocate mid-sleep, same as fixed mode.
|
|
self.timerTask = Task.detached(priority: .utility) { [weak self] in
|
|
while !Task.isCancelled {
|
|
guard let sleepDuration = await Self.nextAdaptiveTimerSleepDuration(for: self) else { return }
|
|
try? await Task.sleep(for: sleepDuration)
|
|
guard !Task.isCancelled else { return }
|
|
await self?.refresh(enrichmentMode: .automatic)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let wait = Self.effectiveAutomaticRefreshInterval(
|
|
frequency.seconds,
|
|
lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled)
|
|
else { return }
|
|
#if DEBUG
|
|
self.fixedRefreshIntervalForTesting = wait
|
|
let fixedTimerSleepOverride = self.refreshTimerSleepOverrideForTesting
|
|
#else
|
|
let fixedTimerSleepOverride: Duration? = nil
|
|
#endif
|
|
|
|
// Background poller so the menu stays responsive; canceled when settings change or store deallocates.
|
|
// Fixed cadence is anchored to the scheduled tick time, not refresh completion, so slow provider
|
|
// work doesn't permanently stretch a two-minute interval into "refresh duration + two minutes".
|
|
self.timerTask = Task.detached(priority: .utility) { [weak self] in
|
|
await Self.runFixedRefreshTimer(
|
|
interval: .seconds(wait),
|
|
sleepOverride: fixedTimerSleepOverride,
|
|
refresh: { [weak self] in
|
|
await self?.refresh(enrichmentMode: .automatic)
|
|
})
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
self.timerTask?.cancel()
|
|
self.tokenRefreshSequenceTask?.cancel()
|
|
self.codexCostCatchUpTask?.cancel()
|
|
self.forcedRefreshEnrichmentTask?.cancel()
|
|
self.pendingForcedRefreshEnrichmentTask?.cancel()
|
|
self.requiredRefreshTask?.cancel()
|
|
self.creditsRefreshTask?.cancel()
|
|
self.openAIDashboardBackgroundRefreshTask?.cancel()
|
|
self.openAIDashboardRefreshTask?.cancel()
|
|
self.memoryPressureReliefTask?.cancel()
|
|
self.startupConnectivityRetryTask?.cancel()
|
|
self.storageRefreshTask?.cancel()
|
|
self.codexPlanHistoryBackfillTask?.cancel()
|
|
self.resetBoundaryRefreshTask?.cancel()
|
|
self.planUtilizationHistoryLoadTask?.cancel()
|
|
}
|
|
|
|
enum SessionQuotaWindowSource: String {
|
|
case primary
|
|
case copilotSecondaryFallback
|
|
case antigravityQuotaSummary
|
|
case antigravityLegacy
|
|
}
|
|
|
|
func postQuotaWarning(_ event: QuotaWarningEvent, provider: UsageProvider) {
|
|
self.sessionQuotaNotifier.postQuotaWarning(
|
|
event: event,
|
|
provider: provider,
|
|
soundEnabled: self.settings.quotaWarningSoundEnabled,
|
|
onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled)
|
|
}
|
|
|
|
func postPredictivePaceWarning(_ event: PredictivePaceWarningEvent, provider: UsageProvider, now: Date) {
|
|
self.sessionQuotaNotifier.postPredictivePaceWarning(
|
|
event: event,
|
|
provider: provider,
|
|
soundEnabled: self.settings.quotaWarningSoundEnabled,
|
|
onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled,
|
|
now: now)
|
|
}
|
|
}
|
|
|
|
extension UsageStore {
|
|
func debugDumpClaude() async {
|
|
// Provider-specific by design: Claude's debug command owns a raw CLI/web probe artifact and error lane.
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: self.browserDetection,
|
|
keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive)
|
|
let output = await fetcher.debugRawProbe(model: "sonnet")
|
|
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("codexbar-claude-probe.txt")
|
|
try? output.write(to: url, atomically: true, encoding: .utf8)
|
|
await MainActor.run {
|
|
let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ")
|
|
self.knownLimitsAvailabilityByProvider.removeValue(forKey: .claude)
|
|
self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))"
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
func dumpLog(toFileFor provider: UsageProvider) async -> URL? {
|
|
let text = await self.debugLog(for: provider)
|
|
let filename = "codexbar-\(provider.rawValue)-probe.txt"
|
|
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename)
|
|
do {
|
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
|
_ = await MainActor.run { NSWorkspace.shared.open(url) }
|
|
return url
|
|
} catch {
|
|
await MainActor.run {
|
|
self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider.instanceID)
|
|
self.errors[provider.instanceID] = "Failed to save log: \(error.localizedDescription)"
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func debugAugmentDump() async -> String {
|
|
await AugmentStatusProbe.latestDumps()
|
|
}
|
|
|
|
func debugLog(for provider: UsageProvider) async -> String {
|
|
if let cached = self.probeLogs[provider.instanceID], !cached.isEmpty {
|
|
return cached
|
|
}
|
|
|
|
let claudeWebExtrasEnabled = self.settings.claudeWebExtrasEnabled
|
|
let claudeUsageDataSource = self.settings.claudeUsageDataSource
|
|
let claudeCookieSource = self.settings.claudeCookieSource
|
|
let claudeCookieHeader = self.settings.claudeCookieHeader
|
|
let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {
|
|
await self.makeClaudeDebugConfiguration(
|
|
fallbackUsageDataSource: claudeUsageDataSource,
|
|
fallbackWebExtrasEnabled: claudeWebExtrasEnabled,
|
|
fallbackCookieSource: claudeCookieSource,
|
|
fallbackCookieHeader: claudeCookieHeader)
|
|
} else {
|
|
nil
|
|
}
|
|
let cursorCookieSource = self.settings.cursorCookieSource
|
|
let cursorCookieHeader = self.settings.cursorCookieHeader
|
|
let ampCookieSource = self.settings.ampCookieSource
|
|
let ampCookieHeader = self.settings.ampCookieHeader
|
|
let ollamaCookieSource = self.settings.ollamaCookieSource
|
|
let ollamaCookieHeader = self.settings.ollamaCookieHeader
|
|
let notionCookieSource = self.settings.notionCookieSource
|
|
let notionCookieHeader = self.settings.notionCookieHeader
|
|
let notionWorkspaceID = self.settings.notionWorkspaceID
|
|
let processEnvironment = self.environmentBase
|
|
let apiKeyDebugContext = self.apiKeyDebugContext(
|
|
provider: provider,
|
|
processEnvironment: processEnvironment)
|
|
let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil
|
|
let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil
|
|
let deepSeekEnvironment = ProviderRegistry.makeEnvironment(
|
|
base: processEnvironment,
|
|
provider: .deepseek,
|
|
settings: self.settings,
|
|
tokenOverride: nil)
|
|
let codexFetcher = self.codexFetcher
|
|
let browserDetection = self.browserDetection
|
|
let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext()
|
|
let text = await Task.detached(priority: .utility) { () -> String in
|
|
// Provider-specific by design: implemented logs capture app-only settings and execution contexts.
|
|
let buildText = {
|
|
if let apiKeyDebugContext {
|
|
return Self.apiKeyDebugLine(apiKeyDebugContext)
|
|
}
|
|
switch provider {
|
|
case .codex:
|
|
return await codexFetcher.debugRawRateLimits()
|
|
case .claude:
|
|
guard let claudeDebugConfiguration else {
|
|
return "Claude debug log configuration unavailable"
|
|
}
|
|
return await claudeDebugExecutionContext.apply {
|
|
await Self.debugClaudeLog(
|
|
browserDetection: browserDetection,
|
|
configuration: claudeDebugConfiguration)
|
|
}
|
|
case .zai:
|
|
let resolution = ProviderTokenResolver.resolution(for: .zai)
|
|
let hasAny = resolution != nil
|
|
let source = resolution?.source.rawValue ?? "none"
|
|
return "Z_AI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
|
|
case .synthetic:
|
|
let resolution = ProviderTokenResolver.resolution(for: .synthetic)
|
|
let hasAny = resolution != nil
|
|
let source = resolution?.source.rawValue ?? "none"
|
|
return "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
|
|
case .cursor:
|
|
return await Self.debugCursorLog(
|
|
browserDetection: browserDetection,
|
|
cursorCookieSource: cursorCookieSource,
|
|
cursorCookieHeader: cursorCookieHeader)
|
|
case .minimax:
|
|
let tokenResolution = ProviderTokenResolver.resolution(for: .minimax)
|
|
let cookieResolution = ProviderTokenResolver.resolution(for: .minimax, kind: .secondary)
|
|
let tokenSource = tokenResolution?.source.rawValue ?? "none"
|
|
let cookieSource = cookieResolution?.source.rawValue ?? "none"
|
|
return "MINIMAX_API_KEY=\(tokenResolution == nil ? "missing" : "present") " +
|
|
"source=\(tokenSource) MINIMAX_COOKIE=\(cookieResolution == nil ? "missing" : "present") " +
|
|
"source=\(cookieSource)"
|
|
case .alibaba:
|
|
let resolution = ProviderTokenResolver.resolution(for: .alibaba)
|
|
let hasAny = resolution != nil
|
|
let source = resolution?.source.rawValue ?? "none"
|
|
return "ALIBABA_CODING_PLAN_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
|
|
case .augment:
|
|
return await Self.debugAugmentLog()
|
|
case .amp:
|
|
return await Self.debugAmpLog(
|
|
browserDetection: browserDetection,
|
|
ampCookieSource: ampCookieSource,
|
|
ampCookieHeader: ampCookieHeader)
|
|
case .ollama:
|
|
return await Self.debugOllamaLog(
|
|
browserDetection: browserDetection,
|
|
ollamaCookieSource: ollamaCookieSource,
|
|
ollamaCookieHeader: ollamaCookieHeader)
|
|
case .notion:
|
|
return await Self.debugNotionLog(
|
|
browserDetection: browserDetection,
|
|
notionCookieSource: notionCookieSource,
|
|
notionCookieHeader: notionCookieHeader,
|
|
notionWorkspaceID: notionWorkspaceID)
|
|
case .warp:
|
|
let resolution = ProviderTokenResolver.resolution(for: .warp)
|
|
let hasAny = resolution != nil
|
|
let source = resolution?.source.rawValue ?? "none"
|
|
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
|
|
case .deepseek:
|
|
return Self.apiKeyDebugLine(
|
|
label: "DEEPSEEK_API_KEY",
|
|
resolution: ProviderTokenResolver.resolution(for: .deepseek, environment: deepSeekEnvironment),
|
|
configToken: nil,
|
|
hasEnvToken: deepSeekHasEnvToken,
|
|
hasTokenAccount: deepSeekHasTokenAccount)
|
|
default:
|
|
return ProviderDescriptorRegistry.descriptor(for: provider).metadata.debugLogUnavailableMessage
|
|
?? "Debug log not yet implemented"
|
|
}
|
|
}
|
|
return await claudeDebugExecutionContext.apply {
|
|
await buildText()
|
|
}
|
|
}.value
|
|
self.probeLogs[provider.instanceID] = text
|
|
return text
|
|
}
|
|
|
|
private func makeClaudeDebugConfiguration(
|
|
fallbackUsageDataSource: ClaudeUsageDataSource,
|
|
fallbackWebExtrasEnabled: Bool,
|
|
fallbackCookieSource: ProviderCookieSource,
|
|
fallbackCookieHeader: String) async -> ClaudeDebugLogConfiguration
|
|
{
|
|
await MainActor.run {
|
|
let sourceMode = self.sourceMode(for: .claude)
|
|
let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil)
|
|
let environment = ProviderRegistry.makeEnvironment(
|
|
base: self.environmentBase,
|
|
provider: .claude,
|
|
settings: self.settings,
|
|
tokenOverride: nil)
|
|
let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(
|
|
usageDataSource: fallbackUsageDataSource,
|
|
webExtrasEnabled: fallbackWebExtrasEnabled,
|
|
cookieSource: fallbackCookieSource,
|
|
manualCookieHeader: fallbackCookieHeader)
|
|
return ClaudeDebugLogConfiguration(
|
|
runtime: CodexBarCore.ProviderRuntime.app,
|
|
sourceMode: sourceMode,
|
|
environment: environment,
|
|
webExtrasEnabled: claudeSettings.webExtrasEnabled,
|
|
usageDataSource: claudeSettings.usageDataSource,
|
|
cookieSource: claudeSettings.cookieSource,
|
|
cookieHeader: claudeSettings.manualCookieHeader ?? "",
|
|
keepCLISessionsAlive: snapshot.debugKeepCLISessionsAlive)
|
|
}
|
|
}
|
|
|
|
private struct ClaudeDebugExecutionContext {
|
|
let interaction: ProviderInteraction
|
|
let refreshPhase: ProviderRefreshPhase
|
|
#if DEBUG
|
|
let keychainServiceOverride: String?
|
|
let credentialsURLOverride: URL?
|
|
let testingOverrides: ClaudeOAuthCredentialsStore.TestingOverridesSnapshot
|
|
let keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.DeniedUntilStore?
|
|
let keychainPromptModeOverride: ClaudeOAuthKeychainPromptMode?
|
|
let keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategy?
|
|
let cliPathOverride: String?
|
|
let statusFetchOverride: ClaudeStatusProbe.FetchOverride?
|
|
#endif
|
|
|
|
func apply<T>(_ operation: () async -> T) async -> T {
|
|
await ProviderInteractionContext.$current.withValue(self.interaction) {
|
|
await ProviderRefreshContext.$current.withValue(self.refreshPhase) {
|
|
#if DEBUG
|
|
return await KeychainCacheStore.withServiceOverrideForTesting(self.keychainServiceOverride) {
|
|
await ClaudeOAuthCredentialsStore
|
|
.withCredentialsURLOverrideForTesting(self.credentialsURLOverride) {
|
|
await ClaudeOAuthCredentialsStore
|
|
.withTestingOverridesSnapshotForTask(self.testingOverrides) {
|
|
await ClaudeOAuthKeychainAccessGate
|
|
.withDeniedUntilStoreOverrideForTesting(self
|
|
.keychainDeniedUntilStoreOverride)
|
|
{
|
|
await ClaudeOAuthKeychainPromptPreference
|
|
.withTaskOverrideForTesting(self.keychainPromptModeOverride) {
|
|
await ClaudeOAuthKeychainReadStrategyPreference
|
|
.withTaskOverrideForTesting(self
|
|
.keychainReadStrategyOverride)
|
|
{
|
|
await ClaudeCLIResolver
|
|
.withResolvedBinaryPathOverrideForTesting(self
|
|
.cliPathOverride)
|
|
{
|
|
await ClaudeStatusProbe
|
|
.withFetchOverrideForTesting(self
|
|
.statusFetchOverride)
|
|
{
|
|
await operation()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#else
|
|
return await operation()
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func currentClaudeDebugExecutionContext() -> ClaudeDebugExecutionContext {
|
|
#if DEBUG
|
|
ClaudeDebugExecutionContext(
|
|
interaction: ProviderInteractionContext.current,
|
|
refreshPhase: ProviderRefreshContext.current,
|
|
keychainServiceOverride: KeychainCacheStore.currentServiceOverrideForTesting,
|
|
credentialsURLOverride: ClaudeOAuthCredentialsStore.currentCredentialsURLOverrideForTesting,
|
|
testingOverrides: ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask,
|
|
keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.currentDeniedUntilStoreOverrideForTesting,
|
|
keychainPromptModeOverride: ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting,
|
|
keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategyPreference.currentTaskOverrideForTesting,
|
|
cliPathOverride: ClaudeCLIResolver.currentResolvedBinaryPathOverrideForTesting,
|
|
statusFetchOverride: ClaudeStatusProbe.currentFetchOverrideForTesting)
|
|
#else
|
|
ClaudeDebugExecutionContext(
|
|
interaction: ProviderInteractionContext.current,
|
|
refreshPhase: ProviderRefreshContext.current)
|
|
#endif
|
|
}
|
|
|
|
private static func debugCursorLog(
|
|
browserDetection: BrowserDetection,
|
|
cursorCookieSource: ProviderCookieSource,
|
|
cursorCookieHeader: String) async -> String
|
|
{
|
|
await runWithTimeout(seconds: 15) {
|
|
var lines: [String] = []
|
|
|
|
do {
|
|
let probe = CursorStatusProbe(browserDetection: browserDetection)
|
|
let snapshot: CursorStatusSnapshot = if cursorCookieSource == .manual,
|
|
let normalizedHeader = CookieHeaderNormalizer
|
|
.normalize(cursorCookieHeader)
|
|
{
|
|
try await probe.fetchWithManualCookies(normalizedHeader)
|
|
} else {
|
|
try await probe.fetch { msg in lines.append("[cursor-cookie] \(msg)") }
|
|
}
|
|
|
|
lines.append("")
|
|
lines.append("Cursor Status Summary:")
|
|
lines.append("membershipType=\(snapshot.membershipType ?? "nil")")
|
|
lines.append("accountEmail=\(snapshot.accountEmail ?? "nil")")
|
|
lines.append("planPercentUsed=\(snapshot.planPercentUsed)%")
|
|
lines.append("planUsedUSD=$\(snapshot.planUsedUSD)")
|
|
lines.append("planLimitUSD=$\(snapshot.planLimitUSD)")
|
|
lines.append("onDemandUsedUSD=$\(snapshot.onDemandUsedUSD)")
|
|
lines.append("onDemandLimitUSD=\(snapshot.onDemandLimitUSD.map { "$\($0)" } ?? "nil")")
|
|
if let teamUsed = snapshot.teamOnDemandUsedUSD {
|
|
lines.append("teamOnDemandUsedUSD=$\(teamUsed)")
|
|
}
|
|
if let teamLimit = snapshot.teamOnDemandLimitUSD {
|
|
lines.append("teamOnDemandLimitUSD=$\(teamLimit)")
|
|
}
|
|
lines.append("billingCycleEnd=\(snapshot.billingCycleEnd?.description ?? "nil")")
|
|
|
|
if let rawJSON = snapshot.rawJSON {
|
|
lines.append("")
|
|
lines.append("Raw API Response:")
|
|
lines.append(rawJSON)
|
|
}
|
|
|
|
return lines.joined(separator: "\n")
|
|
} catch {
|
|
lines.append("")
|
|
lines.append("Cursor probe failed: \(error.localizedDescription)")
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func debugAugmentLog() async -> String {
|
|
await runWithTimeout(seconds: 15) {
|
|
let probe = AugmentStatusProbe()
|
|
return await probe.debugRawProbe()
|
|
}
|
|
}
|
|
|
|
private static func debugAmpLog(
|
|
browserDetection: BrowserDetection,
|
|
ampCookieSource: ProviderCookieSource,
|
|
ampCookieHeader: String) async -> String
|
|
{
|
|
await runWithTimeout(seconds: 15) {
|
|
let fetcher = AmpUsageFetcher(browserDetection: browserDetection)
|
|
let manualHeader = ampCookieSource == .manual
|
|
? CookieHeaderNormalizer.normalize(ampCookieHeader)
|
|
: nil
|
|
return await fetcher.debugRawProbe(cookieHeaderOverride: manualHeader)
|
|
}
|
|
}
|
|
|
|
private static func debugOllamaLog(
|
|
browserDetection: BrowserDetection,
|
|
ollamaCookieSource: ProviderCookieSource,
|
|
ollamaCookieHeader: String) async -> String
|
|
{
|
|
await runWithTimeout(seconds: 15) {
|
|
let fetcher = OllamaUsageFetcher(browserDetection: browserDetection)
|
|
let manualHeader = ollamaCookieSource == .manual
|
|
? CookieHeaderNormalizer.normalize(ollamaCookieHeader)
|
|
: nil
|
|
return await fetcher.debugRawProbe(
|
|
cookieHeaderOverride: manualHeader,
|
|
manualCookieMode: ollamaCookieSource == .manual)
|
|
}
|
|
}
|
|
|
|
/// Version probes can spawn subprocesses (Antigravity's `ps` scan trips a TCC
|
|
/// prompt, CLI providers exec their binaries), so disabled providers must not
|
|
/// be probed (#2267). Settings changes re-run this when the enabled set changes.
|
|
static func versionDetectionImplementations(
|
|
enabled: Set<UsageProvider>) -> [any ProviderImplementation]
|
|
{
|
|
ProviderCatalog.all.filter { enabled.contains($0.id) }
|
|
}
|
|
|
|
func detectVersions() {
|
|
let enabled = Set(self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata))
|
|
self.versionDetectionProviders = enabled
|
|
let implementations = Self.versionDetectionImplementations(
|
|
enabled: Set(enabled.compactMap(\.firstPartyProvider)))
|
|
// Provider-specific by design: only Claude's version probe is background-gated and needs recovery handling.
|
|
let probesClaude = implementations.contains { $0.id == .claude }
|
|
let browserDetection = self.browserDetection
|
|
self.versionDetectionTask = Task { @MainActor [weak self] in
|
|
let detection = await Task.detached { () -> (
|
|
resolved: [UsageProvider: String],
|
|
claudeBinaryResolvable: Bool) in
|
|
var resolved: [UsageProvider: String] = [:]
|
|
await withTaskGroup(of: (UsageProvider, String?).self) { group in
|
|
for implementation in implementations {
|
|
let context = ProviderVersionContext(
|
|
provider: implementation.id,
|
|
browserDetection: browserDetection)
|
|
group.addTask {
|
|
await (implementation.id, implementation.detectVersion(context: context))
|
|
}
|
|
}
|
|
for await (provider, version) in group {
|
|
guard let version, !version.isEmpty else { continue }
|
|
resolved[provider] = version
|
|
}
|
|
}
|
|
// Provider-specific by design: disabled providers must not be probed (#2267), so the
|
|
// Claude binary resolves only when Claude was in this run and its probe returned nil.
|
|
let claudeBinaryResolvable = probesClaude
|
|
&& resolved[.claude] == nil
|
|
&& ProviderVersionDetector.claudeBinaryResolvable()
|
|
return (resolved, claudeBinaryResolvable)
|
|
}.value
|
|
guard let self else { return }
|
|
let resolved = detection.resolved
|
|
var versions = Dictionary(uniqueKeysWithValues: resolved.map { ($0.key.instanceID, $0.value) })
|
|
let claudeID = UsageProvider.claude.instanceID
|
|
// A gated or failed Claude probe preserves a user-initiated recovery while the binary still resolves.
|
|
// A missing/uninstalled binary clears the version so stale data does not survive CLI removal.
|
|
if probesClaude,
|
|
resolved[.claude] == nil,
|
|
detection.claudeBinaryResolvable,
|
|
let recoveredClaudeVersion = self.versions[claudeID]
|
|
{
|
|
versions[claudeID] = recoveredClaudeVersion
|
|
}
|
|
self.versions = versions
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func schedulePathDebugInfoRefresh() {
|
|
self.pathDebugRefreshTask?.cancel()
|
|
self.pathDebugRefreshTask = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(nanoseconds: 150_000_000)
|
|
} catch {
|
|
return
|
|
}
|
|
await self?.refreshPathDebugInfo()
|
|
}
|
|
}
|
|
|
|
private func runBackgroundSnapshot(
|
|
_ snapshot: @escaping @Sendable () async -> PathDebugSnapshot) async
|
|
{
|
|
let result = await snapshot()
|
|
await MainActor.run {
|
|
self.pathDebugInfo = result
|
|
}
|
|
}
|
|
|
|
private func refreshPathDebugInfo() async {
|
|
await self.runBackgroundSnapshot {
|
|
await PathBuilder.debugSnapshotAsync(purposes: [.rpc, .tty, .nodeTooling])
|
|
}
|
|
}
|
|
|
|
func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async {
|
|
guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else {
|
|
self.resetTokenUsageState(for: provider)
|
|
return
|
|
}
|
|
|
|
if Self.tokenCostRequiresProviderSnapshot(provider) {
|
|
if self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) != nil {
|
|
self.tokenErrors[provider.instanceID] = nil
|
|
self.tokenFailureGates[provider.instanceID]?.recordSuccess()
|
|
self.persistWidgetSnapshot(reason: "token-usage")
|
|
} else {
|
|
self.clearTokenSnapshot(for: provider)
|
|
self.tokenErrors[provider.instanceID] = nil
|
|
self.tokenFailureGates[provider.instanceID]?.reset()
|
|
}
|
|
return
|
|
}
|
|
|
|
guard self.settings.isCostUsageEffectivelyEnabled(for: provider) else {
|
|
self.resetTokenUsageState(for: provider)
|
|
return
|
|
}
|
|
|
|
guard self.isEnabled(provider) else {
|
|
self.resetTokenUsageState(for: provider)
|
|
return
|
|
}
|
|
|
|
// Provider-specific by design: Cursor cost shares the dashboard-cookie source policy with status fetching.
|
|
// Cursor cost honors the same cookie policy as status: when the user set the cookie source
|
|
// to Off, skip the network fetch entirely (mirrors CursorProviderDescriptor.checkStatus).
|
|
if provider == .cursor, self.settings.cursorCookieSource == .off {
|
|
self.resetTokenUsageState(for: provider)
|
|
return
|
|
}
|
|
|
|
guard !self.tokenRefreshInFlight.contains(provider.instanceID) else { return }
|
|
|
|
let now = Date()
|
|
let historyDays = self.settings.costUsageHistoryDays
|
|
// Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so
|
|
// cost and status share the same session; other sources fall back to auto resolution.
|
|
guard case let .proceed(cursorCookieHeaderOverride) = self.prepareCursorCostCookie(for: provider) else {
|
|
return
|
|
}
|
|
let costScope = self.tokenCostScope(for: provider)
|
|
let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider)
|
|
let publicationRevision = self.providerPublicationRevision(for: provider)
|
|
let providerConfigRevision = self.settings.providerConfigRevision(for: provider)
|
|
if !force, self.tokenRefreshCanReuseCurrentSnapshot(
|
|
provider: provider,
|
|
now: now,
|
|
costScopeSignature: costScopeSignature)
|
|
{
|
|
return
|
|
}
|
|
self.lastTokenFetchAt[provider.instanceID] = now
|
|
self.lastTokenFetchScope[provider.instanceID] = costScopeSignature
|
|
self.tokenRefreshInFlight.insert(provider.instanceID)
|
|
defer { self.tokenRefreshInFlight.remove(provider.instanceID) }
|
|
|
|
if let override = self._test_tokenUsageRefreshOverride {
|
|
await override(provider, force)
|
|
if Task.isCancelled {
|
|
self.lastTokenFetchAt.removeValue(forKey: provider.instanceID)
|
|
self.lastTokenFetchScope.removeValue(forKey: provider.instanceID)
|
|
}
|
|
return
|
|
}
|
|
|
|
let startedAt = Date()
|
|
self.tokenCostLogger
|
|
.debug("cost usage start provider=\(provider.rawValue) force=\(force)")
|
|
|
|
do {
|
|
// Codex cost usage scans the explicit token-cost scope: selected managed account by
|
|
// default, or this Mac's ambient Codex home when the local ledger is enabled.
|
|
let snapshot = try await self.loadTokenUsageSnapshot(
|
|
provider: provider,
|
|
force: force,
|
|
now: now,
|
|
codexHomePath: costScope.codexHomePath,
|
|
historyDays: historyDays,
|
|
cursorCookieHeaderOverride: cursorCookieHeaderOverride)
|
|
try Task.checkCancellation()
|
|
let completedCostScopeSignature = self.completedTokenCostScopeSignature(
|
|
provider: provider,
|
|
historyDays: historyDays,
|
|
initialSignature: costScopeSignature,
|
|
snapshot: snapshot)
|
|
guard self.tokenRefreshPublicationIsCurrent(
|
|
provider: provider,
|
|
publicationRevision: publicationRevision,
|
|
providerConfigRevision: providerConfigRevision,
|
|
historyDays: historyDays,
|
|
costScopeSignature: costScopeSignature,
|
|
fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint)
|
|
else {
|
|
self.clearTokenFetchMetadataIfMatching(
|
|
provider: provider,
|
|
attemptedAt: now,
|
|
costScopeSignature: costScopeSignature)
|
|
self.requestTokenRefreshAfterStaleCompletion(for: provider)
|
|
return
|
|
}
|
|
self.lastTokenFetchScope[provider.instanceID] = completedCostScopeSignature
|
|
self.startCodexCostCatchUpIfNeeded(afterRefreshing: provider)
|
|
|
|
guard !snapshot.daily.isEmpty || snapshot.meteredCostUSD != nil else {
|
|
self.publishConfirmedEmptyTokenSnapshot(for: provider)
|
|
self.tokenErrors[provider.instanceID] = Self.tokenCostNoDataMessage(for: provider)
|
|
self.tokenFailureGates[provider.instanceID]?.recordSuccess()
|
|
return
|
|
}
|
|
self.logTokenUsageSuccess(
|
|
provider: provider,
|
|
snapshot: snapshot,
|
|
historyDays: historyDays,
|
|
startedAt: startedAt)
|
|
self.publishTokenSnapshot(snapshot, for: provider)
|
|
self.tokenErrors[provider.instanceID] = nil
|
|
self.tokenFailureGates[provider.instanceID]?.recordSuccess()
|
|
self.persistWidgetSnapshot(reason: "token-usage")
|
|
} catch {
|
|
guard self.tokenRefreshPublicationIsCurrent(
|
|
provider: provider,
|
|
publicationRevision: publicationRevision,
|
|
providerConfigRevision: providerConfigRevision,
|
|
historyDays: historyDays,
|
|
costScopeSignature: costScopeSignature)
|
|
else {
|
|
self.clearTokenFetchMetadataIfMatching(
|
|
provider: provider,
|
|
attemptedAt: now,
|
|
costScopeSignature: costScopeSignature)
|
|
self.requestTokenRefreshAfterStaleCompletion(for: provider)
|
|
return
|
|
}
|
|
if error is CancellationError {
|
|
self.clearTokenFetchMetadataIfMatching(
|
|
provider: provider,
|
|
attemptedAt: now,
|
|
costScopeSignature: costScopeSignature)
|
|
return
|
|
}
|
|
let duration = Date().timeIntervalSince(startedAt)
|
|
let msg = error.localizedDescription
|
|
let durationText = String(format: "%.2f", duration)
|
|
let message = "cost usage failed provider=\(provider.rawValue) duration=\(durationText)s error=\(msg)"
|
|
self.tokenCostLogger.error(message)
|
|
if Self.tokenFetchFailureAllowsEarlyRetry(error) {
|
|
self.clearTokenFetchMetadataIfMatching(
|
|
provider: provider,
|
|
attemptedAt: now,
|
|
costScopeSignature: costScopeSignature)
|
|
}
|
|
let hadPriorData = self.tokenSnapshots[provider.instanceID] != nil
|
|
let shouldSurface = self.tokenFailureGates[provider.instanceID]?
|
|
.shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
|
|
if shouldSurface {
|
|
self.tokenErrors[provider.instanceID] = error.localizedDescription
|
|
self.clearTokenSnapshot(for: provider)
|
|
} else {
|
|
self.tokenErrors[provider.instanceID] = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private func resetTokenUsageState(for provider: UsageProvider) {
|
|
// Provider-specific by design: resetting Codex token state also cancels its two ledger catch-up workflows.
|
|
if provider == .codex {
|
|
self.cancelCodexCostCatchUp()
|
|
self.cancelSpendDashboardCodexCostCatchUp()
|
|
}
|
|
self.clearTokenSnapshot(for: provider)
|
|
self.clearSpendDashboardTokenSnapshot(for: provider)
|
|
self.tokenErrors[provider.instanceID] = nil
|
|
self.tokenFailureGates[provider.instanceID]?.reset()
|
|
self.lastTokenFetchAt.removeValue(forKey: provider.instanceID)
|
|
self.lastTokenFetchScope.removeValue(forKey: provider.instanceID)
|
|
self.lastSpendDashboardTokenFetchAt.removeValue(forKey: provider.instanceID)
|
|
self.lastSpendDashboardTokenFetchScope.removeValue(forKey: provider.instanceID)
|
|
}
|
|
|
|
private func logTokenUsageSuccess(
|
|
provider: UsageProvider,
|
|
snapshot: CostUsageTokenSnapshot,
|
|
historyDays: Int,
|
|
startedAt: Date)
|
|
{
|
|
let durationText = String(format: "%.2f", Date().timeIntervalSince(startedAt))
|
|
let sessionCost = snapshot.sessionCostUSD
|
|
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
|
|
let monthCost = snapshot.last30DaysCostUSD
|
|
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
|
|
let message =
|
|
"cost usage success provider=\(provider.rawValue) " +
|
|
"duration=\(durationText)s " +
|
|
"today=\(sessionCost) " +
|
|
"historyDays=\(historyDays) windowCost=\(monthCost)"
|
|
self.tokenCostLogger.info(message)
|
|
}
|
|
|
|
private func clearTokenFetchMetadataIfMatching(
|
|
provider: UsageProvider,
|
|
attemptedAt: Date,
|
|
costScopeSignature: String)
|
|
{
|
|
guard self.lastTokenFetchAt[provider.instanceID] == attemptedAt,
|
|
self.lastTokenFetchScope[provider.instanceID] == costScopeSignature
|
|
else {
|
|
return
|
|
}
|
|
self.lastTokenFetchAt.removeValue(forKey: provider.instanceID)
|
|
self.lastTokenFetchScope.removeValue(forKey: provider.instanceID)
|
|
}
|
|
|
|
/// Fast failures may retry on the next scheduled pass instead of waiting out the fetch
|
|
/// TTL; timed-out scans keep the TTL so a slow corpus cannot thrash back-to-back rescans.
|
|
nonisolated static func tokenFetchFailureAllowsEarlyRetry(_ error: Error) -> Bool {
|
|
if case CostUsageError.timedOut = error {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
extension UsageStore {
|
|
func retainCodingActivityIfNewer(_ date: Date) {
|
|
if self.lastCodingActivityAt.map({ date > $0 }) ?? true {
|
|
self.lastCodingActivityAt = date
|
|
}
|
|
}
|
|
|
|
func clearCodingActivityObservation() {
|
|
self.lastCodingActivityAt = nil
|
|
}
|
|
|
|
func restartAdaptiveTimerPreservingResetBoundary() {
|
|
self.startTimer(preservingResetBoundaryRefresh: true)
|
|
}
|
|
|
|
func noteMenuOpened(at date: Date = Date()) {
|
|
self.lastMenuOpenAt = date
|
|
self.advanceAdaptiveTimerIfEarlier(at: date)
|
|
}
|
|
}
|