1352 lines · 57.4 KB · Swift
RawDownload
import Foundation public protocol ClaudeUsageFetching: Sendable {    func loadLatestUsage(model: String) async throws -> ClaudeUsageSnapshot    func debugRawProbe(model: String) async -> String    func detectVersion() -> String?} public struct ClaudeUsageSnapshot: Sendable {    public enum PrimaryWindowKind: Equatable, Sendable {        case usage        case spendLimit    }     public let primary: RateWindow    public let primaryWindowKind: PrimaryWindowKind    public let secondary: RateWindow?    public let opus: RateWindow?    public let extraRateWindows: [NamedRateWindow]    public let providerCost: ProviderCostSnapshot?    public let updatedAt: Date    public let accountEmail: String?    public let accountOrganization: String?    public let loginMethod: String?    public let rawText: String?     public init(        primary: RateWindow,        primaryWindowKind: PrimaryWindowKind = .usage,        secondary: RateWindow?,        opus: RateWindow?,        extraRateWindows: [NamedRateWindow] = [],        providerCost: ProviderCostSnapshot? = nil,        updatedAt: Date,        accountEmail: String?,        accountOrganization: String?,        loginMethod: String?,        rawText: String?)    {        self.primary = primary        self.primaryWindowKind = primaryWindowKind        self.secondary = secondary        self.opus = opus        self.extraRateWindows = extraRateWindows        self.providerCost = providerCost        self.updatedAt = updatedAt        self.accountEmail = accountEmail        self.accountOrganization = accountOrganization        self.loginMethod = loginMethod        self.rawText = rawText    }} public enum ClaudeUsageError: LocalizedError, Sendable {    case claudeNotInstalled    case parseFailed(String)    case oauthFailed(String)     public var errorDescription: String? {        switch self {        case .claudeNotInstalled:            "Claude CLI is not installed. Install it from https://code.claude.com/docs/en/overview."        case let .parseFailed(details):            "Could not parse Claude usage: \(details)"        case let .oauthFailed(details):            details        }    }} public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {    private static let sessionWindowMinutes = 5 * 60    private static let weeklyWindowMinutes = 7 * 24 * 60    private static let cliAutoProbeTimeout: TimeInterval = 12    private static let cliProbeTimeout: TimeInterval = 24    private static let cliRetryProbeTimeout: TimeInterval = 60    private struct Configuration {        let environment: [String: String]        let runtime: ProviderRuntime        let dataSource: ClaudeUsageDataSource        let oauthKeychainPromptCooldownEnabled: Bool        let allowBackgroundDelegatedRefresh: Bool        let allowStartupBootstrapPrompt: Bool        let useWebExtras: Bool        let manualCookieHeader: String?        let webOrganizationID: String?        let keepCLISessionsAlive: Bool        let browserDetection: BrowserDetection    }     private let configuration: Configuration    private static let log = CodexBarLog.logger(LogCategories.claudeUsage)    private static var isClaudeOAuthFlowDebugEnabled: Bool {        ProcessInfo.processInfo.environment["CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW"] == "1"    }     private var environment: [String: String] {        self.configuration.environment    }     private var runtime: ProviderRuntime {        self.configuration.runtime    }     private var dataSource: ClaudeUsageDataSource {        self.configuration.dataSource    }     private var oauthKeychainPromptCooldownEnabled: Bool {        self.configuration.oauthKeychainPromptCooldownEnabled    }     private var allowBackgroundDelegatedRefresh: Bool {        self.configuration.allowBackgroundDelegatedRefresh    }     private var allowStartupBootstrapPrompt: Bool {        self.configuration.allowStartupBootstrapPrompt    }     private var useWebExtras: Bool {        self.configuration.useWebExtras    }     private var manualCookieHeader: String? {        self.configuration.manualCookieHeader    }     private var webOrganizationID: String? {        self.configuration.webOrganizationID    }     private var keepCLISessionsAlive: Bool {        self.configuration.keepCLISessionsAlive    }     private var browserDetection: BrowserDetection {        self.configuration.browserDetection    }     private struct ClaudeOAuthKeychainPromptPolicy {        let mode: ClaudeOAuthKeychainPromptMode        let isApplicable: Bool        let interaction: ProviderInteraction         var canPromptNow: Bool {            switch self.mode {            case .never:                false            case .onlyOnUserAction:                self.interaction == .userInitiated            case .always:                true            }        }         /// Respect the Keychain prompt cooldown for background operations to avoid spamming system dialogs.        /// User actions (menu open / refresh / settings) are allowed to bypass the cooldown.        var shouldRespectKeychainPromptCooldown: Bool {            self.interaction != .userInitiated        }         var interactionLabel: String {            self.interaction == .userInitiated ? "user" : "background"        }    }     private static func currentClaudeOAuthInteractivePromptPolicy() -> ClaudeOAuthKeychainPromptPolicy {        let policy = ClaudeOAuthKeychainPromptPolicy(            mode: ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode(),            isApplicable: true,            interaction: ProviderInteractionContext.current)         // User actions should be able to immediately retry a Security.framework fallback repair after a background        // cooldown was recorded, even when /usr/bin/security is the primary reader.        if policy.interaction == .userInitiated {            if ClaudeOAuthKeychainAccessGate.clearDenied() {                Self.log.info("Claude OAuth keychain cooldown cleared by user action")            }        }        return policy    }     private static func currentClaudeOAuthDelegatedRefreshPolicy() -> ClaudeOAuthKeychainPromptPolicy {        ClaudeOAuthKeychainPromptPolicy(            mode: ClaudeOAuthKeychainPromptPreference.current(),            isApplicable: ClaudeOAuthKeychainPromptPreference.isApplicable(),            interaction: ProviderInteractionContext.current)    }     private static func assertDelegatedRefreshAllowedInCurrentInteraction(        policy: ClaudeOAuthKeychainPromptPolicy,        allowBackgroundDelegatedRefresh: Bool) throws    {        guard policy.isApplicable else { return }        if policy.mode == .never {            throw ClaudeUsageError.oauthFailed("Delegated refresh is disabled by 'never' keychain policy.")        }        if policy.mode == .onlyOnUserAction,           policy.interaction != .userInitiated,           !allowBackgroundDelegatedRefresh        {            throw ClaudeUsageError.oauthFailed(                "Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy "                    + "is set to only prompt on user action. Open the CodexBar menu or click Refresh to retry.")        }    }     #if DEBUG    @TaskLocal static var loadOAuthCredentialsOverride: (@Sendable (        [String: String],        Bool,        Bool) async throws -> ClaudeOAuthCredentials)?    @TaskLocal static var fetchOAuthUsageOverride: (@Sendable (String) async throws -> OAuthUsageResponse)?    @TaskLocal static var delegatedRefreshAttemptOverride: (@Sendable (        Date,        TimeInterval,        [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)?    @TaskLocal static var hasCachedCredentialsOverride: Bool?    #endif     /// Creates a new ClaudeUsageFetcher.    /// - Parameters:    ///   - environment: Process environment (default: current process environment)    ///   - dataSource: Usage data source (default: OAuth API).    ///   - useWebExtras: If true, attempts to enrich usage with Claude web data (cookies).    public init(        browserDetection: BrowserDetection,        environment: [String: String] = ProcessInfo.processInfo.environment,        runtime: ProviderRuntime = .app,        dataSource: ClaudeUsageDataSource = .oauth,        oauthKeychainPromptCooldownEnabled: Bool = false,        allowBackgroundDelegatedRefresh: Bool = false,        allowStartupBootstrapPrompt: Bool = false,        useWebExtras: Bool = false,        manualCookieHeader: String? = nil,        webOrganizationID: String? = nil,        keepCLISessionsAlive: Bool = false)    {        self.configuration = Configuration(            environment: environment,            runtime: runtime,            dataSource: dataSource,            oauthKeychainPromptCooldownEnabled: oauthKeychainPromptCooldownEnabled,            allowBackgroundDelegatedRefresh: allowBackgroundDelegatedRefresh,            allowStartupBootstrapPrompt: allowStartupBootstrapPrompt,            useWebExtras: useWebExtras,            manualCookieHeader: manualCookieHeader,            webOrganizationID: webOrganizationID,            keepCLISessionsAlive: keepCLISessionsAlive,            browserDetection: browserDetection)    }     private struct OAuthExecutor {        let fetcher: ClaudeUsageFetcher         func load(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot {            do {                let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy()                 #if DEBUG                let hasCache = if let hasCachedCredentialsOverride = ClaudeUsageFetcher.hasCachedCredentialsOverride {                    hasCachedCredentialsOverride                } else if ClaudeUsageFetcher.loadOAuthCredentialsOverride != nil {                    false                } else {                    ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment)                }                #else                let hasCache = ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment)                #endif                 let startupBootstrapOverride = self.shouldAllowStartupBootstrapPrompt(                    policy: promptPolicy,                    hasCache: hasCache)                let allowKeychainPrompt = (promptPolicy.canPromptNow || startupBootstrapOverride) && !hasCache                ClaudeUsageFetcher.logOAuthBootstrapPromptDecision(                    allowKeychainPrompt: allowKeychainPrompt,                    policy: promptPolicy,                    hasCache: hasCache,                    startupBootstrapOverride: startupBootstrapOverride)                 let credentials = try await ClaudeOAuthCredentialsStore.$allowBackgroundPromptBootstrap                    .withValue(startupBootstrapOverride) {                        try await ClaudeUsageFetcher.loadOAuthCredentials(                            environment: self.fetcher.environment,                            allowKeychainPrompt: allowKeychainPrompt,                            respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown)                    }                 try self.validateRequiredOAuthScope(credentials)                let usage = try await ClaudeUsageFetcher.fetchOAuthUsage(accessToken: credentials.accessToken)                return try ClaudeUsageFetcher.mapOAuthUsage(usage, credentials: credentials)            } catch let error as CancellationError {                throw error            } catch let error as ClaudeUsageError {                throw error            } catch let error as ClaudeOAuthCredentialsError {                if case .refreshDelegatedToClaudeCLI = error {                    return try await self.loadAfterDelegatedRefresh(allowDelegatedRetry: allowDelegatedRetry)                }                throw ClaudeUsageError.oauthFailed(error.localizedDescription)            } catch let error as ClaudeOAuthFetchError {                if case .rateLimited = error {                    throw ClaudeUsageError.oauthFailed(error.localizedDescription)                }                ClaudeOAuthCredentialsStore.invalidateCache()                if case let .serverError(statusCode, body) = error,                   statusCode == 403,                   body?.contains("user:profile") ?? false                {                    throw ClaudeUsageError.oauthFailed(                        "Claude OAuth token does not meet scope requirement 'user:profile'. "                            + "Run `claude setup-token` to re-generate credentials, or switch Claude Source to "                            + "Web/CLI.")                }                throw ClaudeUsageError.oauthFailed(error.localizedDescription)            } catch {                throw ClaudeUsageError.oauthFailed(error.localizedDescription)            }        }         private func shouldAllowStartupBootstrapPrompt(            policy: ClaudeOAuthKeychainPromptPolicy,            hasCache: Bool) -> Bool        {            guard self.fetcher.allowStartupBootstrapPrompt else { return false }            guard !hasCache else { return false }            guard ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() == .onlyOnUserAction else {                return false            }            guard policy.interaction == .background else { return false }            return ProviderRefreshContext.current == .startup        }         private func loadAfterDelegatedRefresh(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot {            guard allowDelegatedRetry else {                throw ClaudeUsageError.oauthFailed(                    "Claude OAuth token expired and delegated Claude CLI refresh did not recover. "                        + "Run `claude login`, then retry.")            }             try Task.checkCancellation()             let delegatedPromptPolicy = ClaudeUsageFetcher.currentClaudeOAuthDelegatedRefreshPolicy()            try ClaudeUsageFetcher.assertDelegatedRefreshAllowedInCurrentInteraction(                policy: delegatedPromptPolicy,                allowBackgroundDelegatedRefresh: self.fetcher.allowBackgroundDelegatedRefresh)             let delegatedOutcome = await ClaudeUsageFetcher.attemptDelegatedRefresh(                environment: self.fetcher.environment)            ClaudeUsageFetcher.log.info(                "Claude OAuth delegated refresh attempted",                metadata: [                    "outcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome),                ])             do {                if self.fetcher.oauthKeychainPromptCooldownEnabled {                    switch delegatedOutcome {                    case .skippedByCooldown, .cliUnavailable:                        throw ClaudeUsageError.oauthFailed(                            "Claude OAuth token expired; delegated refresh is unavailable (outcome="                                + "\(ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome))).")                    case .attemptedSucceeded, .attemptedFailed:                        break                    }                }                 try Task.checkCancellation()                 _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()                 let didSyncSilently = delegatedOutcome == .attemptedSucceeded                    && ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(now: Date())                 let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy()                ClaudeUsageFetcher.logDeferredBackgroundDelegatedRecoveryIfNeeded(                    delegatedOutcome: delegatedOutcome,                    didSyncSilently: didSyncSilently,                    policy: promptPolicy)                let retryAllowKeychainPrompt = promptPolicy.canPromptNow && !didSyncSilently                if retryAllowKeychainPrompt {                    ClaudeUsageFetcher.log.info(                        "Claude OAuth keychain prompt allowed (post-delegation retry)",                        metadata: [                            "interaction": promptPolicy.interactionLabel,                            "promptMode": promptPolicy.mode.rawValue,                            "promptPolicyApplicable": "\(promptPolicy.isApplicable)",                            "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome),                            "didSyncSilently": "\(didSyncSilently)",                        ])                }                if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled {                    ClaudeUsageFetcher.log.debug(                        "Claude OAuth credential load (post-delegation retry start)",                        metadata: [                            "cooldownEnabled": "\(self.fetcher.oauthKeychainPromptCooldownEnabled)",                            "didSyncSilently": "\(didSyncSilently)",                            "allowKeychainPrompt": "\(retryAllowKeychainPrompt)",                            "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome),                            "interaction": promptPolicy.interactionLabel,                            "promptMode": promptPolicy.mode.rawValue,                            "promptPolicyApplicable": "\(promptPolicy.isApplicable)",                        ])                }                 let refreshedCredentials = try await ClaudeUsageFetcher.loadOAuthCredentials(                    environment: self.fetcher.environment,                    allowKeychainPrompt: retryAllowKeychainPrompt,                    respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown)                if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled {                    ClaudeUsageFetcher.log.debug(                        "Claude OAuth credential load (post-delegation retry)",                        metadata: [                            "cooldownEnabled": "\(self.fetcher.oauthKeychainPromptCooldownEnabled)",                            "didSyncSilently": "\(didSyncSilently)",                            "allowKeychainPrompt": "\(retryAllowKeychainPrompt)",                            "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome),                            "interaction": promptPolicy.interactionLabel,                            "promptMode": promptPolicy.mode.rawValue,                            "promptPolicyApplicable": "\(promptPolicy.isApplicable)",                        ])                }                 try self.validateRequiredOAuthScope(refreshedCredentials)                let usage = try await ClaudeUsageFetcher.fetchOAuthUsage(                    accessToken: refreshedCredentials.accessToken)                return try ClaudeUsageFetcher.mapOAuthUsage(usage, credentials: refreshedCredentials)            } catch {                ClaudeUsageFetcher.log.debug(                    "Claude OAuth post-delegation retry failed",                    metadata: ClaudeUsageFetcher.delegatedRetryFailureMetadata(                        error: error,                        oauthKeychainPromptCooldownEnabled: self.fetcher.oauthKeychainPromptCooldownEnabled,                        delegatedOutcome: delegatedOutcome))                throw ClaudeUsageError.oauthFailed(                    ClaudeUsageFetcher.delegatedRefreshFailureMessage(                        for: delegatedOutcome,                        retryError: error))            }        }         private func validateRequiredOAuthScope(_ credentials: ClaudeOAuthCredentials) throws {            guard credentials.scopes.contains("user:profile") else {                let scopes = credentials.scopes.joined(separator: ", ")                let detail = scopes.isEmpty                    ? "Claude OAuth token missing 'user:profile' scope."                    : "Claude OAuth token missing 'user:profile' scope (has: \(scopes))."                throw ClaudeUsageError.oauthFailed(                    detail + " Run `claude setup-token` to re-generate credentials, or switch Claude Source to "                        + "Web/CLI.")            }        }    }     private struct StepExecutor {        let fetcher: ClaudeUsageFetcher         func loadLatestUsage(model: String) async throws -> ClaudeUsageSnapshot {            switch self.fetcher.dataSource {            case .auto:                return try await self.executeAuto(model: model)            case .api:                throw ClaudeUsageError.parseFailed("Claude Admin API usage is handled by the provider descriptor.")            case .oauth:                var snapshot = try await self.fetcher.loadViaOAuth(allowDelegatedRetry: true)                snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot)                return snapshot            case .web:                return try await self.fetcher.loadViaWebAPI()            case .cli:                return try await self.loadViaCLIWithRetry(model: model)            }        }         private func executeAuto(model: String) async throws -> ClaudeUsageSnapshot {            let plan = await self.makeAutoFetchPlan()            self.logAutoPlan(plan)             let executionSteps = plan.executionSteps            for (index, step) in executionSteps.enumerated() {                do {                    return try await self.execute(step: step, model: model)                } catch {                    if index < executionSteps.count - 1 {                        ClaudeUsageFetcher.log.debug(                            "Claude planner step failed; falling back to next step",                            metadata: [                                "step": step.dataSource.rawValue,                                "reason": step.inclusionReason.rawValue,                                "errorType": String(describing: type(of: error)),                            ])                        continue                    }                    throw error                }            }            throw ClaudeUsageError.parseFailed("Claude planner produced no executable steps.")        }

Lines 1–500 of 1352.