From 77535eb14fe044044b2fd05949c687c1947859e0 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 7 Mar 2026 16:11:00 +0530 Subject: [PATCH 0001/1239] Characterize Claude provider baseline --- .../ClaudeBaselineCharacterizationTests.swift | 82 ++++ Tests/CodexBarTests/ClaudeUsageTests.swift | 293 +++++++++++++++ .../SettingsStoreCoverageTests.swift | 24 ++ ...kenAccountEnvironmentPrecedenceTests.swift | 104 +++++ docs/claude.md | 19 +- docs/refactor/claude-current-baseline.md | 155 ++++++++ docs/refactor/claude-provider-vnext-locked.md | 355 ++++++++++++++++++ 7 files changed, 1026 insertions(+), 6 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift create mode 100644 docs/refactor/claude-current-baseline.md create mode 100644 docs/refactor/claude-provider-vnext-locked.md diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift new file mode 100644 index 0000000000..41caed0e16 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -0,0 +1,82 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct ClaudeBaselineCharacterizationTests { + private func makeContext( + runtime: ProviderRuntime, + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + let env: [String: String] = [:] + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: runtime, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private func strategyIDs( + runtime: ProviderRuntime, + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil) async -> [String] + { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: runtime, sourceMode: sourceMode, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + return strategies.map(\.id) + } + + @Test + func appAutoPipelineOrder_isOAuthThenCLIThenWeb() async { + let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto) + #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) + } + + @Test + func cliAutoPipelineOrder_isWebThenCLI() async { + let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: .auto) + #expect(strategyIDs == ["claude.web", "claude.cli"]) + } + + @Test(arguments: [ + (ProviderSourceMode.oauth, "claude.oauth"), + (ProviderSourceMode.cli, "claude.cli"), + (ProviderSourceMode.web, "claude.web"), + ]) + func explicitModesResolveSingleClaudeStrategy(sourceMode: ProviderSourceMode, expectedStrategyID: String) async { + let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: sourceMode) + #expect(strategyIDs == [expectedStrategyID]) + } + + @Test(arguments: [ + (ProviderSourceMode.oauth, "claude.oauth"), + (ProviderSourceMode.cli, "claude.cli"), + (ProviderSourceMode.web, "claude.web"), + ]) + func cliExplicitModesResolveSingleClaudeStrategy(sourceMode: ProviderSourceMode, expectedStrategyID: String) async { + let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: sourceMode) + #expect(strategyIDs == [expectedStrategyID]) + } + + @Test + func claudeOAuthTokenHeuristics_acceptRawAndBearerInputs() { + #expect(TokenAccountSupportCatalog.isClaudeOAuthToken("sk-ant-oat-test-token")) + #expect(TokenAccountSupportCatalog.isClaudeOAuthToken("Bearer sk-ant-oat-test-token")) + } + + @Test + func claudeOAuthTokenHeuristics_rejectCookieShapedInputs() { + #expect(!TokenAccountSupportCatalog.isClaudeOAuthToken("sessionKey=sk-ant-session")) + #expect(!TokenAccountSupportCatalog.isClaudeOAuthToken("Cookie: sessionKey=sk-ant-session; foo=bar")) + } +} diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 7f007b8118..608ca4190d 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -856,6 +856,299 @@ struct ClaudeUsageTests { } } +@Suite(.serialized) +struct ClaudeAutoFetcherCharacterizationTests { + private final class RequestLog: @unchecked Sendable { + private var paths: [String] = [] + private let lock = NSLock() + + func append(_ path: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.paths.append(path) + } + + func current() -> [String] { + self.lock.lock() + defer { self.lock.unlock() } + return self.paths + } + } + + private final class InvocationLog: @unchecked Sendable { + let url: URL + + init(url: URL) { + self.url = url + } + + func contents() -> String { + (try? String(contentsOf: self.url, encoding: .utf8)) ?? "" + } + } + + private static func makeOAuthUsageResponse() throws -> OAuthUsageResponse { + let json = """ + { + "five_hour": { "utilization": 7, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 21, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + return try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + } + + private static func makeFakeClaudeCLI(logURL: URL) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-auto-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let scriptURL = directory.appendingPathComponent("claude") + let script = """ + #!/bin/sh + LOG_FILE='\(logURL.path)' + while IFS= read -r line; do + case "$line" in + "/usage") + printf 'usage\\n' >> "$LOG_FILE" + cat <<'EOF' + Current session + 93% left + Dec 23 at 4:00PM + + Current week (all models) + 79% left + Dec 29 at 11:00PM + EOF + ;; + "/status") + printf 'status\\n' >> "$LOG_FILE" + cat <<'EOF' + Account: cli@example.com + Org: CLI Org + EOF + ;; + esac + done + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: scriptURL.path) + return scriptURL + } + + private func withClaudeCLIPath(_ path: String?, operation: () async throws -> T) async rethrows -> T { + let key = "CLAUDE_CLI_PATH" + let original = getenv(key).map { String(cString: $0) } + if let path { + setenv(key, path, 1) + } else { + unsetenv(key) + } + defer { + if let original { + setenv(key, original, 1) + } else { + unsetenv(key) + } + } + return try await operation() + } + + private func withNoOAuthCredentials(operation: () async throws -> T) async rethrows -> T { + let missingCredentialsURL = FileManager.default.temporaryDirectory + .appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json") + return try await KeychainCacheStore.withServiceOverrideForTesting("rat-107-\(UUID().uuidString)") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: nil) + { + try await operation() + } + } + } + } + } + } + } + + private func withClaudeWebStub( + handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data), + operation: () async throws -> T) async rethrows -> T + { + let registered = URLProtocol.registerClass(ClaudeAutoFetcherStubURLProtocol.self) + ClaudeAutoFetcherStubURLProtocol.handler = handler + defer { + if registered { + URLProtocol.unregisterClass(ClaudeAutoFetcherStubURLProtocol.self) + } + ClaudeAutoFetcherStubURLProtocol.handler = nil + } + return try await operation() + } + + private static func makeJSONResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + @Test + func autoPrefersOAuthEvenWhenWebAndCLIAppearAvailable() async throws { + let usageResponse = try Self.makeOAuthUsageResponse() + let cliLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-auto-cli-log-\(UUID().uuidString).txt") + let log = InvocationLog(url: cliLogURL) + let fakeCLI = try Self.makeFakeClaudeCLI(logURL: cliLogURL) + let webRequests = RequestLog() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [ + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + ], + dataSource: .auto, + manualCookieHeader: "sessionKey=sk-ant-session-token") + + try await self.withClaudeCLIPath(fakeCLI.path) { + try await self.withClaudeWebStub(handler: { request in + webRequests.append(request.url?.path ?? "") + let url = try #require(request.url) + return Self.makeJSONResponse(url: url, body: "{}") + }, operation: { + let fetchOverride: @Sendable (String) async throws -> OAuthUsageResponse = { _ in usageResponse } + let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue( + fetchOverride, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + + #expect(snapshot.primary.usedPercent == 7) + #expect(snapshot.secondary?.usedPercent == 21) + #expect(log.contents().isEmpty) + let requests = webRequests.current() + #expect(requests.isEmpty) + }) + } + } + + @Test + func autoPrefersWebBeforeCLIWhenOAuthUnavailable() async throws { + let cliLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-auto-web-log-\(UUID().uuidString).txt") + let log = InvocationLog(url: cliLogURL) + let fakeCLI = try Self.makeFakeClaudeCLI(logURL: cliLogURL) + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .auto, + manualCookieHeader: "sessionKey=sk-ant-session-token") + + try await self.withClaudeCLIPath(fakeCLI.path) { + try await self.withNoOAuthCredentials { + try await self.withClaudeWebStub(handler: { request in + let url = try #require(request.url) + switch url.path { + case "/api/organizations": + return Self.makeJSONResponse( + url: url, + body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#) + case "/api/organizations/org-123/usage": + let body = """ + { + "five_hour": { "utilization": 11, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 22, "resets_at": "2025-12-29T23:00:00.000Z" }, + "seven_day_opus": { "utilization": 33 } + } + """ + return Self.makeJSONResponse( + url: url, + body: body) + case "/api/account": + let body = """ + { + "email_address": "web@example.com", + "memberships": [ + { + "organization": { + "uuid": "org-123", + "name": "Test Org", + "rate_limit_tier": "claude_max", + "billing_type": "stripe" + } + } + ] + } + """ + return Self.makeJSONResponse( + url: url, + body: body) + case "/api/organizations/org-123/overage_spend_limit": + let body = """ + {"monthly_credit_limit":5000,"currency":"USD","used_credits":1200,"is_enabled":true} + """ + return Self.makeJSONResponse( + url: url, + body: body) + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) + } + }, operation: { + let snapshot = try await fetcher.loadLatestUsage(model: "sonnet") + + #expect(snapshot.primary.usedPercent == 11) + #expect(snapshot.secondary?.usedPercent == 22) + #expect(snapshot.opus?.usedPercent == 33) + #expect(snapshot.accountEmail == "web@example.com") + #expect(snapshot.loginMethod == "Claude Max") + #expect(log.contents().isEmpty) + }) + } + } + } +} + +final class ClaudeAutoFetcherStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "claude.ai" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + extension ClaudeUsageTests { @Test func oauthDelegatedRetry_experimental_background_ignoresOnlyOnUserActionSuppression() async throws { diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index aa3c096b80..1b53d36bc5 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -79,6 +79,30 @@ struct SettingsStoreCoverageTests { settings.reloadTokenAccounts() } + @Test + func claudeSnapshotUsesOAuthRoutingForOAuthTokenAccounts() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .claude, label: "OAuth", token: "Bearer sk-ant-oat-account-token") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .off) + #expect(snapshot.manualCookieHeader?.isEmpty == true) + } + + @Test + func claudeSnapshotUsesManualCookieRoutingForSessionKeyAccounts() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .claude, label: "Cookie", token: "sk-ant-session-token") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + @Test func tokenCostUsageSourceDetection() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index cb36b24ae8..c9d7400c72 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -75,6 +75,110 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(ollamaSettings.manualCookieHeader == "session=account-token") } + @Test + func claudeOAuthTokenAccountOverridesEnvironmentInAppEnvironmentBuilder() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-app") + settings.addTokenAccount(provider: .claude, label: "OAuth", token: "Bearer sk-ant-oat-account-token") + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .claude, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") + } + + @Test + func claudeOAuthTokenSelectionForcesOAuthInCLISettingsSnapshot() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieSource: .auto, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .oauth) + #expect(claudeSettings.cookieSource == .off) + #expect(claudeSettings.manualCookieHeader == nil) + } + + @Test + func claudeOAuthTokenSelectionInjectsEnvironmentOverrideInCLI() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig(id: .claude, tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + + let env = tokenContext.environment(base: ["FOO": "bar"], provider: .claude, account: account) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") + } + + @Test + func claudeSessionKeySelectionStaysInManualCookieModeInCLISettingsSnapshot() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieSource: .auto, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .auto) + #expect(claudeSettings.cookieSource == .manual) + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + @Test func applyAccountLabelInAppPreservesSnapshotFields() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-apply-app") diff --git a/docs/claude.md b/docs/claude.md index 22737efd92..f5f2fc17e9 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -9,14 +9,19 @@ read_when: # Claude provider -Claude supports three usage data paths plus local cost usage. Source selection is automatic unless debug override is set. +Claude supports three usage data paths plus local cost usage. The main provider pipeline uses runtime-specific +automatic selection, but the codebase still has multiple active Claude `.auto` decision sites while the refactor is +pending. For the exact current-state parity contract, see +[docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). ## Data sources + selection order ### Default selection (debug menu disabled) -1) OAuth API (if Claude CLI credentials include `user:profile` scope). -2) CLI PTY (`claude`), if OAuth is unavailable or fails. -3) Web API (browser cookies, `sessionKey`), if OAuth + CLI are unavailable or fail. +- App runtime main pipeline: OAuth API → CLI PTY → Web API. +- CLI runtime main pipeline: Web API → CLI PTY. +- Explicit picker modes (OAuth/Web/CLI) bypass automatic fallback. +- A lower-level direct Claude fetcher still contains a separate `.auto` order. That inconsistency is tracked in + [docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). Usage source picker: - Preferences → Providers → Claude → Usage source (Auto/OAuth/Web/CLI). @@ -57,8 +62,10 @@ Usage source picker: - Manual mode accepts a `Cookie:` header from a claude.ai request. - Multi-account manual tokens: add entries to `~/.codexbar/config.json` (`tokenAccounts`) and set Claude cookies to Manual. The menu can show all accounts stacked or a switcher bar (Preferences → Advanced → Display). -- Claude token accounts accept either `sessionKey` cookies or OAuth access tokens (`sk-ant-oat...`). OAuth tokens use - the Anthropic OAuth usage endpoint; to force cookie mode, paste `sessionKey=` or a full `Cookie:` header. +- Claude token accounts accept either `sessionKey` cookies or OAuth access tokens (`sk-ant-oat...`). OAuth-token + accounts route to the OAuth path and disable cookie mode; session-key or cookie-header accounts stay in manual + cookie mode. The exact edge-routing rules are documented in + [docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). - Cookie source order: 1) Safari: `~/Library/Cookies/Cookies.binarycookies` 2) Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` diff --git a/docs/refactor/claude-current-baseline.md b/docs/refactor/claude-current-baseline.md new file mode 100644 index 0000000000..bedeca3860 --- /dev/null +++ b/docs/refactor/claude-current-baseline.md @@ -0,0 +1,155 @@ +--- +summary: "Current Claude behavior baseline before vNext refactor work." +read_when: + - Planning Claude refactor tickets + - Changing Claude runtime/source selection + - Changing Claude OAuth prompt or cooldown behavior + - Changing Claude token-account routing +--- + +# Claude current baseline + +This document is the current-state parity reference for Claude behavior in CodexBar. + +Use it when later tickets need to preserve or intentionally change Claude behavior. When the refactor plan, +summary docs, and running code disagree, treat current code plus characterization coverage as authoritative, and use +this document as the human-readable summary of that current state. + +## Scope of this baseline + +This baseline captures the current behavior surface that later refactor work must preserve unless a future ticket +changes it intentionally: + +- runtime/source-mode selection, +- prompt and cooldown behavior that affects Claude OAuth repair flows, +- token-account routing at the app and CLI edges, +- provider siloing and web-enrichment rules, +- the current relationship between the public Claude doc and the vNext refactor plan. + +## Active behavior owners + +Current Claude behavior is defined by several active owners, not one central planner: + +- `Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift` + owns the main provider-pipeline strategy order and fallback rules. +- `Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift` + still owns a separate direct `.auto` path, delegated refresh, prompt/cooldown handling, and web-extra enrichment. +- `Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift` + owns app-side token-account routing into cookie or OAuth behavior. +- `Sources/CodexBarCLI/TokenAccountCLI.swift` + owns CLI-side token-account routing and effective source-mode overrides. +- `Sources/CodexBarCore/TokenAccountSupport.swift` + owns the current string heuristics that distinguish Claude OAuth access tokens from cookie/session-key inputs. + +## Current runtime and source-mode behavior + +### Main provider pipeline + +The generic provider pipeline currently resolves Claude strategies in this order: + +| Runtime | Selected mode | Ordered strategies | Fallback behavior | +| --- | --- | --- | --- | +| app | auto | `oauth -> cli -> web` | OAuth can fall through to CLI/Web. CLI can fall through to Web only when Web is available. Web is terminal. | +| app | oauth | `oauth` | No fallback. | +| app | cli | `cli` | No fallback. | +| app | web | `web` | No fallback. | +| cli | auto | `web -> cli` | Web can fall through to CLI. CLI is terminal. | +| cli | oauth | `oauth` | No fallback. | +| cli | cli | `cli` | No fallback. | +| cli | web | `web` | No fallback. | + +This behavior is owned by `Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift` +through `ProviderFetchPlan` and `ProviderFetchPipeline`. + +### Other active `.auto` decision sites + +The codebase still contains multiple active `.auto` decision sites: + +| Owner | Current behavior | +| --- | --- | +| `ClaudeProviderDescriptor.resolveUsageStrategy(...)` | Chooses `oauth`, then `cli`, then `web`, with final `cli` fallback when none are available. | +| `ClaudeUsageFetcher.loadLatestUsage(.auto)` | Chooses `oauth`, then `web`, then `cli`, with final `oauth` fallback. | + +This inconsistency is intentional to record here. RAT-107 directly characterizes the active direct-fetcher branches it +can reach cleanly in tests and records the remaining current-state behavior without reconciling it. + +## Prompt and cooldown baseline + +Current behavior that later refactor work must preserve: + +- The default Claude keychain prompt mode is `onlyOnUserAction`. +- Prompt policy is only applicable when the Claude OAuth read strategy is `securityFramework`. +- User-initiated interaction clears a prior Claude keychain cooldown denial before retrying availability or repair. +- Startup bootstrap prompting is allowed only when all of these are true: + - runtime is app, + - interaction is background, + - refresh phase is startup, + - prompt mode is `onlyOnUserAction`, + - no cached Claude credentials exist. +- Background delegated refresh is blocked when prompt policy is `onlyOnUserAction` and the caller did not explicitly + allow background delegated refresh. +- Prompt mode `never` blocks delegated refresh attempts. +- Expired credential owner behavior remains owner-specific: + - `.claudeCLI`: delegated refresh path, + - `.codexbar`: direct refresh path, + - `.environment`: no auto-refresh. + +## Token-account routing baseline + +Accepted Claude token-account input shapes today: + +- raw OAuth access token with `sk-ant-oat...` prefix, +- `Bearer sk-ant-oat...` input, +- raw session key, +- full cookie header. + +Current routing rules: + +- OAuth-token-shaped inputs are not treated as cookies. +- Cookie/header-shaped inputs are any value that already contains `Cookie:` or `=`. +- App-side Claude snapshot behavior: + - OAuth token account keeps the usage source setting as-is, disables cookie mode (`.off`), clears the manual cookie + header, and relies on environment-token injection. + - Session-key or cookie-header account keeps the usage source setting as-is, forces manual cookie mode, and + normalizes raw session keys into `sessionKey=`. +- CLI-side Claude token-account behavior: + - OAuth token account changes the effective source mode from `auto` to `oauth`, disables cookie mode, omits a + manual cookie header, and injects `CODEXBAR_CLAUDE_OAUTH_TOKEN`. + - Session-key or cookie-header account stays in cookie/manual mode. + +## Siloing and web-enrichment baseline + +Claude Web enrichment is cost-only when the primary source is OAuth or CLI: + +- Web extras may populate `providerCost` when it is missing. +- Web extras must not replace `accountEmail`, `accountOrganization`, or `loginMethod` from the primary source. +- Snapshot identity remains provider-scoped to Claude. + +This behavior is implemented in `Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift` +inside `applyWebExtrasIfNeeded`. + +## Documentation contract + +- [docs/claude.md](../claude.md) is the summary doc for contributors who want an overview. +- This file is the exact current-state baseline for contributor and refactor parity work. +- [claude-provider-vnext-locked.md](claude-provider-vnext-locked.md) + is the future refactor plan and should cite this file for present behavior. + +## Characterization coverage + +Stable automated coverage for this baseline lives in: + +- `Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift` +- `Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift` +- `Tests/CodexBarTests/ClaudeUsageTests.swift` +- `Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift` +- `Tests/CodexBarTests/SettingsStoreCoverageTests.swift` + +`ClaudeUsageTests.swift` now directly characterizes the reachable `ClaudeUsageFetcher(.auto)` branches for: + +- OAuth when OAuth, Web, and CLI all appear available, +- Web before CLI when OAuth is unavailable, + +The successful CLI-selected branch and the CLI-failure-to-OAuth fallback remain documented from code inspection plus +surrounding Claude probe/regression coverage, because the current CLI-availability decision is sourced from process-wide +binary discovery with no stable test seam that would keep RAT-107 in scope. diff --git a/docs/refactor/claude-provider-vnext-locked.md b/docs/refactor/claude-provider-vnext-locked.md new file mode 100644 index 0000000000..4331762248 --- /dev/null +++ b/docs/refactor/claude-provider-vnext-locked.md @@ -0,0 +1,355 @@ +--- +summary: "Locked implementation plan for Claude provider vNext: resolved source-selection contracts, typed credential rules, siloing guarantees, and phase gates." +supersedes: "Initial vNext draft (removed)" +created: "2026-02-18" +status: "Locked for implementation" +--- + +# Claude provider vNext (locked plan) + +This is the implementation-locked vNext plan. + +It preserves the original architecture direction, but removes ambiguity in behavior-critical areas before refactor work starts. + +Current-state parity reference for present behavior: + +- [docs/refactor/claude-current-baseline.md](claude-current-baseline.md) + +Use the baseline doc for present behavior. This vNext plan defines what the refactor should preserve and how it should +be staged; it is not the sole source of truth for current implementation details, and RAT-107 does not re-approve the +rest of the future architecture below. + +## Assessment snapshot + +- **Approach score:** `8.4/10`. +- **Why not 9+ yet:** the original plan left runtime ordering, token-account credential typing behavior, and compatibility mapping under-specified. +- **How this doc closes the gap:** explicit contracts + resolved decisions + phase exit gates + risk checklist. +- **Validated gap coverage in this version:** explicit `.auto` inconsistency handling, `ClaudeUsageFetcher` decomposition, stronger parity gates, TaskLocal-to-DI migration, and OAuth decomposition sub-phases. + +## Locked behavioral contracts + +These behaviors are **non-negotiable** during refactor unless this doc is explicitly updated. + +### 1) Runtime + source-mode contract + +`ClaudeSourcePlanner` must reproduce this matrix exactly: + +| Runtime | Selected mode | Ordered attempts | Fallback rules | +| --- | --- | --- | --- | +| app | auto | oauth -> cli -> web | oauth fallback allowed; cli fallback to web only when web available; web terminal | +| app | oauth | oauth | no fallback | +| app | cli | cli | no fallback | +| app | web | web | no fallback | +| cli | auto | web -> cli | web fallback allowed to cli; cli terminal | +| cli | oauth | oauth | no fallback | +| cli | cli | cli | no fallback | +| cli | web | web | no fallback | + +Notes: +- `sourceLabel` remains the final step label for successful fetch output. +- Planner diagnostics must include ordered steps and inclusion reasons. +- Planner output must feed the existing generic provider fetch pipeline; do not introduce a second Claude-only + execution stack alongside `ProviderFetchPlan` / `ProviderFetchPipeline`. + +### 1a) `.auto` inconsistency characterization contract (must-do before reconciliation) + +Current code has three `.auto` decision sites with inconsistent app ordering: + +- Strategy pipeline resolve order (app): `oauth -> cli -> web`. +- `resolveUsageStrategy` helper order: `oauth -> cli -> web -> cli fallback`. +- `ClaudeUsageFetcher.loadLatestUsage(.auto)` order: `oauth -> web -> cli -> oauth fallback`. + +Phase 0 must characterize these paths with tests where they are reachable through stable seams, and otherwise defer to +the baseline doc before deleting any path. +Phase 2 must reconcile this into planner-only source selection. + +### 2) Prompt/cooldown contract + +The planner must use one explicit `ClaudePromptDecision` equivalent, but outcome parity with current behavior is required: + +- User-initiated actions can clear prior keychain cooldown denial. +- Startup bootstrap prompt is only allowed when all are true: + - runtime is app + - interaction is background + - refresh phase is startup + - prompt mode is `onlyOnUserAction` + - no cached credentials +- Background delegated refresh is blocked when: + - prompt policy is `onlyOnUserAction` + - caller does not explicitly allow background delegated refresh +- Prompt mode `never` blocks delegated refresh attempts. + +### 3) Credential typing + routing contract + +Typed credentials must be introduced at the settings snapshot edge, with behavior parity: + +- `ClaudeManualCredential.sessionKey` +- `ClaudeManualCredential.cookieHeader` +- `ClaudeManualCredential.oauthAccessToken` + +Accepted Claude token-account inputs must continue to work: + +- Raw OAuth token (including `Bearer ...` input) +- Raw session key +- Full cookie header + +Routing parity requirements: + +- OAuth token account values must route to OAuth path (not cookie mode). +- Cookie/session-key account values must route to web cookie path. +- CLI token-account behavior must remain consistent in both app and `CodexBarCLI`. +- Scope note: current string heuristics are mostly edge-routing logic, not deep OAuth credential decoding internals. + +### 4) Ownership and refresh contract + +Credential owner behavior must remain identical: + +- `.claudeCLI` expired credentials: delegated refresh path. +- `.codexbar` expired credentials: direct refresh endpoint path. +- `.environment` expired credentials: no auto-refresh. + +Refresh failure-gate semantics must remain unchanged. + +### 5) Provider siloing + enrichment contract + +Hard invariant: + +- Never merge Claude Web identity into OAuth/CLI snapshots. +- Web extras may enrich **cost only**. +- Snapshot identity must always remain provider-scoped to `.claude` when persisted/displayed. + +### 6) Plan inference compatibility contract + +Canonical plan model can be introduced, but compatibility must be preserved: + +- Existing detectable plans continue mapping to display strings: + - `Claude Max` + - `Claude Pro` + - `Claude Team` + - `Claude Enterprise` +- Subscription detection behavior must remain compatible with current UI logic, including existing `Ultra` detection + semantics until that behavior is explicitly changed. + +### 7) Documentation + diagnostics contract + +- During refactor, characterization coverage plus + [docs/refactor/claude-current-baseline.md](claude-current-baseline.md) + are the source of truth when docs and code disagree. +- `docs/claude.md` must be updated as part of Phase 0 after characterization lands so it no longer presents divergent + runtime ordering as settled behavior. +- Debug surfaces must consume planner-derived diagnostics instead of recomputing Claude source decisions separately. + +## Resolved decisions (from open questions) + +### Web identity fill-ins + +- **Decision:** do not use Web identity to fill missing OAuth/CLI identity fields. +- **Allowed:** Web cost enrichment only. + +### CLI runtime fallback ordering + +- **Decision:** keep current CLI ordering in `auto`: `web -> cli`. +- Planner must encode this explicitly and not rely on incidental strategy ordering. + +### Startup bootstrap prompt in `onlyOnUserAction` + +- **Decision:** keep support exactly under the startup bootstrap constraints listed above. +- Any expansion/restriction requires explicit doc update and tests. + +### Runtime policy unification timing + +- **Decision:** do not unify app and CLI `auto` ordering before this refactor. +- First consolidate to one planner implementation with current runtime-specific behavior preserved. +- Any runtime-policy unification is a separate, explicit behavior-change follow-up. + +### Planner integration timing + +- **Decision:** `ClaudeSourcePlanner` must be integrated into the existing provider descriptor / fetch pipeline rather + than added as a parallel orchestration layer. +- Reuse current `ProviderFetchContext` / `ProviderFetchPlan` plumbing where possible. + +### Dependency seam timing + +- **Decision:** introduce dependency-container seams for newly extracted planner/executor components as they are + created. +- Full TaskLocal cleanup can remain later, but new components should not deepen TaskLocal coupling. + +## Locked migration plan with exit gates + +### Phase 0: Baseline lock + +Deliverables: +- Add `docs/refactor/claude-current-baseline.md` as the current-state behavior reference. +- Add/refresh characterization tests for runtime/source matrix and prompt-decision parity. +- Add explicit characterization tests for existing `.auto` decision paths where they are reachable through stable seams, + and defer remaining current-state details to the baseline doc until later reconciliation. +- Update `docs/claude.md` after tests land so documented ordering matches characterized behavior. + +Exit gate: +- Behavior matrix tests pass for app and cli runtimes. +- `.auto` characterization coverage plus the baseline doc record current divergence explicitly without forcing new + production seams in Phase 0. +- `docs/claude.md` no longer contradicts characterized runtime/source behavior. + +### Phase 1: Canonical plan resolver + +Deliverables: +- Introduce `ClaudePlan` + one resolver used by OAuth/Web/CLI mapping and downstream UI consumers. + +Exit gate: +- Plan mapping tests cover tier/billing/status-derived hints, compatibility display strings, and current UI subscription + detection compatibility. + +### Phase 1b: Typed credentials at the snapshot edge + +Deliverables: +- Parse manual Claude credentials once at the app + CLI snapshot edges into a typed model. +- Remove duplicated edge-routing heuristics for OAuth-vs-cookie decisions across settings snapshots and token-account + CLI code. + +Exit gate: +- Token account parity tests pass for app + CLI. +- Snapshot-edge routing no longer duplicates Claude OAuth-token detection logic in multiple call sites. + +### Phase 2: Single source planner + +Deliverables: +- Introduce `ClaudeSourcePlanner` + explicit `ClaudeFetchPlan`. +- Integrate planner outputs into the existing provider pipeline / descriptor flow. +- Remove duplicate `.auto` policy branches from lower layers. +- Reconcile and remove `ClaudeUsageFetcher` internal `.auto` source selection. +- Move debug/diagnostic surfaces to planner-derived attempt ordering instead of helper-specific recomputation. + +Exit gate: +- One authoritative planner path for mode/runtime ordering. +- Fallback attempt logs still show expected sequence and source labels. +- No surviving `.auto` source-order logic outside planner. +- No surviving debug-only source-order recomputation outside planner diagnostics. +- Old-vs-new planner parity tests pass before old branches are removed. + +### Phase 2b: `ClaudeUsageFetcher` decomposition + +Deliverables: +- Split `ClaudeUsageFetcher` into smaller executor-focused components. +- Extract delegated OAuth retry/recovery flow into dedicated units. +- Remove embedded prompt-policy/source-selection ownership from fetcher; keep it execution-only. + +Exit gate: +- Fetcher no longer owns source-selection policy. +- Delegated OAuth retry behavior is covered by dedicated tests and remains parity-compatible. + +### Phase 3: Test injection cleanup + +Deliverables: +- Prefer dependency seams on extracted units instead of adding new TaskLocal-only override points. + - Avoid expanding TaskLocal-only test hooks while decomposition work lands. + +Exit gate: +- New fetcher tests rely on explicit dependency seams where practical. + +### Phase 4: OAuth decomposition + +Deliverables (sub-phases): + +- **Phase 4a (repository extraction):** + - Extract IO + caching + owner/source loading into repository surface. + - Keep prompt-gate semantics unchanged. +- **Phase 4b (refresher extraction):** + - Extract network refresh + failure gating to refresher component. + - Keep owner-based refresh behavior unchanged. +- **Phase 4c (delegated controller extraction):** + - Extract delegated CLI touch + keychain-change observation + cooldown behavior. + - Keep delegated retry outcomes unchanged. + +Exit gate: +- Existing OAuth delegated refresh / prompt policy / cooldown suites pass without behavior deltas at each sub-phase. +- Owner semantics parity remains intact across all sub-phases (`claudeCLI`, `codexbar`, `environment`). + +### Phase 5: Test injection migration (TaskLocal -> DI) + +Deliverables: +- Move test injection from TaskLocal-heavy overrides to `ClaudeFetchDependencies` and explicit protocol stubs. +- Keep compatibility shims temporarily where needed, then remove them. + +Exit gate: +- Core planner/executor tests run without TaskLocal injection dependencies. +- Legacy TaskLocal-only override surfaces are either removed or isolated to compatibility adapters with deletion TODOs. + +### Phase 6: Web decomposition (optional) + +Deliverables: +- Separate cookie acquisition from web usage client. +- Keep probe tooling isolated behind debug/tool surface. + +Exit gate: +- Web parsing and account mapping tests remain green. + +## Implementation PR plan (stacked) + +Use this sequence to keep each PR reviewable without turning the rollout into unnecessary PR overhead. + +| PR | Title | Scope | Primary risks | Must-pass gate before merge | +| --- | --- | --- | --- | --- | +| PR-01 | Baseline characterization + doc correction | Lock current matrix behavior, characterize `.auto` paths through stable seams, defer remaining lower-level current-state details to the baseline doc, characterize prompt bootstrap/cooldown and token-account routing, then update docs to match reality. | R1, R2, R5, R6, R10 | No production behavior changes; characterization suites green; docs no longer contradict tests or the baseline. | +| PR-02 | Canonical plan resolver | Introduce `ClaudePlan` and central resolver; map OAuth/Web/CLI/UI to one model. | R8 | Plan compatibility tests green (`Max/Pro/Team/Enterprise` + current subscription compatibility). | +| PR-03 | Typed credentials at the edge | Parse manual credentials once (`sessionKey`, `cookieHeader`, `oauthAccessToken`) in app + CLI snapshot shaping. | R6 | Token-account routing parity tests green in app + CLI contexts. | +| PR-04 | Source planner introduction + cutover | Add `ClaudeSourcePlanner`, prove parity against old path, then remove duplicate `.auto` selection branches once parity is proven. | R1, R5, R10 | One `.auto` authority remains; attempt/source-label diagnostics remain parity-compatible. | +| PR-05 | `ClaudeUsageFetcher` decomposition | Split fetcher into execution/retry-focused units; remove embedded source-selection ownership. | R2, R10 | Delegated OAuth retry/recovery tests green with no behavior deltas. | +| PR-06 | OAuth decomposition | Extract repository, refresher, and delegated-controller seams from `ClaudeOAuthCredentialsStore` while preserving owner semantics. | R3, R4, R7, R9 | Cache/fingerprint/prompt/owner suites green (`claudeCLI`, `codexbar`, `environment`). | +| PR-07 (optional) | TaskLocal -> DI migration | Move remaining tests and seams to `ClaudeFetchDependencies`, keep temporary compat adapters, then remove. | R9 | Core planner/executor tests run without TaskLocal globals. | +| PR-08 (optional) | Web decomposition | Split cookie acquisition from web usage client and keep tooling isolated. | R8, R10 | Web parsing/account mapping suites remain green. | + +Stacking rules: + +1. Keep each PR scoped to one risk cluster and one merge gate. +2. Do not remove old branches until a prior PR has old-vs-new parity tests in CI. +3. If a PR intentionally changes behavior, update this locked doc in the same PR and call it out in summary. +4. Prefer 6 core PRs unless parity risk forces a temporary split; do not fragment the rollout further without a + concrete rollback or reviewability reason. + +## Mandatory test additions + +Add these test groups before or during Phases 1-3, then extend for later phases: + +1. Planner matrix tests: + - `(runtime x selected mode x interaction x refresh phase x availability)` -> exact step order + fallback. +2. `.auto` divergence characterization tests: + - Lock current behavior of strategy pipeline vs `resolveUsageStrategy` helper vs fetcher-direct `.auto`. + - Use as guardrails while consolidating to planner-only logic. +3. Typed credential parsing tests: + - OAuth token, bearer token, session key, cookie header, malformed strings. +4. Cross-provider identity isolation tests: + - Ensure `.claude` identity does not leak via snapshot scoping/merging. +5. Source-label and attempt diagnostics tests: + - Validate final source label and attempt list parity. +6. CLI token-account parity tests: + - `TokenAccountCLIContext` and app settings snapshot behavior match for OAuth-vs-cookie routing. +7. Old-vs-new parity tests: + - Compare old path and planner path outputs before branch removals in Phase 2 and Phase 2b. +8. DI migration tests: + - Ensure new dependency container can drive planner/executor tests without TaskLocal globals. + +## Risk checklist (implementation review) + +Use these risk IDs in refactor PR checklists/reviews. + +| Risk ID | Severity | Risk | Detail | +| --- | --- | --- | --- | +| R1 | Critical | Auto-ordering reconciliation | Three `.auto` paths are inconsistent today. Characterize strategy pipeline vs `resolveUsageStrategy` helper vs fetcher-direct `.auto` before deleting any path. | +| R2 | High | Prompt policy consolidation | Prompt policy exists across strategy availability, fetcher flow, and credentials store gates. Preserve startup bootstrap constraints exactly to avoid prompt storms or silent OAuth suppression. | +| R3 | High | `ClaudeOAuthCredentialsStore` decomposition | Large lock-protected state + layered caches + fingerprint invalidation + security calls. Splits can break cache coherence, invalidation timing, or prompt gating order. | +| R4 | High | Owner semantics drift | Preserve exact owner-to-refresh mapping: `.claudeCLI` delegated, `.codexbar` direct refresh, `.environment` no refresh. | +| R5 | Medium | CLI runtime parity | Preserve runtime-specific policy: CLI `auto` remains `web -> cli`; OAuth is available only when explicitly selected as `sourceMode=.oauth`. Do not accidentally default CLI runtime to app ordering. | +| R6 | Medium | Token-account OAuth-vs-cookie misrouting | Keep routing parity for OAuth token vs session key vs full cookie header, including `Bearer sk-ant-oat...` normalization. | +| R7 | Medium | Cache invalidation regressions | Preserve credentials file/keychain fingerprint semantics and stale-cache guards during repository extraction. | +| R8 | Low-Medium | Plan inference heuristic drift | Preserve web-specific plan inference fallback (`billing_type` + `rate_limit_tier`) when unifying plan resolution. | +| R9 | Medium | Strict concurrency / `@Sendable` regressions | Maintain thread-safe behavior from current NSLock-based state while moving to DI/decomposed components under Swift 6 strict concurrency. | +| R10 | Low | Debug/diagnostic drift | Keep source labels, attempt sequences, and debug output aligned with real planner decisions after consolidation. | + +## Change-control rule + +Any refactor PR that intentionally changes one of the locked contracts above must: + +1. Update this document. +2. Add/adjust tests proving the new behavior. +3. Call out the behavior change explicitly in the PR summary. From 4beddde4b7e02f8a869750f808c3708ae61d3049 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 7 Mar 2026 16:54:16 +0530 Subject: [PATCH 0002/1239] Canonicalize Claude plan inference --- Sources/CodexBar/UsageStore.swift | 6 +- .../Providers/Claude/ClaudePlan.swift | 143 ++++++++++++++++++ .../Providers/Claude/ClaudeStatusProbe.swift | 4 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 11 +- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 13 +- .../ClaudePlanResolverTests.swift | 56 +++++++ .../SubscriptionDetectionTests.swift | 6 + docs/refactor/claude-provider-vnext-locked.md | 5 +- 8 files changed, 213 insertions(+), 31 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift create mode 100644 Tests/CodexBarTests/ClaudePlanResolverTests.swift diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5876fc3516..27581291c7 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -268,11 +268,7 @@ final class UsageStore { /// 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 { - guard let method = loginMethod?.lowercased(), !method.isEmpty else { - return false - } - let subscriptionIndicators = ["max", "pro", "ultra", "team"] - return subscriptionIndicators.contains { method.contains($0) } + ClaudePlan.isSubscriptionLoginMethod(loginMethod) } func version(for provider: UsageProvider) -> String? { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift new file mode 100644 index 0000000000..e85de6699a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift @@ -0,0 +1,143 @@ +import Foundation + +public enum ClaudePlan: String, CaseIterable, Sendable { + case max + case pro + case team + case enterprise + case ultra + + public var brandedLoginMethod: String { + switch self { + case .max: + "Claude Max" + case .pro: + "Claude Pro" + case .team: + "Claude Team" + case .enterprise: + "Claude Enterprise" + case .ultra: + "Claude Ultra" + } + } + + public var compactLoginMethod: String { + switch self { + case .max: + "Max" + case .pro: + "Pro" + case .team: + "Team" + case .enterprise: + "Enterprise" + case .ultra: + "Ultra" + } + } + + public var countsAsSubscription: Bool { + switch self { + case .max, .pro, .team, .ultra: + true + case .enterprise: + false + } + } + + public static func fromOAuthRateLimitTier(_ rateLimitTier: String?) -> Self? { + self.fromRateLimitTier(rateLimitTier) + } + + public static func fromWebAccount(rateLimitTier: String?, billingType: String?) -> Self? { + if let plan = self.fromRateLimitTier(rateLimitTier) { + return plan + } + + let tier = Self.normalized(rateLimitTier) + let billing = Self.normalized(billingType) + if billing.contains("stripe"), tier.contains("claude") { + return .pro + } + return nil + } + + public static func fromCompatibilityLoginMethod(_ loginMethod: String?) -> Self? { + let words = Self.normalizedWords(loginMethod) + if words.isEmpty { + return nil + } + if words.contains("max") { + return .max + } + if words.contains("pro") { + return .pro + } + if words.contains("team") { + return .team + } + if words.contains("enterprise") { + return .enterprise + } + if words.contains("ultra") { + return .ultra + } + return nil + } + + public static func oauthLoginMethod(rateLimitTier: String?) -> String? { + self.fromOAuthRateLimitTier(rateLimitTier)?.brandedLoginMethod + } + + public static func webLoginMethod(rateLimitTier: String?, billingType: String?) -> String? { + self.fromWebAccount(rateLimitTier: rateLimitTier, billingType: billingType)?.brandedLoginMethod + } + + public static func cliCompatibilityLoginMethod(_ loginMethod: String?) -> String? { + guard let loginMethod = loginMethod?.trimmingCharacters(in: .whitespacesAndNewlines), + !loginMethod.isEmpty + else { + return nil + } + + if let plan = self.fromCompatibilityLoginMethod(loginMethod) { + return plan.compactLoginMethod + } + + return loginMethod + } + + public static func isSubscriptionLoginMethod(_ loginMethod: String?) -> Bool { + self.fromCompatibilityLoginMethod(loginMethod)?.countsAsSubscription ?? false + } + + private static func fromRateLimitTier(_ rateLimitTier: String?) -> Self? { + let tier = Self.normalized(rateLimitTier) + if tier.contains("max") { + return .max + } + if tier.contains("pro") { + return .pro + } + if tier.contains("team") { + return .team + } + if tier.contains("enterprise") { + return .enterprise + } + return nil + } + + private static func normalized(_ text: String?) -> String { + text? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + } + + private static func normalizedWords(_ text: String?) -> [String] { + self.normalized(text) + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + .map(String.init) + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index f5e768624e..590146bd7d 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -610,7 +610,7 @@ public struct ClaudeStatusProbe: Sendable { private static func extractLoginMethod(text: String) -> String? { guard !text.isEmpty else { return nil } if let explicit = self.extractFirst(pattern: #"(?i)login\s+method:\s*(.+)"#, text: text) { - return self.cleanPlan(explicit) + return ClaudePlan.cliCompatibilityLoginMethod(self.cleanPlan(explicit)) } // Capture any "Claude <...>" phrase (e.g., Max/Pro/Ultra/Team) to avoid future plan-name churn. // Strip any leading ANSI that may have survived (rare) before matching. @@ -623,7 +623,7 @@ public struct ClaudeStatusProbe: Sendable { match.numberOfRanges >= 2, let r = Range(match.range(at: 1), in: text) else { return } let raw = String(text[r]) - let val = Self.cleanPlan(raw) + let val = ClaudePlan.cliCompatibilityLoginMethod(Self.cleanPlan(raw)) ?? Self.cleanPlan(raw) candidates.append(val) } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 989db8e060..4e7209c010 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -660,7 +660,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { usage.sevenDaySonnet ?? usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) - let loginMethod = Self.inferPlan(rateLimitTier: credentials.rateLimitTier) + let loginMethod = ClaudePlan.oauthLoginMethod(rateLimitTier: credentials.rateLimitTier) let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod) return ClaudeUsageSnapshot( @@ -704,15 +704,6 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { (used: used / 100.0, limit: limit / 100.0) } - private static func inferPlan(rateLimitTier: String?) -> String? { - let tier = rateLimitTier?.lowercased() ?? "" - if tier.contains("max") { return "Claude Max" } - if tier.contains("pro") { return "Claude Pro" } - if tier.contains("team") { return "Claude Team" } - if tier.contains("enterprise") { return "Claude Enterprise" } - return nil - } - // MARK: - Web API path (uses browser cookies) private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index e004b47c12..09c3cf1ce9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -691,7 +691,7 @@ public enum ClaudeWebAPIFetcher { guard let response = try? JSONDecoder().decode(AccountResponse.self, from: data) else { return nil } let email = response.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines) let membership = Self.selectMembership(response.memberships, orgId: orgId) - let plan = Self.inferPlan( + let plan = ClaudePlan.webLoginMethod( rateLimitTier: membership?.organization.rateLimitTier, billingType: membership?.organization.billingType) return WebAccountInfo(email: email, loginMethod: plan) @@ -708,17 +708,6 @@ public enum ClaudeWebAPIFetcher { return memberships.first } - private static func inferPlan(rateLimitTier: String?, billingType: String?) -> String? { - let tier = rateLimitTier?.lowercased() ?? "" - let billing = billingType?.lowercased() ?? "" - if tier.contains("max") { return "Claude Max" } - if tier.contains("pro") { return "Claude Pro" } - if tier.contains("team") { return "Claude Team" } - if tier.contains("enterprise") { return "Claude Enterprise" } - if billing.contains("stripe"), tier.contains("claude") { return "Claude Pro" } - return nil - } - private struct ProbeParseResult: Sendable { let keys: [String] let emails: [String] diff --git a/Tests/CodexBarTests/ClaudePlanResolverTests.swift b/Tests/CodexBarTests/ClaudePlanResolverTests.swift new file mode 100644 index 0000000000..cbeabf93ac --- /dev/null +++ b/Tests/CodexBarTests/ClaudePlanResolverTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct ClaudePlanResolverTests { + @Test + func oauthRateLimitTierMapsToBrandedPlan() { + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_max_20x") == "Claude Max") + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_pro") == "Claude Pro") + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_team") == "Claude Team") + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_enterprise") == "Claude Enterprise") + } + + @Test + func webFallbackPreservesStripeClaudeCompatibility() { + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "default_claude", + billingType: "stripe_subscription") + == "Claude Pro") + } + + @Test + func compatibilityParserUnderstandsCurrentLabels() { + #expect(ClaudePlan.fromCompatibilityLoginMethod("Claude Max") == .max) + #expect(ClaudePlan.fromCompatibilityLoginMethod("Max") == .max) + #expect(ClaudePlan.fromCompatibilityLoginMethod("Claude Pro") == .pro) + #expect(ClaudePlan.fromCompatibilityLoginMethod("Ultra") == .ultra) + #expect(ClaudePlan.fromCompatibilityLoginMethod("Claude Team") == .team) + #expect(ClaudePlan.fromCompatibilityLoginMethod("Claude Enterprise") == .enterprise) + } + + @Test + func cliProjectionKeepsCompactCompatibilityAndUnknownFallback() { + #expect(ClaudePlan.cliCompatibilityLoginMethod("Claude Max Account") == "Max") + #expect(ClaudePlan.cliCompatibilityLoginMethod("Team") == "Team") + #expect(ClaudePlan.cliCompatibilityLoginMethod("Claude Enterprise Account") == "Enterprise") + #expect(ClaudePlan.cliCompatibilityLoginMethod("Claude Ultra Account") == "Ultra") + #expect(ClaudePlan.cliCompatibilityLoginMethod("Experimental") == "Experimental") + #expect(ClaudePlan.cliCompatibilityLoginMethod("Profile") == "Profile") + #expect(ClaudePlan.cliCompatibilityLoginMethod("Browser profile") == "Browser profile") + } + + @Test + func subscriptionCompatibilityPreservesUltraAndExcludesEnterprise() { + #expect(ClaudePlan.isSubscriptionLoginMethod("Claude Max")) + #expect(ClaudePlan.isSubscriptionLoginMethod("Pro")) + #expect(ClaudePlan.isSubscriptionLoginMethod("Ultra")) + #expect(ClaudePlan.isSubscriptionLoginMethod("Team")) + #expect(!ClaudePlan.isSubscriptionLoginMethod("Claude Enterprise")) + #expect(!ClaudePlan.isSubscriptionLoginMethod("Profile")) + #expect(!ClaudePlan.isSubscriptionLoginMethod("Browser profile")) + #expect(!ClaudePlan.isSubscriptionLoginMethod("API")) + } +} diff --git a/Tests/CodexBarTests/SubscriptionDetectionTests.swift b/Tests/CodexBarTests/SubscriptionDetectionTests.swift index f08821b8f8..0a4973c61e 100644 --- a/Tests/CodexBarTests/SubscriptionDetectionTests.swift +++ b/Tests/CodexBarTests/SubscriptionDetectionTests.swift @@ -35,6 +35,12 @@ struct SubscriptionDetectionTests { #expect(UsageStore.isSubscriptionPlan("team") == true) } + @Test + func enterprisePlanDoesNotCountAsSubscription() { + #expect(UsageStore.isSubscriptionPlan("Claude Enterprise") == false) + #expect(UsageStore.isSubscriptionPlan("Enterprise") == false) + } + // MARK: - Non-subscription plans should return false @Test diff --git a/docs/refactor/claude-provider-vnext-locked.md b/docs/refactor/claude-provider-vnext-locked.md index 4331762248..8576cf8ba6 100644 --- a/docs/refactor/claude-provider-vnext-locked.md +++ b/docs/refactor/claude-provider-vnext-locked.md @@ -120,7 +120,8 @@ Hard invariant: ### 6) Plan inference compatibility contract -Canonical plan model can be introduced, but compatibility must be preserved: +Canonical plan inference can live behind the existing `loginMethod` compatibility surface, but outward +compatibility must be preserved: - Existing detectable plans continue mapping to display strings: - `Claude Max` @@ -291,7 +292,7 @@ Use this sequence to keep each PR reviewable without turning the rollout into un | PR | Title | Scope | Primary risks | Must-pass gate before merge | | --- | --- | --- | --- | --- | | PR-01 | Baseline characterization + doc correction | Lock current matrix behavior, characterize `.auto` paths through stable seams, defer remaining lower-level current-state details to the baseline doc, characterize prompt bootstrap/cooldown and token-account routing, then update docs to match reality. | R1, R2, R5, R6, R10 | No production behavior changes; characterization suites green; docs no longer contradict tests or the baseline. | -| PR-02 | Canonical plan resolver | Introduce `ClaudePlan` and central resolver; map OAuth/Web/CLI/UI to one model. | R8 | Plan compatibility tests green (`Max/Pro/Team/Enterprise` + current subscription compatibility). | +| PR-02 | Canonical plan resolver | Introduce `ClaudePlan` and central resolver; map OAuth/Web/CLI/UI compatibility through one model while preserving current `loginMethod` projections. | R8 | Plan compatibility tests green (`Max/Pro/Team/Enterprise` + current subscription compatibility). | | PR-03 | Typed credentials at the edge | Parse manual credentials once (`sessionKey`, `cookieHeader`, `oauthAccessToken`) in app + CLI snapshot shaping. | R6 | Token-account routing parity tests green in app + CLI contexts. | | PR-04 | Source planner introduction + cutover | Add `ClaudeSourcePlanner`, prove parity against old path, then remove duplicate `.auto` selection branches once parity is proven. | R1, R5, R10 | One `.auto` authority remains; attempt/source-label diagnostics remain parity-compatible. | | PR-05 | `ClaudeUsageFetcher` decomposition | Split fetcher into execution/retry-focused units; remove embedded source-selection ownership. | R2, R10 | Delegated OAuth retry/recovery tests green with no behavior deltas. | From adb28f31d2bd32c5b3c96f78f4f2b0a4e479064d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 7 Mar 2026 18:08:35 +0530 Subject: [PATCH 0003/1239] Normalize Claude credential routing --- .../Claude/ClaudeSettingsStore.swift | 64 ++++++++------- Sources/CodexBarCLI/TokenAccountCLI.swift | 63 +++++++++------ .../Claude/ClaudeCredentialRouting.swift | 78 +++++++++++++++++++ .../CodexBarCore/TokenAccountSupport.swift | 25 ++---- .../ClaudeCredentialRoutingTests.swift | 59 ++++++++++++++ .../SettingsStoreCoverageTests.swift | 26 +++++++ ...kenAccountEnvironmentPrecedenceTests.swift | 61 +++++++++++++++ 7 files changed, 305 insertions(+), 71 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeCredentialRouting.swift create mode 100644 Tests/CodexBarTests/ClaudeCredentialRoutingTests.swift diff --git a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift index f28a473740..3bc55eab42 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift @@ -50,11 +50,15 @@ extension SettingsStore { extension SettingsStore { func claudeSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .ClaudeProviderSettings { - ProviderSettingsSnapshot.ClaudeProviderSettings( + let account = self.selectedClaudeTokenAccount(tokenOverride: tokenOverride) + let routing = self.claudeCredentialRouting(account: account) + return ProviderSettingsSnapshot.ClaudeProviderSettings( usageDataSource: self.claudeUsageDataSource, webExtrasEnabled: self.claudeWebExtrasEnabled, - cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.claudeSnapshotCookieHeader(tokenOverride: tokenOverride)) + cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride, routing: routing), + manualCookieHeader: self.claudeSnapshotCookieHeader( + routing: routing, + hasSelectedAccount: account != nil)) } private static func claudeUsageDataSource(from source: ProviderSourceMode?) -> ClaudeUsageDataSource { @@ -71,42 +75,48 @@ extension SettingsStore { } } - private func claudeSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.claudeCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .claude), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( - provider: .claude, - settings: self, - override: tokenOverride) - else { - return fallback - } - if TokenAccountSupportCatalog.isClaudeOAuthToken(account.token) { - return "" + private func claudeSnapshotCookieHeader( + routing: ClaudeCredentialRouting, + hasSelectedAccount: Bool) -> String + { + switch routing { + case .none: + hasSelectedAccount ? "" : self.claudeCookieHeader + case .oauth: + "" + case let .webCookie(header): + header } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } - private func claudeSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + private func claudeSnapshotCookieSource( + tokenOverride: TokenAccountOverride?, + routing: ClaudeCredentialRouting) -> ProviderCookieSource + { let fallback = self.claudeCookieSource guard let support = TokenAccountSupportCatalog.support(for: .claude), support.requiresManualCookieSource else { return fallback } - if let account = ProviderTokenAccountSelection.selectedAccount( - provider: .claude, - settings: self, - override: tokenOverride), - TokenAccountSupportCatalog.isClaudeOAuthToken(account.token) - { + if routing.isOAuth { return .off } if self.tokenAccounts(for: .claude).isEmpty { return fallback } return .manual } + + private func claudeCredentialRouting(account: ProviderTokenAccount?) -> ClaudeCredentialRouting { + let manualCookieHeader = account == nil ? self.claudeCookieHeader : nil + return ClaudeCredentialRouting.resolve( + tokenAccountToken: account?.token, + manualCookieHeader: manualCookieHeader) + } + + private func selectedClaudeTokenAccount(tokenOverride: TokenAccountOverride?) -> ProviderTokenAccount? { + ProviderTokenAccountSelection.selectedAccount( + provider: .claude, + settings: self, + override: tokenOverride) + } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 7324fa8374..e5847410ae 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -77,70 +77,82 @@ struct TokenAccountCLIContext { func settingsSnapshot(for provider: UsageProvider, account: ProviderTokenAccount?) -> ProviderSettingsSnapshot? { let config = self.providerConfig(for: provider) - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) switch provider { case .codex: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( codex: ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: .auto, cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .claude: - let claudeSource: ClaudeUsageDataSource = if provider == .claude, - let account, - TokenAccountSupportCatalog.isClaudeOAuthToken(account.token) - { - .oauth - } else { - .auto - } - let effectiveSource = (claudeSource == .oauth) ? ProviderCookieSource.off : cookieSource + let routing = self.claudeCredentialRouting(account: account, config: config) + let claudeSource: ClaudeUsageDataSource = routing.isOAuth ? .oauth : .auto + let cookieSource = routing.isOAuth + ? ProviderCookieSource.off + : self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( claude: ProviderSettingsSnapshot.ClaudeProviderSettings( usageDataSource: claudeSource, webExtrasEnabled: false, - cookieSource: effectiveSource, - manualCookieHeader: claudeSource == .oauth ? nil : cookieHeader)) + cookieSource: cookieSource, + manualCookieHeader: routing.manualCookieHeader)) case .cursor: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( cursor: ProviderSettingsSnapshot.CursorProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .opencode: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( opencode: ProviderSettingsSnapshot.OpenCodeProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader, workspaceID: config?.workspaceID)) case .factory: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( factory: ProviderSettingsSnapshot.FactoryProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .minimax: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader, apiRegion: self.resolveMiniMaxRegion(config))) case .augment: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( augment: ProviderSettingsSnapshot.AugmentProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .amp: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( amp: ProviderSettingsSnapshot.AmpProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .ollama: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( ollama: ProviderSettingsSnapshot.OllamaProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .kimi: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( kimi: ProviderSettingsSnapshot.KimiProviderSettings( cookieSource: cookieSource, @@ -238,13 +250,12 @@ struct TokenAccountCLIContext { account: ProviderTokenAccount?) -> ProviderSourceMode { guard base == .auto, - provider == .claude, - let account, - TokenAccountSupportCatalog.isClaudeOAuthToken(account.token) + provider == .claude else { return base } - return .oauth + let config = self.providerConfig(for: provider) + return self.claudeCredentialRouting(account: account, config: config).isOAuth ? .oauth : base } func preferredSourceMode(for provider: UsageProvider) -> ProviderSourceMode { @@ -265,9 +276,6 @@ struct TokenAccountCLIContext { let support = TokenAccountSupportCatalog.support(for: provider), case .cookieHeader = support.injection { - if provider == .claude, TokenAccountSupportCatalog.isClaudeOAuthToken(account.token) { - return nil - } let header = TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) return header.isEmpty ? nil : header } @@ -279,10 +287,7 @@ struct TokenAccountCLIContext { account: ProviderTokenAccount?, config: ProviderConfig?) -> ProviderCookieSource { - if let account, TokenAccountSupportCatalog.support(for: provider)?.requiresManualCookieSource == true { - if provider == .claude, TokenAccountSupportCatalog.isClaudeOAuthToken(account.token) { - return .off - } + if account != nil, TokenAccountSupportCatalog.support(for: provider)?.requiresManualCookieSource == true { return .manual } if let override = config?.cookieSource { return override } @@ -326,4 +331,14 @@ struct TokenAccountCLIContext { guard self.kiloUsageDataSource(from: config?.source) == .auto else { return false } return config?.extrasEnabled ?? false } + + private func claudeCredentialRouting( + account: ProviderTokenAccount?, + config: ProviderConfig?) -> ClaudeCredentialRouting + { + let manualCookieHeader = account == nil ? config?.sanitizedCookieHeader : nil + return ClaudeCredentialRouting.resolve( + tokenAccountToken: account?.token, + manualCookieHeader: manualCookieHeader) + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCredentialRouting.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCredentialRouting.swift new file mode 100644 index 0000000000..b887869d97 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCredentialRouting.swift @@ -0,0 +1,78 @@ +import Foundation + +public enum ClaudeCredentialRouting: Sendable, Equatable { + case none + case oauth(accessToken: String) + case webCookie(header: String) + + public static func resolve(tokenAccountToken: String?, manualCookieHeader: String?) -> Self { + if let tokenAccountToken, + let route = self.resolvePrimaryCredential(tokenAccountToken) + { + return route + } + + guard let manualCookieHeader = self.normalizedWebCookie(manualCookieHeader) else { + return .none + } + return .webCookie(header: manualCookieHeader) + } + + public var oauthAccessToken: String? { + guard case let .oauth(accessToken) = self else { return nil } + return accessToken + } + + public var manualCookieHeader: String? { + guard case let .webCookie(header) = self else { return nil } + return header + } + + public var isOAuth: Bool { + if case .oauth = self { return true } + return false + } + + private static func resolvePrimaryCredential(_ raw: String) -> Self? { + if let accessToken = self.normalizedOAuthToken(raw) { + return .oauth(accessToken: accessToken) + } + if let cookieHeader = self.normalizedWebCookie(raw) { + return .webCookie(header: cookieHeader) + } + return nil + } + + private static func normalizedOAuthToken(_ raw: String?) -> String? { + guard let trimmed = self.cleaned(raw) else { return nil } + let lower = trimmed.lowercased() + if lower.contains("cookie:") || trimmed.contains("=") { + return nil + } + if lower.hasPrefix("bearer ") { + let bearerTrimmed = trimmed.dropFirst("bearer ".count) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !bearerTrimmed.isEmpty else { return nil } + let lowerBearerTrimmed = bearerTrimmed.lowercased() + return lowerBearerTrimmed.hasPrefix("sk-ant-oat") ? bearerTrimmed : nil + } + return lower.hasPrefix("sk-ant-oat") ? trimmed : nil + } + + private static func normalizedWebCookie(_ raw: String?) -> String? { + guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } + if normalized.contains("=") { + return normalized + } + return "sessionKey=\(normalized)" + } + + private static func cleaned(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty + else { + return nil + } + return trimmed + } +} diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index 378ad39392..cadd22be04 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -42,10 +42,11 @@ public enum TokenAccountSupportCatalog { return [key: token] case .cookieHeader: if provider == .claude, - let normalized = self.normalizedClaudeOAuthToken(token), - self.isClaudeOAuthToken(normalized) + case let .oauth(accessToken) = ClaudeCredentialRouting.resolve( + tokenAccountToken: token, + manualCookieHeader: nil) { - return [ClaudeOAuthCredentialsStore.environmentTokenKey: normalized] + return [ClaudeOAuthCredentialsStore.environmentTokenKey: accessToken] } return nil } @@ -59,23 +60,7 @@ public enum TokenAccountSupportCatalog { } public static func isClaudeOAuthToken(_ token: String) -> Bool { - guard let trimmed = self.normalizedClaudeOAuthToken(token) else { return false } - let lower = trimmed.lowercased() - if lower.contains("cookie:") || trimmed.contains("=") { - return false - } - return lower.hasPrefix("sk-ant-oat") - } - - private static func normalizedClaudeOAuthToken(_ token: String) -> String? { - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let lower = trimmed.lowercased() - if lower.hasPrefix("bearer ") { - return trimmed.dropFirst("bearer ".count) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed + ClaudeCredentialRouting.resolve(tokenAccountToken: token, manualCookieHeader: nil).isOAuth } public static func normalizedCookieHeader(_ token: String, support: TokenAccountSupport) -> String { diff --git a/Tests/CodexBarTests/ClaudeCredentialRoutingTests.swift b/Tests/CodexBarTests/ClaudeCredentialRoutingTests.swift new file mode 100644 index 0000000000..f848a4a00b --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCredentialRoutingTests.swift @@ -0,0 +1,59 @@ +import CodexBarCore +import Testing + +@Suite +struct ClaudeCredentialRoutingTests { + @Test + func resolvesRawOAuthToken() { + let routing = ClaudeCredentialRouting.resolve( + tokenAccountToken: "sk-ant-oat-test-token", + manualCookieHeader: nil) + + #expect(routing == .oauth(accessToken: "sk-ant-oat-test-token")) + } + + @Test + func resolvesBearerOAuthToken() { + let routing = ClaudeCredentialRouting.resolve( + tokenAccountToken: "Bearer sk-ant-oat-test-token", + manualCookieHeader: nil) + + #expect(routing == .oauth(accessToken: "sk-ant-oat-test-token")) + } + + @Test + func resolvesSessionTokenToCookieHeader() { + let routing = ClaudeCredentialRouting.resolve( + tokenAccountToken: "sk-ant-session-token", + manualCookieHeader: nil) + + #expect(routing == .webCookie(header: "sessionKey=sk-ant-session-token")) + } + + @Test + func resolvesConfigCookieHeaderThroughSharedNormalizer() { + let routing = ClaudeCredentialRouting.resolve( + tokenAccountToken: nil, + manualCookieHeader: "Cookie: sessionKey=sk-ant-session-token; foo=bar") + + #expect(routing == .webCookie(header: "sessionKey=sk-ant-session-token; foo=bar")) + } + + @Test + func tokenAccountInputWinsOverConfigCookieFallback() { + let routing = ClaudeCredentialRouting.resolve( + tokenAccountToken: "Bearer sk-ant-oat-test-token", + manualCookieHeader: "Cookie: sessionKey=sk-ant-session-token") + + #expect(routing == .oauth(accessToken: "sk-ant-oat-test-token")) + } + + @Test + func emptyInputsResolveToNone() { + let routing = ClaudeCredentialRouting.resolve( + tokenAccountToken: " ", + manualCookieHeader: "\n") + + #expect(routing == .none) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 1b53d36bc5..80e5768775 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -103,6 +103,32 @@ struct SettingsStoreCoverageTests { #expect(snapshot.manualCookieHeader == "sessionKey=sk-ant-session-token") } + @Test + func claudeSnapshotNormalizesConfigManualCookieInputThroughSharedRoute() { + let settings = Self.makeSettingsStore() + settings.claudeCookieSource = .manual + settings.claudeCookieHeader = "Cookie: sessionKey=sk-ant-session-token; foo=bar" + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "sessionKey=sk-ant-session-token; foo=bar") + } + + @Test + func claudeSnapshotDoesNotFallBackToConfigCookieForMalformedSelectedTokenAccount() { + let settings = Self.makeSettingsStore() + settings.claudeCookieSource = .manual + settings.claudeCookieHeader = "Cookie: sessionKey=sk-ant-config-cookie" + settings.addTokenAccount(provider: .claude, label: "Malformed", token: "Cookie:") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader?.isEmpty == true) + } + @Test func tokenCostUsageSourceDetection() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index c9d7400c72..6da6a39da3 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -148,6 +148,28 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") } + @Test + func claudeOAuthTokenSelectionPromotesAutoSourceModeInCLI() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .auto, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .oauth) + } + @Test func claudeSessionKeySelectionStaysInManualCookieModeInCLISettingsSnapshot() throws { let accounts = ProviderTokenAccountData( @@ -179,6 +201,45 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") } + @Test + func claudeConfigManualCookieUsesSharedRouteInCLISettingsSnapshot() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieHeader: "Cookie: sessionKey=sk-ant-session-token; foo=bar"), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: nil)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .auto) + #expect(claudeSettings.cookieSource == .manual) + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token; foo=bar") + } + + @Test + func claudeConfigManualCookieDoesNotPromoteAutoSourceModeInCLI() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieHeader: "Cookie: sessionKey=sk-ant-session-token"), + ]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .auto, + provider: .claude, + account: nil) + + #expect(effectiveSourceMode == .auto) + } + @Test func applyAccountLabelInAppPreservesSnapshotFields() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-apply-app") From 468950d5b8f84f19c3cf834c2a0cf92973262be5 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 8 Mar 2026 01:25:44 +0530 Subject: [PATCH 0004/1239] Consolidate Claude source planning --- Sources/CodexBar/PreferencesDebugPane.swift | 18 +- Sources/CodexBar/UsageStore+ClaudeDebug.swift | 168 +++++++ Sources/CodexBar/UsageStore+Timeout.swift | 19 + Sources/CodexBar/UsageStore.swift | 387 +++++++-------- Sources/CodexBarCore/KeychainCacheStore.swift | 17 +- ...udeOAuthCredentials+TestingOverrides.swift | 80 +++ .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 15 +- ...audeOAuthDelegatedRefreshCoordinator.swift | 367 +++++++++----- .../Claude/ClaudeProviderDescriptor.swift | 201 +++++--- .../Claude/ClaudeSourcePlanner.swift | 214 ++++++++ .../Providers/Claude/ClaudeStatusProbe.swift | 85 +++- .../Providers/Claude/ClaudeUsageFetcher.swift | 151 +++--- .../ClaudeBaselineCharacterizationTests.swift | 203 +++++++- .../ClaudeDebugDiagnosticsTests.swift | 459 ++++++++++++++++++ ...AuthCredentialsStoreSecurityCLITests.swift | 199 ++++---- .../ClaudeOAuthCredentialsStoreTests.swift | 278 +++++------ ...AuthDelegatedRefreshCoordinatorTests.swift | 365 +++++++++----- ...deOAuthDelegatedRefreshRecoveryTests.swift | 41 +- ...eOAuthFetchStrategyAvailabilityTests.swift | 29 +- .../ClaudeSourcePlannerTests.swift | 100 ++++ ...sageDelegatedRefreshEnvironmentTests.swift | 49 ++ Tests/CodexBarTests/ClaudeUsageTests.swift | 141 +++++- 22 files changed, 2684 insertions(+), 902 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+ClaudeDebug.swift create mode 100644 Sources/CodexBar/UsageStore+Timeout.swift create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift create mode 100644 Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift create mode 100644 Tests/CodexBarTests/ClaudeSourcePlannerTests.swift create mode 100644 Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index c5d4730b4a..a86a55434c 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -430,7 +430,11 @@ struct DebugPane: View { private func loadLog(_ provider: UsageProvider) { self.isLoadingLog = true Task { - let text = await self.store.debugLog(for: provider) + let text = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ProviderRefreshContext.$current.withValue(.regular) { + await self.store.debugLog(for: provider) + } + } await MainActor.run { self.logText = text self.isLoadingLog = false @@ -442,11 +446,19 @@ struct DebugPane: View { Task { if self.logText.isEmpty { self.isLoadingLog = true - let text = await self.store.debugLog(for: provider) + let text = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ProviderRefreshContext.$current.withValue(.regular) { + await self.store.debugLog(for: provider) + } + } await MainActor.run { self.logText = text } self.isLoadingLog = false } - _ = await self.store.dumpLog(toFileFor: provider) + _ = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ProviderRefreshContext.$current.withValue(.regular) { + await self.store.dumpLog(toFileFor: provider) + } + } } } diff --git a/Sources/CodexBar/UsageStore+ClaudeDebug.swift b/Sources/CodexBar/UsageStore+ClaudeDebug.swift new file mode 100644 index 0000000000..ee9b2ab8c8 --- /dev/null +++ b/Sources/CodexBar/UsageStore+ClaudeDebug.swift @@ -0,0 +1,168 @@ +import CodexBarCore +import Foundation +import SweetCookieKit + +@MainActor +extension UsageStore { + func debugClaudeDump() async -> String { + await ClaudeStatusProbe.latestDumps() + } +} + +extension UsageStore { + struct ClaudeDebugLogConfiguration: Sendable { + let runtime: CodexBarCore.ProviderRuntime + let sourceMode: ProviderSourceMode + let environment: [String: String] + let webExtrasEnabled: Bool + let usageDataSource: ClaudeUsageDataSource + let cookieSource: ProviderCookieSource + let cookieHeader: String + let keepCLISessionsAlive: Bool + } + + static func debugClaudeLog( + browserDetection: BrowserDetection, + configuration: ClaudeDebugLogConfiguration) async -> String + { + struct OAuthDebugProbe: Sendable { + let hasCredentials: Bool + let ownerRawValue: String + let sourceRawValue: String + let isExpired: Bool + } + + return await runWithTimeout(seconds: 15) { + var lines: [String] = [] + let manualHeader = configuration.cookieSource == .manual + ? CookieHeaderNormalizer.normalize(configuration.cookieHeader) + : nil + let hasKey = if configuration.cookieSource == .off { + false + } else if let manualHeader { + ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: manualHeader) + } else { + ClaudeWebAPIFetcher.hasSessionKey(browserDetection: browserDetection) { msg in lines.append(msg) } + } + let oauthProbe = await withTaskGroup(of: OAuthDebugProbe.self) { group in + // Preserve task-local test overrides while keeping the keychain read off the calling task. + group.addTask(priority: .utility) { + let oauthRecord = try? ClaudeOAuthCredentialsStore.loadRecord( + environment: configuration.environment, + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + return OAuthDebugProbe( + hasCredentials: oauthRecord?.credentials.scopes.contains("user:profile") == true, + ownerRawValue: oauthRecord?.owner.rawValue ?? "none", + sourceRawValue: oauthRecord?.source.rawValue ?? "none", + isExpired: oauthRecord?.credentials.isExpired ?? false) + } + return await group.next() ?? OAuthDebugProbe( + hasCredentials: false, + ownerRawValue: "none", + sourceRawValue: "none", + isExpired: false) + } + let hasOAuthCredentials = ClaudeOAuthPlanningAvailability.isAvailable( + runtime: configuration.runtime, + sourceMode: configuration.sourceMode, + environment: configuration.environment) + let hasClaudeBinary = ClaudeCLIResolver.isAvailable(environment: configuration.environment) + let delegatedCooldownSeconds = ClaudeOAuthDelegatedRefreshCoordinator.cooldownRemainingSeconds() + let planningInput = ClaudeSourcePlanningInput( + runtime: configuration.runtime, + selectedDataSource: configuration.usageDataSource, + webExtrasEnabled: configuration.webExtrasEnabled, + hasWebSession: hasKey, + hasCLI: hasClaudeBinary, + hasOAuthCredentials: hasOAuthCredentials) + let plan = ClaudeSourcePlanner.resolve(input: planningInput) + let strategy = plan.compatibilityStrategy + + lines.append(contentsOf: plan.debugLines()) + lines.append("hasSessionKey=\(hasKey)") + lines.append("hasOAuthCredentials=\(hasOAuthCredentials)") + lines.append("oauthCredentialOwner=\(oauthProbe.ownerRawValue)") + lines.append("oauthCredentialSource=\(oauthProbe.sourceRawValue)") + lines.append("oauthCredentialExpired=\(oauthProbe.isExpired)") + lines.append("delegatedRefreshCLIAvailable=\(hasClaudeBinary)") + lines.append("delegatedRefreshCooldownActive=\(delegatedCooldownSeconds != nil)") + if let delegatedCooldownSeconds { + lines.append("delegatedRefreshCooldownSeconds=\(delegatedCooldownSeconds)") + } + lines.append("hasClaudeBinary=\(hasClaudeBinary)") + if strategy?.useWebExtras == true { + lines.append("web_extras=enabled") + } + lines.append("") + + guard let strategy else { + lines.append("No planner-selected Claude source.") + return lines.joined(separator: "\n") + } + + switch strategy.dataSource { + case .auto: + lines.append("Auto source selected.") + return lines.joined(separator: "\n") + case .web: + do { + let web: ClaudeWebAPIFetcher.WebUsageData = + if let manualHeader { + try await ClaudeWebAPIFetcher.fetchUsage(cookieHeader: manualHeader) { msg in + lines.append(msg) + } + } else { + try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: browserDetection) { msg in + lines.append(msg) + } + } + lines.append("") + lines.append("Web API summary:") + + let sessionReset = web.sessionResetsAt?.description ?? "nil" + lines.append("session_used=\(web.sessionPercentUsed)% resetsAt=\(sessionReset)") + + if let weekly = web.weeklyPercentUsed { + let weeklyReset = web.weeklyResetsAt?.description ?? "nil" + lines.append("weekly_used=\(weekly)% resetsAt=\(weeklyReset)") + } else { + lines.append("weekly_used=nil") + } + + lines.append("opus_used=\(web.opusPercentUsed?.description ?? "nil")") + + if let extra = web.extraUsageCost { + let resetsAt = extra.resetsAt?.description ?? "nil" + let period = extra.period ?? "nil" + let line = + "extra_usage used=\(extra.used) limit=\(extra.limit) " + + "currency=\(extra.currencyCode) period=\(period) resetsAt=\(resetsAt)" + lines.append(line) + } else { + lines.append("extra_usage=nil") + } + + return lines.joined(separator: "\n") + } catch { + lines.append("Web API failed: \(error.localizedDescription)") + return lines.joined(separator: "\n") + } + case .cli: + let fetcher = ClaudeUsageFetcher( + browserDetection: browserDetection, + environment: configuration.environment, + runtime: configuration.runtime, + dataSource: configuration.usageDataSource, + keepCLISessionsAlive: configuration.keepCLISessionsAlive) + let cli = await fetcher.debugRawProbe(model: "sonnet") + lines.append(cli) + return lines.joined(separator: "\n") + case .oauth: + lines.append("OAuth source selected.") + return lines.joined(separator: "\n") + } + } + } +} diff --git a/Sources/CodexBar/UsageStore+Timeout.swift b/Sources/CodexBar/UsageStore+Timeout.swift new file mode 100644 index 0000000000..ac81d04bcb --- /dev/null +++ b/Sources/CodexBar/UsageStore+Timeout.swift @@ -0,0 +1,19 @@ +import Foundation + +extension UsageStore { + nonisolated static func runWithTimeout( + seconds: Double, + operation: @escaping @Sendable () async -> String) async -> String + { + await withTaskGroup(of: String?.self) { group -> String in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + let result = await group.next()?.flatMap(\.self) + group.cancelAll() + return result ?? "Probe timed out after \(Int(seconds))s" + } + } +} diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 27581291c7..20989ed218 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -55,6 +55,7 @@ extension UsageStore { Task { @MainActor [weak self] in guard let self else { return } self.observeSettingsChanges() + self.probeLogs = [:] self.startTimer() self.updateProviderRuntimes() await self.refresh() @@ -1149,10 +1150,6 @@ extension UsageStore { } } - func debugClaudeDump() async -> String { - await ClaudeStatusProbe.latestDumps() - } - func debugAugmentDump() async -> String { await AugmentStatusProbe.latestDumps() } @@ -1166,7 +1163,15 @@ extension UsageStore { let claudeUsageDataSource = self.settings.claudeUsageDataSource let claudeCookieSource = self.settings.claudeCookieSource let claudeCookieHeader = self.settings.claudeCookieHeader - let keepCLISessionsAlive = self.settings.debugKeepCLISessionsAlive + 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 @@ -1182,7 +1187,10 @@ extension UsageStore { base: processEnvironment, provider: .openrouter, config: self.settings.providerConfig(for: .openrouter)) - return await Task.detached(priority: .utility) { () -> String in + let codexFetcher = self.codexFetcher + let browserDetection = self.browserDetection + let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() + let text = await Task.detached(priority: .utility) { () -> String in let unimplementedDebugLogMessages: [UsageProvider: String] = [ .gemini: "Gemini debug log not yet implemented", .antigravity: "Antigravity debug log not yet implemented", @@ -1196,209 +1204,179 @@ extension UsageStore { .kimik2: "Kimi K2 debug log not yet implemented", .jetbrains: "JetBrains AI debug log not yet implemented", ] - let text: String - switch provider { - case .codex: - text = await self.codexFetcher.debugRawRateLimits() - case .claude: - text = await self.debugClaudeLog( - claudeWebExtrasEnabled: claudeWebExtrasEnabled, - claudeUsageDataSource: claudeUsageDataSource, - claudeCookieSource: claudeCookieSource, - claudeCookieHeader: claudeCookieHeader, - keepCLISessionsAlive: keepCLISessionsAlive) - case .zai: - let resolution = ProviderTokenResolver.zaiResolution() - let hasAny = resolution != nil - let source = resolution?.source.rawValue ?? "none" - text = "Z_AI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .synthetic: - let resolution = ProviderTokenResolver.syntheticResolution() - let hasAny = resolution != nil - let source = resolution?.source.rawValue ?? "none" - text = "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .cursor: - text = await self.debugCursorLog( - cursorCookieSource: cursorCookieSource, - cursorCookieHeader: cursorCookieHeader) - case .minimax: - let tokenResolution = ProviderTokenResolver.minimaxTokenResolution() - let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() - let tokenSource = tokenResolution?.source.rawValue ?? "none" - let cookieSource = cookieResolution?.source.rawValue ?? "none" - text = "MINIMAX_API_KEY=\(tokenResolution == nil ? "missing" : "present") " + - "source=\(tokenSource) MINIMAX_COOKIE=\(cookieResolution == nil ? "missing" : "present") " + - "source=\(cookieSource)" - case .augment: - text = await self.debugAugmentLog() - case .amp: - text = await self.debugAmpLog( - ampCookieSource: ampCookieSource, - ampCookieHeader: ampCookieHeader) - case .ollama: - text = await self.debugOllamaLog( - ollamaCookieSource: ollamaCookieSource, - ollamaCookieHeader: ollamaCookieHeader) - case .openrouter: - let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) - let hasAny = resolution != nil - let source: String = if resolution == nil { - "none" - } else if openRouterHasConfigToken, openRouterHasEnvToken { - "settings-config (overrides env)" - } else if openRouterHasConfigToken { - "settings-config" - } else { - resolution?.source.rawValue ?? "environment" + let buildText = { + switch provider { + case .codex: + return await codexFetcher.debugRawRateLimits() + case .claude: + guard let claudeDebugConfiguration else { + return "Claude debug log configuration unavailable" + } + return await Self.debugClaudeLog( + browserDetection: browserDetection, + configuration: claudeDebugConfiguration) + case .zai: + let resolution = ProviderTokenResolver.zaiResolution() + 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.syntheticResolution() + 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.minimaxTokenResolution() + let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() + 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 .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 .openrouter: + let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) + let hasAny = resolution != nil + let source: String = if resolution == nil { + "none" + } else if openRouterHasConfigToken, openRouterHasEnvToken { + "settings-config (overrides env)" + } else if openRouterHasConfigToken { + "settings-config" + } else { + resolution?.source.rawValue ?? "environment" + } + return "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .warp: + let resolution = ProviderTokenResolver.warpResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, + .kimik2, .jetbrains: + return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } - text = "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .warp: - let resolution = ProviderTokenResolver.warpResolution() - let hasAny = resolution != nil - let source = resolution?.source.rawValue ?? "none" - text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, - .jetbrains: - text = unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } - - await MainActor.run { self.probeLogs[provider] = text } - return text + return await claudeDebugExecutionContext.apply { + await buildText() + } }.value + self.probeLogs[provider] = text + return text } - private func debugClaudeLog( - claudeWebExtrasEnabled: Bool, - claudeUsageDataSource: ClaudeUsageDataSource, - claudeCookieSource: ProviderCookieSource, - claudeCookieHeader: String, - keepCLISessionsAlive: Bool) async -> String + private func makeClaudeDebugConfiguration( + fallbackUsageDataSource: ClaudeUsageDataSource, + fallbackWebExtrasEnabled: Bool, + fallbackCookieSource: ProviderCookieSource, + fallbackCookieHeader: String) async -> ClaudeDebugLogConfiguration { - struct OAuthDebugProbe: Sendable { - let hasCredentials: Bool - let ownerRawValue: String - let sourceRawValue: String - let isExpired: Bool + await MainActor.run { + let sourceMode = self.sourceMode(for: .claude) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) + let environment = ProviderRegistry.makeEnvironment( + base: ProcessInfo.processInfo.environment, + 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) } + } - return await self.runWithTimeout(seconds: 15) { - var lines: [String] = [] - let manualHeader = claudeCookieSource == .manual - ? CookieHeaderNormalizer.normalize(claudeCookieHeader) - : nil - let hasKey = if let manualHeader { - ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: manualHeader) - } else { - ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) { msg in lines.append(msg) } - } - // Run potentially blocking keychain probes off MainActor so debug dumps don't stall UI rendering. - let oauthProbe = await Task.detached(priority: .utility) { - // Don't prompt for keychain access during debug dump. - let oauthRecord = try? ClaudeOAuthCredentialsStore.loadRecord( - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true, - allowClaudeKeychainRepairWithoutPrompt: false) - return OAuthDebugProbe( - hasCredentials: oauthRecord?.credentials.scopes.contains("user:profile") == true, - ownerRawValue: oauthRecord?.owner.rawValue ?? "none", - sourceRawValue: oauthRecord?.source.rawValue ?? "none", - isExpired: oauthRecord?.credentials.isExpired ?? false) - }.value - let hasOAuthCredentials = oauthProbe.hasCredentials - let hasClaudeBinary = ClaudeOAuthDelegatedRefreshCoordinator.isClaudeCLIAvailable() - let delegatedCooldownSeconds = ClaudeOAuthDelegatedRefreshCoordinator.cooldownRemainingSeconds() - - let strategy = ClaudeProviderDescriptor.resolveUsageStrategy( - selectedDataSource: claudeUsageDataSource, - webExtrasEnabled: claudeWebExtrasEnabled, - hasWebSession: hasKey, - hasCLI: hasClaudeBinary, - hasOAuthCredentials: hasOAuthCredentials) - - if claudeUsageDataSource == .auto { - lines.append("pipeline_order=oauth→cli→web") - lines.append("auto_heuristic=\(strategy.dataSource.rawValue)") - } else { - lines.append("strategy=\(strategy.dataSource.rawValue)") - } - lines.append("hasSessionKey=\(hasKey)") - lines.append("hasOAuthCredentials=\(hasOAuthCredentials)") - lines.append("oauthCredentialOwner=\(oauthProbe.ownerRawValue)") - lines.append("oauthCredentialSource=\(oauthProbe.sourceRawValue)") - lines.append("oauthCredentialExpired=\(oauthProbe.isExpired)") - lines.append("delegatedRefreshCLIAvailable=\(hasClaudeBinary)") - lines.append("delegatedRefreshCooldownActive=\(delegatedCooldownSeconds != nil)") - if let delegatedCooldownSeconds { - lines.append("delegatedRefreshCooldownSeconds=\(delegatedCooldownSeconds)") - } - lines.append("hasClaudeBinary=\(hasClaudeBinary)") - if strategy.useWebExtras { - lines.append("web_extras=enabled") - } - lines.append("") - - switch strategy.dataSource { - case .auto: - lines.append("Auto source selected.") - return lines.joined(separator: "\n") - case .web: - do { - let web = try await ClaudeWebAPIFetcher - .fetchUsage(browserDetection: self.browserDetection) { msg in lines.append(msg) } - lines.append("") - lines.append("Web API summary:") - - let sessionReset = web.sessionResetsAt?.description ?? "nil" - lines.append("session_used=\(web.sessionPercentUsed)% resetsAt=\(sessionReset)") - - if let weekly = web.weeklyPercentUsed { - let weeklyReset = web.weeklyResetsAt?.description ?? "nil" - lines.append("weekly_used=\(weekly)% resetsAt=\(weeklyReset)") - } else { - lines.append("weekly_used=nil") - } - - lines.append("opus_used=\(web.opusPercentUsed?.description ?? "nil")") - - if let extra = web.extraUsageCost { - let resetsAt = extra.resetsAt?.description ?? "nil" - let period = extra.period ?? "nil" - let line = - "extra_usage used=\(extra.used) limit=\(extra.limit) " + - "currency=\(extra.currencyCode) period=\(period) resetsAt=\(resetsAt)" - lines.append(line) - } else { - lines.append("extra_usage=nil") + private struct ClaudeDebugExecutionContext { + let interaction: ProviderInteraction + let refreshPhase: ProviderRefreshPhase + #if DEBUG + let keychainServiceOverride: String? + let credentialsURLOverride: URL? + let testingOverrides: ClaudeOAuthCredentialsStore.TestingOverridesSnapshot + let cliPathOverride: String? + let statusFetchOverride: ClaudeStatusProbe.FetchOverride? + #endif + + func apply(_ 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 ClaudeCLIResolver + .withResolvedBinaryPathOverrideForTesting(self.cliPathOverride) { + await ClaudeStatusProbe + .withFetchOverrideForTesting(self.statusFetchOverride) { + await operation() + } + } + } + } } - - return lines.joined(separator: "\n") - } catch { - lines.append("Web API failed: \(error.localizedDescription)") - return lines.joined(separator: "\n") + #else + return await operation() + #endif } - case .cli: - let fetcher = ClaudeUsageFetcher( - browserDetection: self.browserDetection, - keepCLISessionsAlive: keepCLISessionsAlive) - let cli = await fetcher.debugRawProbe(model: "sonnet") - lines.append(cli) - return lines.joined(separator: "\n") - case .oauth: - lines.append("OAuth source selected.") - return lines.joined(separator: "\n") } } } - private func debugCursorLog( + private func currentClaudeDebugExecutionContext() -> ClaudeDebugExecutionContext { + #if DEBUG + ClaudeDebugExecutionContext( + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + keychainServiceOverride: KeychainCacheStore.currentServiceOverrideForTesting, + credentialsURLOverride: ClaudeOAuthCredentialsStore.currentCredentialsURLOverrideForTesting, + testingOverrides: ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask, + 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 self.runWithTimeout(seconds: 15) { + await runWithTimeout(seconds: 15) { var lines: [String] = [] do { - let probe = CursorStatusProbe(browserDetection: self.browserDetection) + let probe = CursorStatusProbe(browserDetection: browserDetection) let snapshot: CursorStatusSnapshot = if cursorCookieSource == .manual, let normalizedHeader = CookieHeaderNormalizer .normalize(cursorCookieHeader) @@ -1440,19 +1418,20 @@ extension UsageStore { } } - private func debugAugmentLog() async -> String { - await self.runWithTimeout(seconds: 15) { + private static func debugAugmentLog() async -> String { + await runWithTimeout(seconds: 15) { let probe = AugmentStatusProbe() return await probe.debugRawProbe() } } - private func debugAmpLog( + private static func debugAmpLog( + browserDetection: BrowserDetection, ampCookieSource: ProviderCookieSource, ampCookieHeader: String) async -> String { - await self.runWithTimeout(seconds: 15) { - let fetcher = AmpUsageFetcher(browserDetection: self.browserDetection) + await runWithTimeout(seconds: 15) { + let fetcher = AmpUsageFetcher(browserDetection: browserDetection) let manualHeader = ampCookieSource == .manual ? CookieHeaderNormalizer.normalize(ampCookieHeader) : nil @@ -1460,12 +1439,13 @@ extension UsageStore { } } - private func debugOllamaLog( + private static func debugOllamaLog( + browserDetection: BrowserDetection, ollamaCookieSource: ProviderCookieSource, ollamaCookieHeader: String) async -> String { - await self.runWithTimeout(seconds: 15) { - let fetcher = OllamaUsageFetcher(browserDetection: self.browserDetection) + await runWithTimeout(seconds: 15) { + let fetcher = OllamaUsageFetcher(browserDetection: browserDetection) let manualHeader = ollamaCookieSource == .manual ? CookieHeaderNormalizer.normalize(ollamaCookieHeader) : nil @@ -1475,19 +1455,6 @@ extension UsageStore { } } - private func runWithTimeout(seconds: Double, operation: @escaping @Sendable () async -> String) async -> String { - await withTaskGroup(of: String?.self) { group -> String in - group.addTask { await operation() } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil - } - let result = await group.next()?.flatMap(\.self) - group.cancelAll() - return result ?? "Probe timed out after \(Int(seconds))s" - } - } - private func detectVersions() { let implementations = ProviderCatalog.all let browserDetection = self.browserDetection diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 563e07343a..e77ebbd778 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -141,7 +141,7 @@ public enum KeychainCacheStore { self.globalServiceOverride = service } - static func withServiceOverrideForTesting( + public static func withServiceOverrideForTesting( _ service: String?, operation: () throws -> T) rethrows -> T { @@ -150,7 +150,7 @@ public enum KeychainCacheStore { } } - static func withServiceOverrideForTesting( + public static func withServiceOverrideForTesting( _ service: String?, operation: () async throws -> T) async rethrows -> T { @@ -159,6 +159,19 @@ public enum KeychainCacheStore { } } + public static func withCurrentServiceOverrideForTesting( + operation: () async throws -> T) async rethrows -> T + { + let service = self.serviceOverride + return try await self.$serviceOverride.withValue(service) { + try await operation() + } + } + + public static var currentServiceOverrideForTesting: String? { + self.serviceOverride + } + static func setTestStoreForTesting(_ enabled: Bool) { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift index dbcd11105a..9d8fe61705 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift @@ -115,6 +115,37 @@ extension ClaudeOAuthCredentialsStore { @TaskLocal static var taskSecurityCLIReadAccountOverride: String? nonisolated(unsafe) static var securityCLIReadOverride: SecurityCLIReadOverride? + public struct TestingOverridesSnapshot: Sendable { + let keychainData: Data? + let keychainFingerprint: ClaudeKeychainFingerprint? + let memoryCacheStore: MemoryCacheStore? + let fingerprintStore: ClaudeKeychainFingerprintStore? + let keychainAccessOverride: Bool? + let credentialsFileFingerprintStore: CredentialsFileFingerprintStore? + let securityCLIReadOverride: SecurityCLIReadOverride? + let securityCLIReadAccountOverride: String? + + init( + keychainData: Data?, + keychainFingerprint: ClaudeKeychainFingerprint?, + memoryCacheStore: MemoryCacheStore?, + fingerprintStore: ClaudeKeychainFingerprintStore?, + keychainAccessOverride: Bool?, + credentialsFileFingerprintStore: CredentialsFileFingerprintStore?, + securityCLIReadOverride: SecurityCLIReadOverride?, + securityCLIReadAccountOverride: String?) + { + self.keychainData = keychainData + self.keychainFingerprint = keychainFingerprint + self.memoryCacheStore = memoryCacheStore + self.fingerprintStore = fingerprintStore + self.keychainAccessOverride = keychainAccessOverride + self.credentialsFileFingerprintStore = credentialsFileFingerprintStore + self.securityCLIReadOverride = securityCLIReadOverride + self.securityCLIReadAccountOverride = securityCLIReadAccountOverride + } + } + static func withKeychainAccessOverrideForTesting( _ disabled: Bool?, operation: () throws -> T) rethrows -> T @@ -205,6 +236,55 @@ extension ClaudeOAuthCredentialsStore { } } + public static func withCurrentTestingOverridesForTask( + operation: () async throws -> T) async rethrows -> T + { + try await self.withTestingOverridesSnapshotForTask( + self.currentTestingOverridesSnapshotForTask, + operation: operation) + } + + public static var currentTestingOverridesSnapshotForTask: TestingOverridesSnapshot { + TestingOverridesSnapshot( + keychainData: self.taskClaudeKeychainDataOverride, + keychainFingerprint: self.taskClaudeKeychainFingerprintOverride, + memoryCacheStore: self.taskMemoryCacheStoreOverride, + fingerprintStore: self.taskClaudeKeychainFingerprintStoreOverride, + keychainAccessOverride: self.taskKeychainAccessOverride, + credentialsFileFingerprintStore: self.taskCredentialsFileFingerprintStoreOverride, + securityCLIReadOverride: self.taskSecurityCLIReadOverride, + securityCLIReadAccountOverride: self.taskSecurityCLIReadAccountOverride) + } + + public static func withTestingOverridesSnapshotForTask( + _ snapshot: TestingOverridesSnapshot, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskClaudeKeychainDataOverride.withValue(snapshot.keychainData) { + try await self.$taskClaudeKeychainFingerprintOverride.withValue(snapshot.keychainFingerprint) { + try await self.$taskMemoryCacheStoreOverride.withValue(snapshot.memoryCacheStore) { + try await self.$taskClaudeKeychainFingerprintStoreOverride.withValue(snapshot.fingerprintStore) { + try await self.$taskKeychainAccessOverride.withValue(snapshot.keychainAccessOverride) { + try await self.$taskCredentialsFileFingerprintStoreOverride.withValue( + snapshot.credentialsFileFingerprintStore) + { + try await self.$taskSecurityCLIReadOverride.withValue( + snapshot.securityCLIReadOverride) + { + try await self.$taskSecurityCLIReadAccountOverride.withValue( + snapshot.securityCLIReadAccountOverride) + { + try await operation() + } + } + } + } + } + } + } + } + } + static func setSecurityCLIReadOverrideForTesting(_ readOverride: SecurityCLIReadOverride?) { self.securityCLIReadOverride = readOverride } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index e3e52c51f1..5d1cb28e32 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1323,23 +1323,22 @@ public enum ClaudeOAuthCredentialsStore { } #if DEBUG - static func withCredentialsURLOverrideForTesting( - _ url: URL?, - operation: () throws -> T) rethrows -> T - { + public static func withCredentialsURLOverrideForTesting(_ url: URL?, operation: () throws -> T) rethrows -> T { try self.$taskCredentialsURLOverride.withValue(url) { try operation() } } - static func withCredentialsURLOverrideForTesting( - _ url: URL?, - operation: () async throws -> T) async rethrows -> T - { + public static func withCredentialsURLOverrideForTesting(_ url: URL?, operation: () async throws -> T) + async rethrows -> T { try await self.$taskCredentialsURLOverride.withValue(url) { try await operation() } } + + public static var currentCredentialsURLOverrideForTesting: URL? { + self.taskCredentialsURLOverride + } #endif private static func saveToCacheKeychain(_ data: Data, owner: ClaudeOAuthCredentialOwner? = nil) { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index d94940602a..b331bc988b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -1,6 +1,21 @@ import Foundation public enum ClaudeOAuthDelegatedRefreshCoordinator { + private final class AttemptStateStorage: @unchecked Sendable { + let lock = NSLock() + let persistsCooldown: Bool + var hasLoadedState = false + var lastAttemptAt: Date? + var lastCooldownInterval: TimeInterval? + var inFlightAttemptID: UInt64? + var inFlightTask: Task? + var nextAttemptID: UInt64 = 0 + + init(persistsCooldown: Bool) { + self.persistsCooldown = persistsCooldown + } + } + public enum Outcome: Sendable, Equatable { case skippedByCooldown case cliUnavailable @@ -14,84 +29,118 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { private static let defaultCooldownInterval: TimeInterval = 60 * 5 private static let shortCooldownInterval: TimeInterval = 20 - private static let stateLock = NSLock() - private nonisolated(unsafe) static var hasLoadedState = false - private nonisolated(unsafe) static var lastAttemptAt: Date? - private nonisolated(unsafe) static var lastCooldownInterval: TimeInterval? - private nonisolated(unsafe) static var inFlightAttemptID: UInt64? - private nonisolated(unsafe) static var inFlightTask: Task? - private nonisolated(unsafe) static var nextAttemptID: UInt64 = 0 + private static let sharedState = AttemptStateStorage(persistsCooldown: true) - public static func attempt(now: Date = Date(), timeout: TimeInterval = 8) async -> Outcome { + public static func attempt( + now: Date = Date(), + timeout: TimeInterval = 8, + environment: [String: String] = ProcessInfo.processInfo.environment) async -> Outcome + { if Task.isCancelled { return .attemptedFailed("Cancelled.") } - switch self.inFlightDecision(now: now, timeout: timeout) { + switch self.inFlightDecision(now: now, timeout: timeout, environment: environment) { case let .join(task): return await task.value - case let .start(id, task): + case let .start(id, task, state): let outcome = await task.value - self.clearInFlightTaskIfStillCurrent(id: id) + self.clearInFlightTaskIfStillCurrent(id: id, state: state) return outcome } } private enum InFlightDecision { case join(Task) - case start(UInt64, Task) + case start(UInt64, Task, AttemptStateStorage) } - private static func inFlightDecision(now: Date, timeout: TimeInterval) -> InFlightDecision { - self.stateLock.lock() - defer { self.stateLock.unlock() } + private struct AttemptConfiguration: Sendable { + let environment: [String: String] + let readStrategy: ClaudeOAuthKeychainReadStrategy + let keychainAccessDisabled: Bool + let securityCLIReadOverride: (@Sendable () -> Data?)? + #if DEBUG + let cliAvailableOverride: Bool? + let touchAuthPathOverride: (@Sendable (TimeInterval, [String: String]) async throws -> Void)? + let keychainFingerprintOverride: (@Sendable () -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?)? + #endif + } - if let existing = self.inFlightTask { + private static func inFlightDecision( + now: Date, + timeout: TimeInterval, + environment: [String: String]) -> InFlightDecision + { + let state = self.currentStateStorage + state.lock.lock() + defer { state.lock.unlock() } + + if let existing = state.inFlightTask { return .join(existing) } - self.nextAttemptID += 1 - let attemptID = self.nextAttemptID + state.nextAttemptID += 1 + let attemptID = state.nextAttemptID // Detached to avoid inheriting the caller's executor context (e.g. MainActor) and cancellation state. - let readStrategy = ClaudeOAuthKeychainReadStrategyPreference.current() - let keychainAccessDisabled = KeychainAccessGate.isDisabled + #if DEBUG + let configuration = AttemptConfiguration( + environment: environment, + readStrategy: ClaudeOAuthKeychainReadStrategyPreference.current(), + keychainAccessDisabled: KeychainAccessGate.isDisabled, + securityCLIReadOverride: self.securityCLIReadOverrideSnapshot(), + cliAvailableOverride: self.cliAvailableOverrideForTesting, + touchAuthPathOverride: self.touchAuthPathOverrideForTesting, + keychainFingerprintOverride: self.keychainFingerprintOverrideForTesting) + #else + let configuration = AttemptConfiguration( + environment: environment, + readStrategy: ClaudeOAuthKeychainReadStrategyPreference.current(), + keychainAccessDisabled: KeychainAccessGate.isDisabled, + securityCLIReadOverride: self.securityCLIReadOverrideSnapshot()) + #endif let task = Task.detached(priority: .utility) { await self.performAttempt( now: now, timeout: timeout, - readStrategy: readStrategy, - keychainAccessDisabled: keychainAccessDisabled) + configuration: configuration, + state: state) } - self.inFlightAttemptID = attemptID - self.inFlightTask = task - return .start(attemptID, task) + state.inFlightAttemptID = attemptID + state.inFlightTask = task + return .start(attemptID, task, state) } private static func performAttempt( now: Date, timeout: TimeInterval, - readStrategy: ClaudeOAuthKeychainReadStrategy, - keychainAccessDisabled: Bool) async -> Outcome + configuration: AttemptConfiguration, + state: AttemptStateStorage) async -> Outcome { - guard self.isClaudeCLIAvailable() else { + guard self.isClaudeCLIAvailable(environment: configuration.environment, configuration: configuration) else { self.log.info("Claude OAuth delegated refresh skipped: claude CLI unavailable") return .cliUnavailable } // Atomically reserve an attempt under the lock so concurrent callers don't race past isInCooldown() and start // multiple touches/poll loops. - guard self.reserveAttemptIfNotInCooldown(now: now) else { + guard self.reserveAttemptIfNotInCooldown(now: now, state: state) else { self.log.debug("Claude OAuth delegated refresh skipped by cooldown") return .skippedByCooldown } let baseline = self.currentKeychainChangeObservationBaseline( - readStrategy: readStrategy, - keychainAccessDisabled: keychainAccessDisabled) + readStrategy: configuration.readStrategy, + keychainAccessDisabled: configuration.keychainAccessDisabled, + securityCLIReadOverride: configuration.securityCLIReadOverride, + configuration: configuration) var touchError: Error? do { - try await self.touchOAuthAuthPath(timeout: timeout) + try await self.touchOAuthAuthPath( + timeout: timeout, + environment: configuration.environment, + configuration: configuration) } catch { touchError = error } @@ -100,16 +149,17 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { // Otherwise we end up in a long cooldown with still-expired credentials. let changed = await self.waitForClaudeKeychainChange( from: baseline, - readStrategy: readStrategy, - keychainAccessDisabled: keychainAccessDisabled, + readStrategy: configuration.readStrategy, + keychainAccessDisabled: configuration.keychainAccessDisabled, + configuration: configuration, timeout: min(max(timeout, 1), 2)) if changed { - self.recordAttempt(now: now, cooldown: self.defaultCooldownInterval) + self.recordAttempt(now: now, cooldown: self.defaultCooldownInterval, state: state) self.log.info("Claude OAuth delegated refresh touch succeeded") return .attemptedSucceeded } - self.recordAttempt(now: now, cooldown: self.shortCooldownInterval) + self.recordAttempt(now: now, cooldown: self.shortCooldownInterval, state: state) if let touchError { let errorType = String(describing: type(of: touchError)) self.log.warning( @@ -124,42 +174,59 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { } public static func isInCooldown(now: Date = Date()) -> Bool { - self.stateLock.lock() - defer { self.stateLock.unlock() } - self.loadStateIfNeededLocked() - guard let lastAttemptAt = self.lastAttemptAt else { return false } - let cooldown = self.lastCooldownInterval ?? self.defaultCooldownInterval + let state = self.currentStateStorage + state.lock.lock() + defer { state.lock.unlock() } + self.loadStateIfNeededLocked(state: state) + guard let lastAttemptAt = state.lastAttemptAt else { return false } + let cooldown = state.lastCooldownInterval ?? self.defaultCooldownInterval return now.timeIntervalSince(lastAttemptAt) < cooldown } public static func cooldownRemainingSeconds(now: Date = Date()) -> Int? { - self.stateLock.lock() - defer { self.stateLock.unlock() } - self.loadStateIfNeededLocked() - guard let lastAttemptAt = self.lastAttemptAt else { return nil } - let cooldown = self.lastCooldownInterval ?? self.defaultCooldownInterval + let state = self.currentStateStorage + state.lock.lock() + defer { state.lock.unlock() } + self.loadStateIfNeededLocked(state: state) + guard let lastAttemptAt = state.lastAttemptAt else { return nil } + let cooldown = state.lastCooldownInterval ?? self.defaultCooldownInterval let remaining = cooldown - now.timeIntervalSince(lastAttemptAt) guard remaining > 0 else { return nil } return Int(remaining.rounded(.up)) } - public static func isClaudeCLIAvailable() -> Bool { + public static func isClaudeCLIAvailable( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + self.isClaudeCLIAvailable( + environment: environment, + configuration: nil) + } + + private static func isClaudeCLIAvailable( + environment: [String: String], + configuration: AttemptConfiguration?) -> Bool + { #if DEBUG - if let override = self.cliAvailableOverride { + if let override = configuration?.cliAvailableOverride ?? self.cliAvailableOverrideForTesting { return override } #endif - return ClaudeStatusProbe.isClaudeBinaryAvailable() + return ClaudeCLIResolver.isAvailable(environment: environment) } - private static func touchOAuthAuthPath(timeout: TimeInterval) async throws { + private static func touchOAuthAuthPath( + timeout: TimeInterval, + environment: [String: String], + configuration: AttemptConfiguration?) async throws + { #if DEBUG - if let override = self.touchAuthPathOverride { - try await override(timeout) + if let override = configuration?.touchAuthPathOverride ?? self.touchAuthPathOverrideForTesting { + try await override(timeout, environment) return } #endif - try await ClaudeStatusProbe.touchOAuthAuthPath(timeout: timeout) + try await ClaudeStatusProbe.touchOAuthAuthPath(timeout: timeout, environment: environment) } private enum KeychainChangeObservationBaseline: Sendable { @@ -169,20 +236,24 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { private static func currentKeychainChangeObservationBaseline( readStrategy: ClaudeOAuthKeychainReadStrategy, - keychainAccessDisabled: Bool) -> KeychainChangeObservationBaseline + keychainAccessDisabled: Bool, + securityCLIReadOverride: (@Sendable () -> Data?)?, + configuration: AttemptConfiguration?) -> KeychainChangeObservationBaseline { if readStrategy == .securityCLIExperimental { return .securityCLI(data: self.currentClaudeKeychainDataViaSecurityCLIForObservation( readStrategy: readStrategy, - keychainAccessDisabled: keychainAccessDisabled)) + keychainAccessDisabled: keychainAccessDisabled, + securityCLIReadOverride: securityCLIReadOverride)) } - return .securityFramework(fingerprint: self.currentClaudeKeychainFingerprint()) + return .securityFramework(fingerprint: self.currentClaudeKeychainFingerprint(configuration: configuration)) } private static func waitForClaudeKeychainChange( from baseline: KeychainChangeObservationBaseline, readStrategy: ClaudeOAuthKeychainReadStrategy, keychainAccessDisabled: Bool, + configuration: AttemptConfiguration?, timeout: TimeInterval) async -> Bool { // Prefer correctness but bound the delay. Keychain writes can be slightly delayed after the CLI touch. @@ -198,7 +269,10 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { case let .securityFramework(fingerprintBefore): // Treat "no fingerprint" as "not observed"; we only succeed if we can read a fingerprint and it // differs. - guard let current = self.currentClaudeKeychainFingerprintForObservation() else { return false } + guard let current = self.currentClaudeKeychainFingerprintForObservation(configuration: configuration) + else { + return false + } return current != fingerprintBefore case let .securityCLI(dataBefore): // In experimental mode, avoid Security.framework observation entirely and detect change from @@ -208,7 +282,8 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { guard let dataBefore else { return false } guard let current = self.currentClaudeKeychainDataViaSecurityCLIForObservation( readStrategy: readStrategy, - keychainAccessDisabled: keychainAccessDisabled) + keychainAccessDisabled: keychainAccessDisabled, + securityCLIReadOverride: configuration?.securityCLIReadOverride) else { return false } return current != dataBefore } @@ -235,9 +310,11 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { return false } - private static func currentClaudeKeychainFingerprint() -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint? { + private static func currentClaudeKeychainFingerprint( + configuration: AttemptConfiguration?) -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint? + { #if DEBUG - if let override = self.keychainFingerprintOverride { + if let override = configuration?.keychainFingerprintOverride ?? self.keychainFingerprintOverrideForTesting { return override() } #endif @@ -246,9 +323,15 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { private static func currentClaudeKeychainFingerprintForObservation() -> ClaudeOAuthCredentialsStore .ClaudeKeychainFingerprint? + { + self.currentClaudeKeychainFingerprintForObservation(configuration: nil) + } + + private static func currentClaudeKeychainFingerprintForObservation( + configuration: AttemptConfiguration?) -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint? { #if DEBUG - if let override = self.keychainFingerprintOverride { + if let override = configuration?.keychainFingerprintOverride ?? self.keychainFingerprintOverrideForTesting { return override() } #endif @@ -265,101 +348,163 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { private static func currentClaudeKeychainDataViaSecurityCLIForObservation( readStrategy: ClaudeOAuthKeychainReadStrategy, - keychainAccessDisabled: Bool) -> Data? + keychainAccessDisabled: Bool, + securityCLIReadOverride: (@Sendable () -> Data?)?) -> Data? { guard !keychainAccessDisabled else { return nil } + if let securityCLIReadOverride { + return securityCLIReadOverride() + } return ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled( interaction: .background, readStrategy: readStrategy) } - private static func clearInFlightTaskIfStillCurrent(id: UInt64) { - self.stateLock.lock() - if self.inFlightAttemptID == id { - self.inFlightAttemptID = nil - self.inFlightTask = nil + private static func securityCLIReadOverrideSnapshot() -> (@Sendable () -> Data?)? { + #if DEBUG + guard let override = ClaudeOAuthCredentialsStore.taskSecurityCLIReadOverride + ?? ClaudeOAuthCredentialsStore.securityCLIReadOverride + else { + return nil + } + + switch override { + case let .data(data): + return { data } + case .timedOut, .nonZeroExit: + return { nil } + case let .dynamic(read): + return { read(ClaudeOAuthCredentialsStore.SecurityCLIReadRequest(account: nil)) } } - self.stateLock.unlock() + #else + return nil + #endif + } + + private static func clearInFlightTaskIfStillCurrent(id: UInt64, state: AttemptStateStorage) { + state.lock.lock() + if state.inFlightAttemptID == id { + state.inFlightAttemptID = nil + state.inFlightTask = nil + } + state.lock.unlock() } - private static func recordAttempt(now: Date, cooldown: TimeInterval) { - self.stateLock.lock() - defer { self.stateLock.unlock() } - self.loadStateIfNeededLocked() - self.lastAttemptAt = now - self.lastCooldownInterval = cooldown + private static func recordAttempt(now: Date, cooldown: TimeInterval, state: AttemptStateStorage) { + state.lock.lock() + defer { state.lock.unlock() } + self.loadStateIfNeededLocked(state: state) + state.lastAttemptAt = now + state.lastCooldownInterval = cooldown + guard state.persistsCooldown else { return } UserDefaults.standard.set(now.timeIntervalSince1970, forKey: self.cooldownDefaultsKey) UserDefaults.standard.set(cooldown, forKey: self.cooldownIntervalDefaultsKey) } - private static func reserveAttemptIfNotInCooldown(now: Date) -> Bool { - self.stateLock.lock() - defer { self.stateLock.unlock() } - self.loadStateIfNeededLocked() + private static func reserveAttemptIfNotInCooldown(now: Date, state: AttemptStateStorage) -> Bool { + state.lock.lock() + defer { state.lock.unlock() } + self.loadStateIfNeededLocked(state: state) - let cooldown = self.lastCooldownInterval ?? self.defaultCooldownInterval - if let lastAttemptAt = self.lastAttemptAt, now.timeIntervalSince(lastAttemptAt) < cooldown { + let cooldown = state.lastCooldownInterval ?? self.defaultCooldownInterval + if let lastAttemptAt = state.lastAttemptAt, now.timeIntervalSince(lastAttemptAt) < cooldown { return false } // Reserve with a short cooldown; the final outcome will extend or keep it short. - self.lastAttemptAt = now - self.lastCooldownInterval = self.shortCooldownInterval + state.lastAttemptAt = now + state.lastCooldownInterval = self.shortCooldownInterval + guard state.persistsCooldown else { return true } UserDefaults.standard.set(now.timeIntervalSince1970, forKey: self.cooldownDefaultsKey) UserDefaults.standard.set(self.shortCooldownInterval, forKey: self.cooldownIntervalDefaultsKey) return true } - private static func loadStateIfNeededLocked() { - guard !self.hasLoadedState else { return } - self.hasLoadedState = true + private static func loadStateIfNeededLocked(state: AttemptStateStorage) { + guard !state.hasLoadedState else { return } + state.hasLoadedState = true + guard state.persistsCooldown else { + state.lastAttemptAt = nil + state.lastCooldownInterval = nil + return + } guard let raw = UserDefaults.standard.object(forKey: self.cooldownDefaultsKey) as? Double else { - self.lastAttemptAt = nil - self.lastCooldownInterval = nil + state.lastAttemptAt = nil + state.lastCooldownInterval = nil return } - self.lastAttemptAt = Date(timeIntervalSince1970: raw) + state.lastAttemptAt = Date(timeIntervalSince1970: raw) if let interval = UserDefaults.standard.object(forKey: self.cooldownIntervalDefaultsKey) as? Double { - self.lastCooldownInterval = interval + state.lastCooldownInterval = interval } else { - self.lastCooldownInterval = nil + state.lastCooldownInterval = nil } } #if DEBUG - private nonisolated(unsafe) static var cliAvailableOverride: Bool? - private nonisolated(unsafe) static var touchAuthPathOverride: (@Sendable (TimeInterval) async throws -> Void)? - private nonisolated(unsafe) static var keychainFingerprintOverride: (() -> ClaudeOAuthCredentialsStore + @TaskLocal private static var stateStorageForTesting: AttemptStateStorage? + @TaskLocal static var cliAvailableOverrideForTesting: Bool? + @TaskLocal static var touchAuthPathOverrideForTesting: (@Sendable ( + TimeInterval, + [String: String]) async throws -> Void)? + @TaskLocal static var keychainFingerprintOverrideForTesting: (@Sendable () -> ClaudeOAuthCredentialsStore .ClaudeKeychainFingerprint?)? - static func setCLIAvailableOverrideForTesting(_ override: Bool?) { - self.cliAvailableOverride = override + static func withCLIAvailableOverrideForTesting( + _ override: Bool?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$cliAvailableOverrideForTesting.withValue(override) { + try await operation() + } } - static func setTouchAuthPathOverrideForTesting(_ override: (@Sendable (TimeInterval) async throws -> Void)?) { - self.touchAuthPathOverride = override + static func withTouchAuthPathOverrideForTesting( + _ override: (@Sendable (TimeInterval, [String: String]) async throws -> Void)?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$touchAuthPathOverrideForTesting.withValue(override) { + try await operation() + } } - static func setKeychainFingerprintOverrideForTesting( - _ override: (() -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?)?) + static func withKeychainFingerprintOverrideForTesting( + _ override: (@Sendable () -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?)?, + operation: () async throws -> T) async rethrows -> T { - self.keychainFingerprintOverride = override + try await self.$keychainFingerprintOverrideForTesting.withValue(override) { + try await operation() + } + } + + static func withIsolatedStateForTesting(operation: () async throws -> T) async rethrows -> T { + let state = AttemptStateStorage(persistsCooldown: false) + return try await self.$stateStorageForTesting.withValue(state) { + try await operation() + } } static func resetForTesting() { - self.stateLock.lock() - self.hasLoadedState = true - self.lastAttemptAt = nil - self.lastCooldownInterval = nil - self.inFlightAttemptID = nil - self.inFlightTask = nil - self.nextAttemptID = 0 - self.stateLock.unlock() + let state = self.currentStateStorage + state.lock.lock() + state.hasLoadedState = true + state.lastAttemptAt = nil + state.lastCooldownInterval = nil + state.inFlightAttemptID = nil + state.inFlightTask = nil + state.nextAttemptID = 0 + state.lock.unlock() + guard state.persistsCooldown else { return } UserDefaults.standard.removeObject(forKey: self.cooldownDefaultsKey) UserDefaults.standard.removeObject(forKey: self.cooldownIntervalDefaultsKey) - self.cliAvailableOverride = nil - self.touchAuthPathOverride = nil - self.keychainFingerprintOverride = nil } #endif + + private static var currentStateStorage: AttemptStateStorage { + #if DEBUG + self.stateStorageForTesting ?? self.sharedState + #else + self.sharedState + #endif + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 002a600ae9..1786b07284 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -43,57 +43,52 @@ public enum ClaudeProviderDescriptor { } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { - switch context.runtime { - case .cli: - switch context.sourceMode { - case .oauth: - return [ClaudeOAuthFetchStrategy()] - case .web: - return [ClaudeWebFetchStrategy(browserDetection: context.browserDetection)] - case .cli: - return [ClaudeCLIFetchStrategy( - useWebExtras: false, - manualCookieHeader: nil, - browserDetection: context.browserDetection)] - case .api: - return [] - case .auto: - return [ - ClaudeWebFetchStrategy(browserDetection: context.browserDetection), - ClaudeCLIFetchStrategy( - useWebExtras: false, - manualCookieHeader: nil, - browserDetection: context.browserDetection), - ] - } - case .app: - let webExtrasEnabled = context.settings?.claude?.webExtrasEnabled ?? false - let manualCookieHeader = CookieHeaderNormalizer.normalize(context.settings?.claude?.manualCookieHeader) - switch context.sourceMode { + guard context.sourceMode != .api else { return [] } + + let planningInput = await Self.makePlanningInput(context: context) + let plan = ClaudeSourcePlanner.resolve(input: planningInput) + let manualCookieHeader = Self.manualCookieHeader(from: context) + + return plan.orderedSteps.map { step in + let strategy: any ProviderFetchStrategy = switch step.dataSource { case .oauth: - return [ClaudeOAuthFetchStrategy()] + ClaudeOAuthFetchStrategy() case .web: - return [ClaudeWebFetchStrategy(browserDetection: context.browserDetection)] + ClaudeWebFetchStrategy(browserDetection: context.browserDetection) case .cli: - return [ClaudeCLIFetchStrategy( - useWebExtras: webExtrasEnabled, + ClaudeCLIFetchStrategy( + useWebExtras: context.runtime == .app + && planningInput.webExtrasEnabled, manualCookieHeader: manualCookieHeader, - browserDetection: context.browserDetection)] - case .api: - return [] + browserDetection: context.browserDetection) case .auto: - return [ - ClaudeOAuthFetchStrategy(), - ClaudeCLIFetchStrategy( - useWebExtras: webExtrasEnabled, - manualCookieHeader: manualCookieHeader, - browserDetection: context.browserDetection), - ClaudeWebFetchStrategy(browserDetection: context.browserDetection), - ] + fatalError("Planner must not emit .auto as an executable step.") } + return ClaudePlannedFetchStrategy(base: strategy, plannedStep: step) } } + private static func makePlanningInput(context: ProviderFetchContext) async -> ClaudeSourcePlanningInput { + let webExtrasEnabled = context.settings?.claude?.webExtrasEnabled ?? false + return ClaudeSourcePlanningInput( + runtime: context.runtime, + selectedDataSource: Self.sourceDataSource(from: context.sourceMode), + webExtrasEnabled: webExtrasEnabled, + hasWebSession: ClaudeWebFetchStrategy.isAvailableForFallback( + context: context, + browserDetection: context.browserDetection), + hasCLI: ClaudeCLIResolver.isAvailable(environment: context.env), + hasOAuthCredentials: ClaudeOAuthPlanningAvailability.isAvailable( + runtime: context.runtime, + sourceMode: context.sourceMode, + environment: context.env)) + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.claude?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(context.settings?.claude?.manualCookieHeader) + } + private static func noDataMessage() -> String { "No Claude usage logs found in ~/.config/claude/projects or ~/.claude/projects." } @@ -105,21 +100,27 @@ public enum ClaudeProviderDescriptor { hasCLI: Bool, hasOAuthCredentials: Bool) -> ClaudeUsageStrategy { - if selectedDataSource == .auto { - if hasOAuthCredentials { - return ClaudeUsageStrategy(dataSource: .oauth, useWebExtras: false) - } - if hasCLI { - return ClaudeUsageStrategy(dataSource: .cli, useWebExtras: false) - } - if hasWebSession { - return ClaudeUsageStrategy(dataSource: .web, useWebExtras: false) - } - return ClaudeUsageStrategy(dataSource: .cli, useWebExtras: false) - } + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .app, + selectedDataSource: selectedDataSource, + webExtrasEnabled: webExtrasEnabled, + hasWebSession: hasWebSession, + hasCLI: hasCLI, + hasOAuthCredentials: hasOAuthCredentials)) + return plan.compatibilityStrategy ?? ClaudeUsageStrategy(dataSource: selectedDataSource, useWebExtras: false) + } - let useWebExtras = selectedDataSource == .cli && webExtrasEnabled && hasWebSession - return ClaudeUsageStrategy(dataSource: selectedDataSource, useWebExtras: useWebExtras) + private static func sourceDataSource(from mode: ProviderSourceMode) -> ClaudeUsageDataSource { + switch mode { + case .auto, .api: + .auto + case .web: + .web + case .cli: + .cli + case .oauth: + .oauth + } } } @@ -128,6 +129,45 @@ public struct ClaudeUsageStrategy: Equatable, Sendable { public let useWebExtras: Bool } +public enum ClaudeOAuthPlanningAvailability { + public static func isAvailable( + runtime: ProviderRuntime, + sourceMode: ProviderSourceMode, + environment: [String: String]) -> Bool + { + ClaudeOAuthFetchStrategy.isPlausiblyAvailable( + runtime: runtime, + sourceMode: sourceMode, + environment: environment) + } +} + +private struct ClaudePlannedFetchStrategy: ProviderFetchStrategy { + let base: any ProviderFetchStrategy + let plannedStep: ClaudeFetchPlanStep + + var id: String { + self.base.id + } + + var kind: ProviderFetchKind { + self.base.kind + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + _ = context + return self.plannedStep.isPlausiblyAvailable + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + try await self.base.fetch(context) + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + self.base.shouldFallback(on: error, context: context) + } +} + struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { let id: String = "claude.oauth" let kind: ProviderFetchKind = .oauth @@ -137,38 +177,42 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { @TaskLocal static var claudeCLIAvailableOverride: Bool? #endif - private func loadNonInteractiveCredentialRecord(_ context: ProviderFetchContext) -> ClaudeOAuthCredentialRecord? { + private func loadNonInteractiveCredentialRecord(environment: [String: String]) -> ClaudeOAuthCredentialRecord? { #if DEBUG if let override = Self.nonInteractiveCredentialRecordOverride { return override } #endif return try? ClaudeOAuthCredentialsStore.loadRecord( - environment: context.env, + environment: environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: true, allowClaudeKeychainRepairWithoutPrompt: false) } - private func isClaudeCLIAvailable() -> Bool { + private func isClaudeCLIAvailable(environment: [String: String]) -> Bool { #if DEBUG if let override = Self.claudeCLIAvailableOverride { return override } #endif - return ClaudeOAuthDelegatedRefreshCoordinator.isClaudeCLIAvailable() + return ClaudeCLIResolver.isAvailable(environment: environment) } - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - let nonInteractiveRecord = self.loadNonInteractiveCredentialRecord(context) + static func isPlausiblyAvailable( + runtime: ProviderRuntime, + sourceMode: ProviderSourceMode, + environment: [String: String]) -> Bool + { + let strategy = ClaudeOAuthFetchStrategy() + let nonInteractiveRecord = strategy.loadNonInteractiveCredentialRecord(environment: environment) let nonInteractiveCredentials = nonInteractiveRecord?.credentials let hasRequiredScopeWithoutPrompt = nonInteractiveCredentials?.scopes.contains("user:profile") == true if hasRequiredScopeWithoutPrompt, nonInteractiveCredentials?.isExpired == false { - // Gate controls refresh attempts, not use of already-valid access tokens. return true } - let hasEnvironmentOAuthToken = !(context.env[ClaudeOAuthCredentialsStore.environmentTokenKey]? + let hasEnvironmentOAuthToken = !(environment[ClaudeOAuthCredentialsStore.environmentTokenKey]? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) - let claudeCLIAvailable = self.isClaudeCLIAvailable() + let claudeCLIAvailable = strategy.isClaudeCLIAvailable(environment: environment) if hasEnvironmentOAuthToken { return true @@ -179,40 +223,33 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { case .codexbar: let refreshToken = nonInteractiveRecord.credentials.refreshToken? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if context.sourceMode == .auto { + if sourceMode == .auto { return !refreshToken.isEmpty } return true case .claudeCLI: - if context.sourceMode == .auto { + if sourceMode == .auto { return claudeCLIAvailable } return true case .environment: - return context.sourceMode != .auto + return sourceMode != .auto } } - guard context.sourceMode == .auto else { return true } + guard sourceMode == .auto else { return true } - // Prefer OAuth in Auto mode only when it’s plausibly usable: - // - we can load credentials without prompting (env / CodexBar cache / credentials file) AND they meet the - // scope requirement, or - // - Claude Code has stored OAuth creds in Keychain and we may be able to bootstrap (one prompt max). - // - // User actions should be able to recover immediately even if a prior background attempt tripped the - // keychain cooldown gate. Clear the cooldown before deciding availability so the fetch path can proceed. let promptPolicyApplicable = ClaudeOAuthKeychainPromptPreference.isApplicable() if promptPolicyApplicable, ProviderInteractionContext.current == .userInitiated { _ = ClaudeOAuthKeychainAccessGate.clearDenied() } let shouldAllowStartupBootstrap = promptPolicyApplicable && - context.runtime == .app && + runtime == .app && ProviderRefreshContext.current == .startup && ProviderInteractionContext.current == .background && ClaudeOAuthKeychainPromptPreference.current() == .onlyOnUserAction && - !ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: context.env) + !ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environment) if shouldAllowStartupBootstrap { return ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() } @@ -225,6 +262,13 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { return ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt() } + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.isPlausiblyAvailable( + runtime: context.runtime, + sourceMode: context.sourceMode, + environment: context.env) + } + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let fetcher = ClaudeUsageFetcher( browserDetection: context.browserDetection, @@ -324,6 +368,7 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { let keepAlive = context.settings?.debugKeepCLISessionsAlive ?? false let fetcher = ClaudeUsageFetcher( browserDetection: browserDetection, + environment: context.env, dataSource: .cli, useWebExtras: self.useWebExtras, manualCookieHeader: self.manualCookieHeader, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift new file mode 100644 index 0000000000..d3e34b46e0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift @@ -0,0 +1,214 @@ +import Foundation + +public struct ClaudeSourcePlanningInput: Equatable, Sendable { + public let runtime: ProviderRuntime + public let selectedDataSource: ClaudeUsageDataSource + public let webExtrasEnabled: Bool + public let hasWebSession: Bool + public let hasCLI: Bool + public let hasOAuthCredentials: Bool + + public init( + runtime: ProviderRuntime, + selectedDataSource: ClaudeUsageDataSource, + webExtrasEnabled: Bool, + hasWebSession: Bool, + hasCLI: Bool, + hasOAuthCredentials: Bool) + { + self.runtime = runtime + self.selectedDataSource = selectedDataSource + self.webExtrasEnabled = webExtrasEnabled + self.hasWebSession = hasWebSession + self.hasCLI = hasCLI + self.hasOAuthCredentials = hasOAuthCredentials + } +} + +public enum ClaudeSourcePlanReason: String, Equatable, Sendable { + case explicitSourceSelection = "explicit-source-selection" + case appAutoPreferredOAuth = "app-auto-preferred-oauth" + case appAutoFallbackCLI = "app-auto-fallback-cli" + case appAutoFallbackWeb = "app-auto-fallback-web" + case cliAutoPreferredWeb = "cli-auto-preferred-web" + case cliAutoFallbackCLI = "cli-auto-fallback-cli" +} + +public struct ClaudeFetchPlanStep: Equatable, Sendable { + public let dataSource: ClaudeUsageDataSource + public let inclusionReason: ClaudeSourcePlanReason + public let isPlausiblyAvailable: Bool + + public init( + dataSource: ClaudeUsageDataSource, + inclusionReason: ClaudeSourcePlanReason, + isPlausiblyAvailable: Bool) + { + self.dataSource = dataSource + self.inclusionReason = inclusionReason + self.isPlausiblyAvailable = isPlausiblyAvailable + } +} + +public struct ClaudeFetchPlan: Equatable, Sendable { + public let input: ClaudeSourcePlanningInput + public let orderedSteps: [ClaudeFetchPlanStep] + + public init(input: ClaudeSourcePlanningInput, orderedSteps: [ClaudeFetchPlanStep]) { + self.input = input + self.orderedSteps = orderedSteps + } + + public var availableSteps: [ClaudeFetchPlanStep] { + self.orderedSteps.filter(\.isPlausiblyAvailable) + } + + public var isNoSourceAvailable: Bool { + self.availableSteps.isEmpty + } + + public var preferredStep: ClaudeFetchPlanStep? { + switch self.input.selectedDataSource { + case .auto: + self.availableSteps.first + case .oauth, .web, .cli: + self.orderedSteps.first + } + } + + public var executionSteps: [ClaudeFetchPlanStep] { + switch self.input.selectedDataSource { + case .auto: + self.availableSteps + case .oauth, .web, .cli: + self.orderedSteps + } + } + + public var compatibilityStrategy: ClaudeUsageStrategy? { + guard let preferredStep else { return nil } + let useWebExtras = self.input.runtime == .app + && preferredStep.dataSource == .cli + && self.input.webExtrasEnabled + return ClaudeUsageStrategy( + dataSource: preferredStep.dataSource, + useWebExtras: useWebExtras) + } + + public var orderLabel: String { + self.orderedSteps.map(\.dataSource.sourceLabel).joined(separator: "→") + } + + public func debugLines() -> [String] { + var lines = ["planner_order=\(self.orderLabel)"] + lines.append("planner_selected=\(self.preferredStep?.dataSource.rawValue ?? "none")") + lines.append("planner_no_source=\(self.isNoSourceAvailable)") + for step in self.orderedSteps { + let availability = step.isPlausiblyAvailable ? "available" : "unavailable" + lines.append( + "planner_step.\(step.dataSource.rawValue)=\(availability) reason=\(step.inclusionReason.rawValue)") + } + return lines + } +} + +public enum ClaudeCLIResolver { + #if DEBUG + @TaskLocal static var resolvedBinaryPathOverrideForTesting: String? + + public static var currentResolvedBinaryPathOverrideForTesting: String? { + self.resolvedBinaryPathOverrideForTesting + } + + public static func withResolvedBinaryPathOverrideForTesting( + _ path: String?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$resolvedBinaryPathOverrideForTesting.withValue(path) { + try await operation() + } + } + #endif + + public static func resolvedBinaryPath( + environment: [String: String] = ProcessInfo.processInfo.environment) + -> String? + { + #if DEBUG + if let override = self.resolvedBinaryPathOverrideForTesting { + return FileManager.default.isExecutableFile(atPath: override) ? override : nil + } + #endif + if let override = environment["CLAUDE_CLI_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return FileManager.default.isExecutableFile(atPath: override) ? override : nil + } + + return BinaryLocator.resolveClaudeBinary( + env: environment, + loginPATH: LoginShellPathCache.shared.current) + } + + public static func isAvailable(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + self.resolvedBinaryPath(environment: environment) != nil + } +} + +public enum ClaudeSourcePlanner { + public static func resolve(input: ClaudeSourcePlanningInput) -> ClaudeFetchPlan { + ClaudeFetchPlan(input: input, orderedSteps: self.makeSteps(input: input)) + } + + private static func makeSteps(input: ClaudeSourcePlanningInput) -> [ClaudeFetchPlanStep] { + switch input.selectedDataSource { + case .auto: + switch input.runtime { + case .app: + [ + self.step(.oauth, reason: .appAutoPreferredOAuth, input: input), + self.step(.cli, reason: .appAutoFallbackCLI, input: input), + self.step(.web, reason: .appAutoFallbackWeb, input: input), + ] + case .cli: + [ + self.step(.web, reason: .cliAutoPreferredWeb, input: input), + self.step(.cli, reason: .cliAutoFallbackCLI, input: input), + ] + } + case .oauth: + [self.step(.oauth, reason: .explicitSourceSelection, input: input)] + case .web: + [self.step(.web, reason: .explicitSourceSelection, input: input)] + case .cli: + [self.step(.cli, reason: .explicitSourceSelection, input: input)] + } + } + + private static func step( + _ dataSource: ClaudeUsageDataSource, + reason: ClaudeSourcePlanReason, + input: ClaudeSourcePlanningInput) -> ClaudeFetchPlanStep + { + ClaudeFetchPlanStep( + dataSource: dataSource, + inclusionReason: reason, + isPlausiblyAvailable: self.isPlausiblyAvailable(dataSource, input: input)) + } + + private static func isPlausiblyAvailable( + _ dataSource: ClaudeUsageDataSource, + input: ClaudeSourcePlanningInput) -> Bool + { + switch dataSource { + case .auto: + false + case .oauth: + input.hasOAuthCredentials + case .web: + input.hasWebSession + case .cli: + input.hasCLI + } + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 590146bd7d..7d2078e68b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -48,6 +48,10 @@ public struct ClaudeStatusProbe: Sendable { public var timeout: TimeInterval = 20.0 public var keepCLISessionsAlive: Bool = false private static let log = CodexBarLog.logger(LogCategories.claudeProbe) + #if DEBUG + public typealias FetchOverride = @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot + @TaskLocal static var fetchOverride: FetchOverride? + #endif public init(claudeBinary: String = "claude", timeout: TimeInterval = 20.0, keepCLISessionsAlive: Bool = false) { self.claudeBinary = claudeBinary @@ -55,15 +59,44 @@ public struct ClaudeStatusProbe: Sendable { self.keepCLISessionsAlive = keepCLISessionsAlive } + #if DEBUG + public static var currentFetchOverrideForTesting: FetchOverride? { + self.fetchOverride + } + + public static func withFetchOverrideForTesting( + _ override: FetchOverride?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$fetchOverride.withValue(override) { + try await operation() + } + } + + public static func withFetchOverrideForTesting( + _ override: FetchOverride?, + operation: () async -> T) async -> T + { + await self.$fetchOverride.withValue(override) { + await operation() + } + } + #endif + public func fetch() async throws -> ClaudeStatusSnapshot { let resolved = Self.resolvedBinaryPath(binaryName: self.claudeBinary) - guard Self.isBinaryAvailable(resolved) else { + guard let resolved, Self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } // Run commands sequentially through a shared Claude session to avoid warm-up churn. let timeout = self.timeout let keepAlive = self.keepCLISessionsAlive + #if DEBUG + if let override = Self.fetchOverride { + return try await override(resolved, timeout, keepAlive) + } + #endif do { var usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout) if !Self.usageOutputLooksRelevant(usage) { @@ -212,18 +245,24 @@ public struct ClaudeStatusProbe: Sendable { return self.extractIdentity(usageText: usageClean, statusText: statusClean) } - public static func fetchIdentity(timeout: TimeInterval = 12.0) async throws -> ClaudeAccountIdentity { - let resolved = self.resolvedBinaryPath(binaryName: "claude") - guard self.isBinaryAvailable(resolved) else { + public static func fetchIdentity( + timeout: TimeInterval = 12.0, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ClaudeAccountIdentity + { + let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) + guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } let statusText = try await Self.capture(subcommand: "/status", binary: resolved, timeout: timeout) return Self.parseIdentity(usageText: nil, statusText: statusText) } - public static func touchOAuthAuthPath(timeout: TimeInterval = 8) async throws { - let resolved = self.resolvedBinaryPath(binaryName: "claude") - guard self.isBinaryAvailable(resolved) else { + public static func touchOAuthAuthPath( + timeout: TimeInterval = 8, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws + { + let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) + guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } do { @@ -245,8 +284,10 @@ public struct ClaudeStatusProbe: Sendable { } } - public static func isClaudeBinaryAvailable() -> Bool { - let resolved = self.resolvedBinaryPath(binaryName: "claude") + public static func isClaudeBinaryAvailable( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) return self.isBinaryAvailable(resolved) } @@ -679,6 +720,14 @@ public struct ClaudeStatusProbe: Sendable { } } + #if DEBUG + public static func _replaceDumpsForTesting(_ dumps: [String]) async { + await MainActor.run { + self.recentDumps = dumps + } + } + #endif + private static func extractUsageErrorJSON(text: String) -> String? { let pattern = #"Failed\s*to\s*load\s*usage\s*data:\s*(\{.*\})"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { @@ -726,15 +775,19 @@ public struct ClaudeStatusProbe: Sendable { // MARK: - Process helpers - private static func resolvedBinaryPath(binaryName: String) -> String { - let env = ProcessInfo.processInfo.environment - return BinaryLocator.resolveClaudeBinary(env: env, loginPATH: LoginShellPathCache.shared.current) - ?? TTYCommandRunner.which(binaryName) - ?? binaryName + private static func resolvedBinaryPath( + binaryName: String, + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + if binaryName.contains("/") { + return binaryName + } + return ClaudeCLIResolver.resolvedBinaryPath(environment: environment) } - private static func isBinaryAvailable(_ binaryPathOrName: String) -> Bool { - FileManager.default.isExecutableFile(atPath: binaryPathOrName) + private static func isBinaryAvailable(_ binaryPathOrName: String?) -> Bool { + guard let binaryPathOrName else { return false } + return FileManager.default.isExecutableFile(atPath: binaryPathOrName) || TTYCommandRunner.which(binaryPathOrName) != nil } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 4e7209c010..0d66b733f3 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -59,6 +59,7 @@ public enum ClaudeUsageError: LocalizedError, Sendable { public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private let environment: [String: String] + private let runtime: ProviderRuntime private let dataSource: ClaudeUsageDataSource private let oauthKeychainPromptCooldownEnabled: Bool private let allowBackgroundDelegatedRefresh: Bool @@ -141,7 +142,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { @TaskLocal static var fetchOAuthUsageOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? @TaskLocal static var delegatedRefreshAttemptOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? @TaskLocal static var hasCachedCredentialsOverride: Bool? #endif @@ -153,6 +155,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { public init( browserDetection: BrowserDetection, environment: [String: String] = ProcessInfo.processInfo.environment, + runtime: ProviderRuntime = .app, dataSource: ClaudeUsageDataSource = .oauth, oauthKeychainPromptCooldownEnabled: Bool = false, allowBackgroundDelegatedRefresh: Bool = false, @@ -163,6 +166,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { { self.browserDetection = browserDetection self.environment = environment + self.runtime = runtime self.dataSource = dataSource self.oauthKeychainPromptCooldownEnabled = oauthKeychainPromptCooldownEnabled self.allowBackgroundDelegatedRefresh = allowBackgroundDelegatedRefresh @@ -424,7 +428,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { policy: delegatedPromptPolicy, allowBackgroundDelegatedRefresh: self.allowBackgroundDelegatedRefresh) - let delegatedOutcome = await Self.attemptDelegatedRefresh() + let delegatedOutcome = await Self.attemptDelegatedRefresh(environment: self.environment) Self.log.info( "Claude OAuth delegated refresh attempted", metadata: [ @@ -556,14 +560,19 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private static func attemptDelegatedRefresh( now: Date = Date(), - timeout: TimeInterval = 15) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome + timeout: TimeInterval = 15, + environment: [String: String] = ProcessInfo.processInfo.environment) + async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome { #if DEBUG if let override = delegatedRefreshAttemptOverride { - return await override(now, timeout) + return await override(now, timeout, environment) } #endif - return await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: now, timeout: timeout) + return await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: now, + timeout: timeout, + environment: environment) } private static func delegatedRefreshOutcomeLabel(_ outcome: ClaudeOAuthDelegatedRefreshCoordinator @@ -764,11 +773,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private func loadViaPTY(model: String, timeout: TimeInterval = 10) async throws -> ClaudeUsageSnapshot { - guard TTYCommandRunner.which("claude") != nil else { + guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else { throw ClaudeUsageError.claudeNotInstalled } let probe = ClaudeStatusProbe( - claudeBinary: "claude", + claudeBinary: claudeBinary, timeout: timeout, keepCLISessionsAlive: self.keepCLISessionsAlive) let snap = try await probe.fetch() @@ -876,83 +885,79 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } extension ClaudeUsageFetcher { - public func loadLatestUsage(model: String = "sonnet") async throws -> ClaudeUsageSnapshot { - switch self.dataSource { - case .auto: - let oauthCreds: ClaudeOAuthCredentials? - let oauthProbeError: Error? - do { - oauthCreds = try ClaudeOAuthCredentialsStore.load( - environment: self.environment, - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) - oauthProbeError = nil - } catch { - oauthCreds = nil - oauthProbeError = error + private func makeAutoFetchPlan() async -> ClaudeFetchPlan { + let hasWebSession = + if let header = self.manualCookieHeader { + ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header) + } else { + ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) } + let hasCLI = ClaudeCLIResolver.isAvailable(environment: self.environment) + return ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: self.runtime, + selectedDataSource: .auto, + webExtrasEnabled: self.useWebExtras, + hasWebSession: hasWebSession, + hasCLI: hasCLI, + hasOAuthCredentials: ClaudeOAuthPlanningAvailability.isAvailable( + runtime: self.runtime, + sourceMode: .auto, + environment: self.environment))) + } - let hasOAuthCredentials = oauthCreds?.scopes.contains("user:profile") ?? false - let hasWebSession = - if let header = self.manualCookieHeader { - ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header) - } else { - ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) - } - let hasCLI = TTYCommandRunner.which("claude") != nil + private func execute(step: ClaudeFetchPlanStep, model: String) async throws -> ClaudeUsageSnapshot { + switch step.dataSource { + case .oauth: + var snapshot = try await self.loadViaOAuth(allowDelegatedRetry: true) + snapshot = await self.applyWebExtrasIfNeeded(to: snapshot) + return snapshot + case .web: + return try await self.loadViaWebAPI() + case .cli: + var snapshot = try await self.loadViaPTY(model: model, timeout: 10) + snapshot = await self.applyWebExtrasIfNeeded(to: snapshot) + return snapshot + case .auto: + throw ClaudeUsageError.parseFailed("Planner emitted invalid auto execution step.") + } + } - var autoDecisionMetadata: [String: String] = [ - "hasOAuthCredentials": "\(hasOAuthCredentials)", - "hasWebSession": "\(hasWebSession)", - "hasCLI": "\(hasCLI)", + public func loadLatestUsage(model: String = "sonnet") async throws -> ClaudeUsageSnapshot { + switch self.dataSource { + case .auto: + let plan = await self.makeAutoFetchPlan() + var planMetadata: [String: String] = [ + "plannerOrder": plan.orderLabel, + "selected": plan.preferredStep?.dataSource.rawValue ?? "none", + "noSourceAvailable": "\(plan.isNoSourceAvailable)", + "webExtrasEnabled": "\(self.useWebExtras)", "oauthReadStrategy": ClaudeOAuthKeychainReadStrategyPreference.current().rawValue, ] - if let oauthCreds { - autoDecisionMetadata["oauthProbe"] = "success" - for (key, value) in oauthCreds.diagnosticsMetadata(now: Date()) { - autoDecisionMetadata[key] = value - } - } else if let oauthProbeError { - autoDecisionMetadata["oauthProbe"] = "failure" - autoDecisionMetadata["oauthProbeError"] = Self.oauthCredentialProbeErrorLabel(oauthProbeError) - } else { - autoDecisionMetadata["oauthProbe"] = "none" - } - - func logAutoDecision(selected: String) { - var metadata = autoDecisionMetadata - metadata["selected"] = selected - Self.log.debug("Claude auto source decision", metadata: metadata) + for (index, step) in plan.orderedSteps.enumerated() { + planMetadata["step\(index)"] = + "\(step.dataSource.rawValue):\(step.inclusionReason.rawValue):\(step.isPlausiblyAvailable)" } + Self.log.debug("Claude auto source planner", metadata: planMetadata) - if hasOAuthCredentials { - logAutoDecision(selected: "oauth") - var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap - } - if hasWebSession { - logAutoDecision(selected: "web") - return try await self.loadViaWebAPI() - } - if hasCLI { + let executionSteps = plan.executionSteps + for (index, step) in executionSteps.enumerated() { do { - logAutoDecision(selected: "cli") - var snap = try await self.loadViaPTY(model: model, timeout: 10) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap + return try await self.execute(step: step, model: model) } catch { - Self.log.debug( - "Claude auto source CLI path failed; falling back to OAuth", - metadata: [ - "errorType": String(describing: type(of: error)), - ]) + if index < executionSteps.count - 1 { + Self.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 } } - logAutoDecision(selected: "oauthFallback") - var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap + throw ClaudeUsageError.parseFailed("Claude planner produced no executable steps.") case .oauth: var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) snap = await self.applyWebExtrasIfNeeded(to: snap) diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 41caed0e16..04a7fd6f9b 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -1,15 +1,39 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore -@Suite +@Suite(.serialized) struct ClaudeBaselineCharacterizationTests { + private func makeStubClaudeCLI() throws -> String { + let sample = """ + Current session + 12% used (Resets 11am) + Current week (all models) + 40% used (Resets Nov 21) + Current week (Sonnet only) + 5% used (Resets Nov 21) + Account: user@example.com + Org: Example Org + """ + let script = """ + #!/bin/sh + cat <<'EOF' + \(sample) + EOF + """ + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-stub-\(UUID().uuidString)") + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + private func makeContext( runtime: ProviderRuntime, sourceMode: ProviderSourceMode, + env: [String: String] = [:], settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext { - let env: [String: String] = [:] let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( runtime: runtime, @@ -28,26 +52,193 @@ struct ClaudeBaselineCharacterizationTests { private func strategyIDs( runtime: ProviderRuntime, sourceMode: ProviderSourceMode, + env: [String: String] = [:], settings: ProviderSettingsSnapshot? = nil) async -> [String] { let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) - let context = self.makeContext(runtime: runtime, sourceMode: sourceMode, settings: settings) + let context = self.makeContext(runtime: runtime, sourceMode: sourceMode, env: env, settings: settings) let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) return strategies.map(\.id) } + private func fetchOutcome( + runtime: ProviderRuntime, + sourceMode: ProviderSourceMode, + env: [String: String] = [:], + settings: ProviderSettingsSnapshot? = nil) async -> ProviderFetchOutcome + { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: runtime, sourceMode: sourceMode, env: env, settings: settings) + return await descriptor.fetchPlan.fetchOutcome(context: context, provider: .claude) + } + + private func withNoOAuthCredentials(operation: () async throws -> T) async rethrows -> T { + let missingCredentialsURL = FileManager.default.temporaryDirectory + .appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json") + return try await KeychainCacheStore.withServiceOverrideForTesting("rat-110-\(UUID().uuidString)") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: nil) + { + try await operation() + } + } + } + } + } + } + } + @Test func appAutoPipelineOrder_isOAuthThenCLIThenWeb() async { - let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto) + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: true, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let env = [ + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "CLAUDE_CLI_PATH": "/usr/bin/true", + ] + let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto, env: env, settings: settings) #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) } @Test func cliAutoPipelineOrder_isWebThenCLI() async { - let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: .auto) + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let env = [ + "CLAUDE_CLI_PATH": "/usr/bin/true", + ] + let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: .auto, env: env, settings: settings) #expect(strategyIDs == ["claude.web", "claude.cli"]) } + @Test + func autoPipelineRecordsUnavailablePlannedStepsWhenPlannerHasNoExecutableSource() async { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: true, + cookieSource: .off, + manualCookieHeader: nil)) + let env = [ + "CLAUDE_CLI_PATH": "/definitely/missing/claude", + ] + + await self.withNoOAuthCredentials { + let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto, env: env, settings: settings) + #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) + + let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + + switch outcome.result { + case let .failure(error as ProviderFetchError): + switch error { + case let .noAvailableStrategy(provider): + #expect(provider == .claude) + } + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + case let .success(result): + Issue.record("Unexpected success: \(result.sourceLabel)") + } + } + } + + @Test + func appAutoPipelineRetainsOAuthBootstrapStrategyAtStartup() async { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + + await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthKeychainAccessGate.resetForTesting() + } + + await self.withNoOAuthCredentials { + let strategyIDs = await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await self.strategyIDs(runtime: .app, sourceMode: .auto, settings: settings) + } + } + } + #expect(strategyIDs.first == "claude.oauth") + #expect(strategyIDs.contains("claude.oauth")) + } + } + } + + @Test + func autoPipelineCLIUsesPlannedEnvironmentForExecution() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let stubCLIPath = try self.makeStubClaudeCLI() + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + + await self.withNoOAuthCredentials { + let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws + -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == stubCLIPath) + return ClaudeStatusSnapshot( + sessionPercentLeft: 88, + weeklyPercentLeft: 60, + opusPercentLeft: 95, + accountEmail: "user@example.com", + accountOrganization: "Example Org", + loginMethod: nil, + primaryResetDescription: "Resets 11am", + secondaryResetDescription: "Resets Nov 21", + opusResetDescription: "Resets Nov 21", + rawText: "stub") + } + let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.cli") + #expect(result.sourceLabel == "claude") + #expect(result.usage.primary?.usedPercent == 12) + #expect(result.usage.secondary?.usedPercent == 40) + #expect(result.usage.tertiary?.usedPercent == 5) + #expect(result.usage.identity?.accountEmail == "user@example.com") + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + } + } + } + @Test(arguments: [ (ProviderSourceMode.oauth, "claude.oauth"), (ProviderSourceMode.cli, "claude.cli"), diff --git a/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift b/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift new file mode 100644 index 0000000000..8fe5b0c7ca --- /dev/null +++ b/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift @@ -0,0 +1,459 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeDebugDiagnosticsTests { + private func makeCredentialsData( + accessToken: String, + expiresAt: Date, + refreshToken: String? = nil) -> Data + { + let refreshTokenLine = if let refreshToken { + """ + "refreshToken": "\(refreshToken)", + """ + } else { + "" + } + let json = """ + { + "accessToken": "\(accessToken)", + \(refreshTokenLine) + "expiresAt": \(Int(expiresAt.timeIntervalSince1970 * 1000)), + "scopes": ["user:profile"] + } + """ + return Data(json.utf8) + } + + @Test + func debugLogUsesPlannerDerivedOrderAndReasons() async throws { + let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)" + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let credentialsURL = tempDir.appendingPathComponent("credentials.json") + let credsJSON = """ + { + "claudeAiOauth": { + "accessToken": "oauth-token", + "expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)), + "scopes": ["user:profile"] + } + } + """ + try Data(credsJSON.utf8).write(to: credentialsURL) + + let store = try await MainActor.run { () -> UsageStore in + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.claudeUsageDataSource = .auto + settings.claudeCookieSource = .manual + settings.claudeCookieHeader = "sessionKey=sk-ant-session-token" + + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + let text = await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) { + await store.debugLog(for: .claude) + } + } + } + } + + #expect(text.contains("planner_order=oauth→cli→web")) + #expect(text.contains("planner_selected=oauth")) + #expect(text.contains("planner_no_source=false")) + #expect(text.contains("planner_step.oauth=available reason=app-auto-preferred-oauth")) + #expect(text.contains("planner_step.cli=")) + #expect(text.contains("reason=app-auto-fallback-cli")) + #expect(text.contains("planner_step.web=available reason=app-auto-fallback-web")) + #expect(!text.contains("auto_heuristic=")) + } + + @Test + func debugLogReportsNoPlannerSelectedSourceWhenAutoHasNoAvailableSources() async throws { + let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)" + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") + + let store = try await MainActor.run { () -> UsageStore in + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.claudeUsageDataSource = .auto + settings.claudeCookieSource = .off + settings.claudeWebExtrasEnabled = true + + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + let text = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") { + await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: nil) + { + await store.debugLog(for: .claude) + } + } + } + } + } + } + } + + #expect(text.contains("planner_selected=none")) + #expect(text.contains("planner_no_source=true")) + #expect(text.contains("No planner-selected Claude source.")) + #expect(!text.contains("web_extras=enabled")) + } + + @Test + func debugClaudeDumpReturnsRecordedParseDumps() async { + await ClaudeStatusProbe._replaceDumpsForTesting([ + "dump one", + "dump two", + ]) + + let store = await MainActor.run { + UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: SettingsStore( + userDefaults: UserDefaults(), + configStore: testConfigStore(suiteName: "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)"), + zaiTokenStore: NoopZaiTokenStore())) + } + let text = await store.debugClaudeDump() + + #expect(text.contains("dump one")) + #expect(text.contains("dump two")) + #expect(!text.contains("planner_order=")) + await ClaudeStatusProbe._replaceDumpsForTesting([]) + } + + @Test + func debugLogUsesRuntimeOAuthAvailabilityForTokenAccountRouting() async throws { + let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)" + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") + + let store = try await MainActor.run { () -> UsageStore in + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.claudeUsageDataSource = .auto + settings.claudeCookieSource = .off + settings.addTokenAccount( + provider: .claude, + label: "OAuth Account", + token: "sk-ant-oat-test-token") + + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + let text = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") { + await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: nil) + { + await store.debugLog(for: .claude) + } + } + } + } + } + } + } + + #expect(text.contains("planner_selected=oauth")) + #expect(text.contains("hasOAuthCredentials=true")) + #expect(text.contains("oauthCredentialOwner=environment")) + #expect(text.contains("oauthCredentialSource=environment")) + #expect(!text.contains("planner_selected=none")) + } + + @Test + func debugLogPreservesCLIProbeOverridesAcrossDetachedWork() async throws { + let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)" + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") + let store = try await MainActor.run { () -> UsageStore in + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.claudeUsageDataSource = .cli + settings.claudeCookieSource = .off + + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + let fetchOverride: ClaudeStatusProbe.FetchOverride = { resolved, _, _ in + #expect(resolved == "/usr/bin/true") + return ClaudeStatusSnapshot( + sessionPercentLeft: 76, + weeklyPercentLeft: 55, + opusPercentLeft: nil, + accountEmail: "cli@example.com", + accountOrganization: "CLI Org", + loginMethod: "cli", + primaryResetDescription: "Mar 7 at 1pm", + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "probe raw") + } + + let text = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: nil) + { + await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + await store.debugLog(for: .claude) + } + } + } + } + } + } + } + } + + #expect(text.contains("planner_selected=cli")) + #expect(text.contains("session_left=76.0 weekly_left=55.0")) + #expect(text.contains("email cli@example.com")) + } + + @Test + func debugLogUsesUserInitiatedInteractionForOAuthPromptGate() async throws { + let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)" + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") + let securityData = self.makeCredentialsData( + accessToken: "user-initiated-oauth", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() + deniedStore.deniedUntil = Date(timeIntervalSinceNow: 300) + + let store = try await MainActor.run { () -> UsageStore in + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.claudeUsageDataSource = .auto + settings.claudeCookieSource = .off + + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + let text = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") { + await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + await ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) { + await ClaudeOAuthKeychainPromptPreference + .withTaskOverrideForTesting(.onlyOnUserAction) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.debugLog(for: .claude) + } + } + } + } + } + } + } + } + } + } + + #expect(text.contains("planner_selected=oauth")) + #expect(text.contains("hasOAuthCredentials=true")) + } + + @Test + func debugLogInvalidatesCachedPlannerOutputWhenClaudeSettingsChange() async throws { + let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)" + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") + let storeAndSettings = try await MainActor.run { () -> (UsageStore, SettingsStore) in + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.claudeUsageDataSource = .cli + settings.claudeCookieSource = .off + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + return (store, settings) + } + let store = storeAndSettings.0 + let settings = storeAndSettings.1 + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, _, _ in + ClaudeStatusSnapshot( + sessionPercentLeft: 80, + weeklyPercentLeft: 60, + opusPercentLeft: nil, + accountEmail: "cache@example.com", + accountOrganization: nil, + loginMethod: "cli", + primaryResetDescription: "Mar 7 at 1pm", + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "probe raw") + } + + let first = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: nil) + { + await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + await store.debugLog(for: .claude) + } + } + } + } + } + } + } + } + + await MainActor.run { + settings.claudeUsageDataSource = .auto + } + await Task.yield() + await Task.yield() + + let second = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") { + await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: nil) + { + await store.debugLog(for: .claude) + } + } + } + } + } + } + } + + #expect(first.contains("planner_selected=cli")) + #expect(second.contains("planner_selected=none")) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift index bfae65a0fd..2fd42efabf 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -484,63 +484,67 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { defer { KeychainCacheStore.setTestStoreForTesting(false) } try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - } - - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let securityData = self.makeCredentialsData( - accessToken: "security-sync", - expiresAt: Date(timeIntervalSinceNow: 3600)) - final class ReadCounter: @unchecked Sendable { - var count = 0 + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - let securityReadCalls = ReadCounter() - func loadWithPreflight( - _ outcome: KeychainAccessPreflight.Outcome) throws -> ClaudeOAuthCredentials - { - let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in - outcome + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-sync", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class ReadCounter: @unchecked Sendable { + var count = 0 } - return try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( - preflightOverride, - operation: { - try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - try ProviderInteractionContext.$current.withValue(.background) { - try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .dynamic { _ in - securityReadCalls.count += 1 - return securityData - }) { - try ClaudeOAuthCredentialsStore.load( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) + let securityReadCalls = ReadCounter() + + func loadWithPreflight( + _ outcome: KeychainAccessPreflight.Outcome) throws -> ClaudeOAuthCredentials + { + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + outcome + } + return try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference + .withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityReadCalls.count += 1 + return securityData + }) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } } - } + } } - } - }) - } + }) + } - let first = try loadWithPreflight(.allowed) - #expect(first.accessToken == "security-sync") - #expect(securityReadCalls.count == 1) + let first = try loadWithPreflight(.allowed) + #expect(first.accessToken == "security-sync") + #expect(securityReadCalls.count == 1) - let second = try loadWithPreflight(.interactionRequired) - #expect(second.accessToken == "security-sync") - #expect(securityReadCalls.count == 1) + let second = try loadWithPreflight(.interactionRequired) + #expect(second.accessToken == "security-sync") + #expect(securityReadCalls.count == 1) + } } } } @@ -556,63 +560,66 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { defer { KeychainCacheStore.setTestStoreForTesting(false) } try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - } - - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let securityData = self.makeCredentialsData( - accessToken: "security-sync-only-on-user-action", - expiresAt: Date(timeIntervalSinceNow: 3600)) - final class ReadCounter: @unchecked Sendable { - var count = 0 - } - let securityReadCalls = ReadCounter() - let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in - .allowed + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - func load(_ interaction: ProviderInteraction) throws -> ClaudeOAuthCredentials { - try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( - preflightOverride, - operation: { - try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( - .onlyOnUserAction) + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-sync-only-on-user-action", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class ReadCounter: @unchecked Sendable { + var count = 0 + } + let securityReadCalls = ReadCounter() + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .allowed + } + + func load(_ interaction: ProviderInteraction) throws -> ClaudeOAuthCredentials { + try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) { - try ProviderInteractionContext.$current.withValue(interaction) { - try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .dynamic { _ in - securityReadCalls.count += 1 - return securityData - }) { - try ClaudeOAuthCredentialsStore.load( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) - } + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ProviderInteractionContext.$current.withValue(interaction) { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityReadCalls.count += 1 + return securityData + }) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + } } } - } - }) - } + }) + } - let first = try load(.userInitiated) - #expect(first.accessToken == "security-sync-only-on-user-action") - #expect(securityReadCalls.count == 1) + let first = try load(.userInitiated) + #expect(first.accessToken == "security-sync-only-on-user-action") + #expect(securityReadCalls.count == 1) - let second = try load(.background) - #expect(second.accessToken == "security-sync-only-on-user-action") - #expect(securityReadCalls.count == 1) + let second = try load(.background) + #expect(second.accessToken == "security-sync-only-on-user-action") + #expect(securityReadCalls.count == 1) + } } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 5fd258eb08..30b13f149d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -637,51 +637,55 @@ struct ClaudeOAuthCredentialsStoreTests { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - defer { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) - } + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - let cachedData = self.makeCredentialsData( - accessToken: "cached-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - KeychainCacheStore.store( - key: cacheKey, - entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) - - let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "ref1") - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(fingerprint) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) - - let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) - #expect(first.accessToken == "cached-token") - - ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() - let keychainData = self.makeCredentialsData( - accessToken: "keychain-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) - - let second = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) - #expect(second.accessToken == "cached-token") - - switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { - case let .found(entry): - let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) - #expect(parsed.accessToken == "cached-token") - default: - #expect(Bool(false)) + let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1") + let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: cachedData, + fingerprint: fingerprint, + operation: { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + }) + #expect(first.accessToken == "cached-token") + + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: fingerprint, + operation: { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + }) + #expect(second.accessToken == "cached-token") + + switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + default: + #expect(Bool(false)) + } + } } } @@ -690,85 +694,13 @@ struct ClaudeOAuthCredentialsStoreTests { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - defer { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) - } - - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - let cachedData = self.makeCredentialsData( - accessToken: "cached-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - KeychainCacheStore.store( - key: cacheKey, - entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) - - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting( - ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "ref1")) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) - - let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) - #expect(first.accessToken == "cached-token") - - ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() - - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting( - ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 2, - createdAt: 2, - persistentRefHash: "ref2")) - let expiredKeychainData = self.makeCredentialsData( - accessToken: "expired-keychain-token", - expiresAt: Date(timeIntervalSinceNow: -3600)) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(expiredKeychainData) - - let second = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) - #expect(second.accessToken == "cached-token") - - switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { - case let .found(entry): - let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) - #expect(parsed.accessToken == "cached-token") - default: - #expect(Bool(false)) - } - } - - @Test - func respectsPromptCooldownGateWhenDisabledPrompting() throws { - let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { - KeychainCacheStore.setTestStoreForTesting(true) - defer { KeychainCacheStore.setTestStoreForTesting(false) } - - ClaudeOAuthKeychainAccessGate.resetForTesting() - defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } - - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() defer { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) } let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) @@ -779,33 +711,31 @@ struct ClaudeOAuthCredentialsStoreTests { key: cacheKey, entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting( - ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: cachedData, + fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, - persistentRefHash: "ref1")) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) - - let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + persistentRefHash: "ref1"), + operation: { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + }) #expect(first.accessToken == "cached-token") ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() - ClaudeOAuthKeychainAccessGate.recordDenied(now: Date()) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting( - ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + let expiredKeychainData = self.makeCredentialsData( + accessToken: "expired-keychain-token", + expiresAt: Date(timeIntervalSinceNow: -3600)) + let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: expiredKeychainData, + fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 2, createdAt: 2, - persistentRefHash: "ref2")) - let keychainData = self.makeCredentialsData( - accessToken: "keychain-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) - - let second = try ClaudeOAuthCredentialsStore.load( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) + persistentRefHash: "ref2"), + operation: { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + }) #expect(second.accessToken == "cached-token") switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { @@ -819,6 +749,82 @@ struct ClaudeOAuthCredentialsStoreTests { } } + @Test + func respectsPromptCooldownGateWhenDisabledPrompting() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) + + let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: cachedData, + fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1"), + operation: { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + }) + #expect(first.accessToken == "cached-token") + + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() + ClaudeOAuthKeychainAccessGate.recordDenied(now: Date()) + + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2"), + operation: { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + }) + #expect(second.accessToken == "cached-token") + + switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + default: + #expect(Bool(false)) + } + } + } + } + } + } + @Test func syncFromClaudeKeychainWithoutPrompt_respectsBackoffInBackground() { ProviderInteractionContext.$current.withValue(.background) { diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index 3ab8f87c9c..cf45fb6271 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -29,6 +29,47 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { return Data(json.utf8) } + private func withCoordinatorOverrides( + isolateState: Bool = true, + cliAvailable: Bool? = nil, + touchAuthPath: (@Sendable (TimeInterval, [String: String]) async throws -> Void)? = nil, + keychainFingerprint: (@Sendable () -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?)? = nil, + operation: () async throws -> T) async rethrows -> T + { + if isolateState { + return try await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting( + keychainFingerprint) + { + try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting( + cliAvailable) + { + try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting( + touchAuthPath) + { + try await operation() + } + } + } + } + } + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting( + keychainFingerprint) + { + try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(cliAvailable) { + try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting( + touchAuthPath) + { + try await operation() + } + } + } + } + @Test func cooldownPreventsRepeatedAttempts() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() @@ -44,20 +85,22 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1")) - ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { box.fingerprint } - - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) - ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in - box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 2, - createdAt: 2, - persistentRefHash: "ref2") - } - let start = Date(timeIntervalSince1970: 10000) - let first = await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: start, timeout: 0.1) - let second = await ClaudeOAuthDelegatedRefreshCoordinator - .attempt(now: start.addingTimeInterval(30), timeout: 0.1) + let (first, second) = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in + box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2") + }, + keychainFingerprint: { box.fingerprint }, + operation: { + let first = await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: start, timeout: 0.1) + let second = await ClaudeOAuthDelegatedRefreshCoordinator + .attempt(now: start.addingTimeInterval(30), timeout: 0.1) + return (first, second) + }) #expect(first == .attemptedSucceeded) #expect(second == .skippedByCooldown) @@ -68,11 +111,11 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(false) - - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 20000), - timeout: 0.1) + let outcome = await self.withCoordinatorOverrides(cliAvailable: false, operation: { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 20000), + timeout: 0.1) + }) #expect(outcome == .cliUnavailable) } @@ -92,46 +135,91 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { modifiedAt: 10, createdAt: 10, persistentRefHash: "refA")) - ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { box.fingerprint } - - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) - ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in - box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 11, - createdAt: 11, - persistentRefHash: "refB") - } - - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 30000), - timeout: 0.1) + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in + box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 11, + createdAt: 11, + persistentRefHash: "refB") + }, + keychainFingerprint: { box.fingerprint }, + operation: { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 30000), + timeout: 0.1) + }) #expect(outcome == .attemptedSucceeded) } @Test - func failedAuthTouchReportsAttemptedFailed() async { + func failedAuthTouchReportsAttemptedFailed() async throws { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { - ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 20, - createdAt: 20, - persistentRefHash: "refX") - } + let outcome = try await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in + throw StubError.failed + }, + keychainFingerprint: { + ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 20, + createdAt: 20, + persistentRefHash: "refX") + }, + operation: { + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 40000), + timeout: 0.1) + } + } + }) - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) - ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in - throw StubError.failed + guard case let .attemptedFailed(message) = outcome else { + Issue.record("Expected .attemptedFailed outcome") + return } + #expect(message.contains("failed")) + } + + @Test + func environmentCLIOverrideAvoidsCliUnavailable() async throws { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 40000), - timeout: 0.1) + let stubCLI = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let script = "#!/bin/sh\nexit 0\n" + try script.write(to: stubCLI, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stubCLI.path) + + let outcome = try await self.withCoordinatorOverrides( + touchAuthPath: { _, environment in + #expect(environment["CLAUDE_CLI_PATH"] == stubCLI.path) + throw StubError.failed + }, + keychainFingerprint: { + ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 20, + createdAt: 20, + persistentRefHash: "ref-env") + }, + operation: { + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 45000), + timeout: 0.1, + environment: ["CLAUDE_CLI_PATH": stubCLI.path]) + } + } + }) guard case let .attemptedFailed(message) = outcome else { - Issue.record("Expected .attemptedFailed outcome") + Issue.record("Expected env-provided CLI override to reach touch attempt") return } #expect(message.contains("failed")) @@ -200,26 +288,34 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1")) - ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { box.fingerprint } - - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) - ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in - counter.increment() - await gate.markStarted() - await gate.waitRelease() - box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 2, - createdAt: 2, - persistentRefHash: "ref2") - } - let now = Date(timeIntervalSince1970: 50000) - async let first = ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: now, timeout: 2) - await gate.waitStarted() - async let second = ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: now.addingTimeInterval(30), timeout: 2) + let outcomes = await self.withCoordinatorOverrides( + isolateState: false, + cliAvailable: true, + touchAuthPath: { _, _ in + counter.increment() + await gate.markStarted() + await gate.waitRelease() + box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2") + }, + keychainFingerprint: { box.fingerprint }, + operation: { + let first = Task { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: now, timeout: 2) + } + await gate.waitStarted() + let second = Task { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: now.addingTimeInterval(30), + timeout: 2) + } - await gate.release() - let outcomes = await [first, second] + await gate.release() + return await [first.value, second.value] + }) #expect(outcomes.allSatisfy { $0 == .attemptedSucceeded }) #expect(counter.count == 1) @@ -242,24 +338,26 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { } } let fingerprintCounter = CounterBox() - ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { - fingerprintCounter.increment() - return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "framework-fingerprint") - } - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) - ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in } - let securityData = self.makeCredentialsData( accessToken: "security-token-a", expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.data(securityData)) - defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 60000), - timeout: 0.1) + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in }, + keychainFingerprint: { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "framework-fingerprint") + }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 60000), + timeout: 0.1) + } + }) guard case .attemptedFailed = outcome else { Issue.record("Expected .attemptedFailed outcome") @@ -304,15 +402,6 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { self.lock.unlock() } } - let fingerprintCounter = CounterBox() - ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { - fingerprintCounter.increment() - return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 11, - createdAt: 11, - persistentRefHash: "framework-fingerprint") - } - let beforeData = self.makeCredentialsData( accessToken: "security-token-before", expiresAt: Date(timeIntervalSinceNow: -60)) @@ -320,17 +409,28 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { accessToken: "security-token-after", expiresAt: Date(timeIntervalSinceNow: 3600)) let dataBox = DataBox(data: beforeData) - - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) - ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in - dataBox.store(afterData) - } - ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in dataBox.load() }) - defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } - - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61000), - timeout: 0.1) + let fingerprintCounter = CounterBox() + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in + dataBox.store(afterData) + }, + keychainFingerprint: { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 11, + createdAt: 11, + persistentRefHash: "framework-fingerprint") + }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in dataBox.load() }) + { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61000), + timeout: 0.1) + } + }) #expect(outcome == .attemptedSucceeded) #expect(fingerprintCounter.count < 1) @@ -372,30 +472,32 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { self.lock.unlock() } } - let fingerprintCounter = CounterBox() - ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { - fingerprintCounter.increment() - return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 21, - createdAt: 21, - persistentRefHash: "framework-fingerprint") - } - let afterData = self.makeCredentialsData( accessToken: "security-token-after-baseline-miss", expiresAt: Date(timeIntervalSinceNow: 3600)) let dataBox = DataBox(data: nil) - - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) - ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in - dataBox.store(afterData) - } - ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in dataBox.load() }) - defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } - - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61500), - timeout: 0.1) + let fingerprintCounter = CounterBox() + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in + dataBox.store(afterData) + }, + keychainFingerprint: { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 21, + createdAt: 21, + persistentRefHash: "framework-fingerprint") + }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in dataBox.load() }) + { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61500), + timeout: 0.1) + } + }) guard case .attemptedFailed = outcome else { Issue.record("Expected .attemptedFailed outcome when baseline is unavailable") @@ -426,18 +528,21 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { let securityData = self.makeCredentialsData( accessToken: "security-should-not-be-read", expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) - ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in } - ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in - securityReadCounter.increment() - return securityData - }) - defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } - let outcome = await KeychainAccessGate.withTaskOverrideForTesting(true) { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 62000), - timeout: 0.1) - } + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in + securityReadCounter.increment() + return securityData + }) { + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 62000), + timeout: 0.1) + } + } + }) guard case .attemptedFailed = outcome else { Issue.record("Expected .attemptedFailed outcome") diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift index fa25a78e6f..3041a6607d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift @@ -121,10 +121,12 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests { } let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in - _ = await delegatedCounter.increment() - return .attemptedSucceeded - } + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = + { _, _, _ in + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } let snapshot = try await ClaudeOAuthKeychainPromptPreference .withTaskOverrideForTesting(.onlyOnUserAction) { @@ -229,13 +231,16 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests { let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in - // Simulate Claude CLI writing fresh credentials after the delegated refresh touch. - keychainOverrideStore.data = freshData - keychainOverrideStore.fingerprint = stubFingerprint - _ = await delegatedCounter.increment() - return .attemptedSucceeded - } + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = + { _, _, _ in + // Simulate Claude CLI writing fresh credentials after the delegated refresh + // touch. + keychainOverrideStore.data = freshData + keychainOverrideStore.fingerprint = stubFingerprint + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } let snapshot = try await ClaudeOAuthKeychainPromptPreference .withTaskOverrideForTesting(.always) { @@ -331,12 +336,14 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests { let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in - keychainOverrideStore.data = freshData - keychainOverrideStore.fingerprint = stubFingerprint - _ = await delegatedCounter.increment() - return .attemptedSucceeded - } + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = + { _, _, _ in + keychainOverrideStore.data = freshData + keychainOverrideStore.fingerprint = stubFingerprint + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } do { _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index 6bebf67a46..e31e106f66 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -19,9 +19,11 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { } } - private func makeContext(sourceMode: ProviderSourceMode) -> ProviderFetchContext { - let env: [String: String] = [:] - return ProviderFetchContext( + private func makeContext( + sourceMode: ProviderSourceMode, + env: [String: String] = [:]) -> ProviderFetchContext + { + ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: false, @@ -192,6 +194,27 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { } } + @Test + func autoModeExpiredClaudeCLICreds_envProvidedCLIOverride_returnsAvailable() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let cliURL = tempDir.appendingPathComponent("claude") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: cliURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + + let context = self.makeContext( + sourceMode: .auto, + env: ["CLAUDE_CLI_PATH": cliURL.path]) + let strategy = ClaudeOAuthFetchStrategy() + let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await strategy.isAvailable(context) + } + + #expect(available == true) + } + @Test func autoMode_experimental_reader_ignoresPromptPolicyCooldownGate() async { let context = self.makeContext(sourceMode: .auto) diff --git a/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift b/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift new file mode 100644 index 0000000000..6eaf145ee2 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct ClaudeSourcePlannerTests { + @Test + func appAutoPlanPreservesOrderedStepsAndReasons() { + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .app, + selectedDataSource: .auto, + webExtrasEnabled: false, + hasWebSession: true, + hasCLI: true, + hasOAuthCredentials: true)) + + #expect(plan.orderedSteps.map(\.dataSource) == [.oauth, .cli, .web]) + #expect(plan.orderedSteps.map(\.inclusionReason) == [ + .appAutoPreferredOAuth, + .appAutoFallbackCLI, + .appAutoFallbackWeb, + ]) + #expect(plan.availableSteps.map(\.dataSource) == [.oauth, .cli, .web]) + #expect(plan.preferredStep?.dataSource == .oauth) + } + + @Test + func cliAutoPlanPreservesOrderedStepsAndReasons() { + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .cli, + selectedDataSource: .auto, + webExtrasEnabled: false, + hasWebSession: true, + hasCLI: true, + hasOAuthCredentials: false)) + + #expect(plan.orderedSteps.map(\.dataSource) == [.web, .cli]) + #expect(plan.orderedSteps.map(\.inclusionReason) == [ + .cliAutoPreferredWeb, + .cliAutoFallbackCLI, + ]) + #expect(plan.preferredStep?.dataSource == .web) + } + + @Test + func explicitModePlanIsSingleStep() { + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .app, + selectedDataSource: .cli, + webExtrasEnabled: true, + hasWebSession: false, + hasCLI: true, + hasOAuthCredentials: false)) + + #expect(plan.orderedSteps.count == 1) + #expect(plan.orderedSteps.first?.dataSource == .cli) + #expect(plan.orderedSteps.first?.inclusionReason == .explicitSourceSelection) + #expect(plan.compatibilityStrategy == ClaudeUsageStrategy(dataSource: .cli, useWebExtras: true)) + } + + @Test + func appAutoCLIFallbackReportsWebExtrasLikeRuntime() { + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .app, + selectedDataSource: .auto, + webExtrasEnabled: true, + hasWebSession: false, + hasCLI: true, + hasOAuthCredentials: false)) + + #expect(plan.preferredStep?.dataSource == .cli) + #expect(plan.compatibilityStrategy == ClaudeUsageStrategy(dataSource: .cli, useWebExtras: true)) + } + + @Test + func noSourcePlannerOutputIsDeterministic() { + let input = ClaudeSourcePlanningInput( + runtime: .app, + selectedDataSource: .auto, + webExtrasEnabled: false, + hasWebSession: false, + hasCLI: false, + hasOAuthCredentials: false) + let plan = ClaudeSourcePlanner.resolve(input: input) + + #expect(plan.orderedSteps.map(\.dataSource) == [.oauth, .cli, .web]) + #expect(plan.availableSteps.isEmpty) + #expect(plan.isNoSourceAvailable) + #expect(plan.preferredStep == nil) + #expect(plan.executionSteps.isEmpty) + #expect(plan.debugLines() == [ + "planner_order=oauth→cli→web", + "planner_selected=none", + "planner_no_source=true", + "planner_step.oauth=unavailable reason=app-auto-preferred-oauth", + "planner_step.cli=unavailable reason=app-auto-fallback-cli", + "planner_step.web=unavailable reason=app-auto-fallback-web", + ]) + } +} diff --git a/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift b/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift new file mode 100644 index 0000000000..6cb42814c4 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeUsageDelegatedRefreshEnvironmentTests { + @Test + func oauthDelegatedRetry_passesFetcherEnvironmentToDelegatedRefresh() async throws { + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: ["CLAUDE_CLI_PATH": "/tmp/rat110-env-claude"], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + let delegatedOverride: (@Sendable (Date, TimeInterval, [String: String]) async + -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, environment in + #expect(environment["CLAUDE_CLI_PATH"] == "/tmp/rat110-env-claude") + return .cliUnavailable + } + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + + do { + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride, + operation: { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredsOverride, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + }) + } + } + Issue.record("Expected delegated retry to fail when the override reports CLI unavailable") + } catch let error as ClaudeUsageError { + guard case let .oauthFailed(message) = error else { + Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)") + return + } + #expect(message.contains("Claude CLI is not available")) + } + } +} diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 608ca4190d..834f41851e 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -60,7 +60,8 @@ struct ClaudeUsageTests { let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() return .attemptedSucceeded } @@ -114,7 +115,8 @@ struct ClaudeUsageTests { do { let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() return .attemptedSucceeded } @@ -165,8 +167,8 @@ struct ClaudeUsageTests { dataSource: .oauth, oauthKeychainPromptCooldownEnabled: true) - let delegatedOverride: (@Sendable (Date, TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator - .Outcome)? = { _, _ in + let delegatedOverride: (@Sendable (Date, TimeInterval, [String: String]) async + -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() return .cliUnavailable } @@ -226,8 +228,8 @@ struct ClaudeUsageTests { oauthKeychainPromptCooldownEnabled: true) let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } - let delegatedOverride: (@Sendable (Date, TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator - .Outcome)? = { _, _ in + let delegatedOverride: (@Sendable (Date, TimeInterval, [String: String]) async + -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() return .attemptedFailed("no-change") } @@ -286,7 +288,8 @@ struct ClaudeUsageTests { let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() return .attemptedSucceeded } @@ -337,7 +340,8 @@ struct ClaudeUsageTests { let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() return .attemptedSucceeded } @@ -442,7 +446,8 @@ struct ClaudeUsageTests { let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() return .attemptedSucceeded } @@ -913,7 +918,6 @@ struct ClaudeAutoFetcherCharacterizationTests { Current session 93% left Dec 23 at 4:00PM - Current week (all models) 79% left Dec 29 at 11:00PM @@ -1019,6 +1023,7 @@ struct ClaudeAutoFetcherCharacterizationTests { ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", ], + runtime: .app, dataSource: .auto, manualCookieHeader: "sessionKey=sk-ant-session-token") @@ -1045,14 +1050,15 @@ struct ClaudeAutoFetcherCharacterizationTests { } @Test - func autoPrefersWebBeforeCLIWhenOAuthUnavailable() async throws { + func appRuntimeAutoPrefersCLIBeforeWebWhenOAuthUnavailable() async throws { let cliLogURL = FileManager.default.temporaryDirectory .appendingPathComponent("claude-auto-web-log-\(UUID().uuidString).txt") let log = InvocationLog(url: cliLogURL) let fakeCLI = try Self.makeFakeClaudeCLI(logURL: cliLogURL) let fetcher = ClaudeUsageFetcher( browserDetection: BrowserDetection(cacheTTL: 0), - environment: [:], + environment: ["CLAUDE_CLI_PATH": fakeCLI.path], + runtime: .app, dataSource: .auto, manualCookieHeader: "sessionKey=sk-ant-session-token") @@ -1108,6 +1114,72 @@ struct ClaudeAutoFetcherCharacterizationTests { }, operation: { let snapshot = try await fetcher.loadLatestUsage(model: "sonnet") + #expect(snapshot.rawText != nil) + #expect(log.contents().contains("usage")) + }) + } + } + } + + @Test + func cliRuntimeAutoPrefersWebBeforeCLIWhenOAuthUnavailable() async throws { + let cliLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-auto-cli-runtime-web-log-\(UUID().uuidString).txt") + let log = InvocationLog(url: cliLogURL) + let fakeCLI = try Self.makeFakeClaudeCLI(logURL: cliLogURL) + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: ["CLAUDE_CLI_PATH": fakeCLI.path], + runtime: .cli, + dataSource: .auto, + manualCookieHeader: "sessionKey=sk-ant-session-token") + + try await self.withClaudeCLIPath(fakeCLI.path) { + try await self.withNoOAuthCredentials { + try await self.withClaudeWebStub(handler: { request in + let url = try #require(request.url) + switch url.path { + case "/api/organizations": + return Self.makeJSONResponse( + url: url, + body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#) + case "/api/organizations/org-123/usage": + let body = """ + { + "five_hour": { "utilization": 11, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 22, "resets_at": "2025-12-29T23:00:00.000Z" }, + "seven_day_opus": { "utilization": 33 } + } + """ + return Self.makeJSONResponse(url: url, body: body) + case "/api/account": + let body = """ + { + "email_address": "web@example.com", + "memberships": [ + { + "organization": { + "uuid": "org-123", + "name": "Test Org", + "rate_limit_tier": "claude_max", + "billing_type": "stripe" + } + } + ] + } + """ + return Self.makeJSONResponse(url: url, body: body) + case "/api/organizations/org-123/overage_spend_limit": + let body = """ + {"monthly_credit_limit":5000,"currency":"USD","used_credits":1200,"is_enabled":true} + """ + return Self.makeJSONResponse(url: url, body: body) + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) + } + }, operation: { + let snapshot = try await fetcher.loadLatestUsage(model: "sonnet") + #expect(snapshot.primary.usedPercent == 11) #expect(snapshot.secondary?.usedPercent == 22) #expect(snapshot.opus?.usedPercent == 33) @@ -1118,6 +1190,48 @@ struct ClaudeAutoFetcherCharacterizationTests { } } } + + @Test + func appRuntimeAutoFailsDeterministicallyWhenPlannerHasNoExecutableSteps() async { + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: ["CLAUDE_CLI_PATH": "/definitely/missing/claude"], + runtime: .app, + dataSource: .auto, + manualCookieHeader: "foo=bar") + + await self.withNoOAuthCredentials { + do { + _ = try await fetcher.loadLatestUsage(model: "sonnet") + Issue.record("Expected app auto no-source fetch to fail.") + } catch let error as ClaudeUsageError { + #expect(error.localizedDescription.contains("Claude planner produced no executable steps.")) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + } + + @Test + func cliRuntimeAutoFailsDeterministicallyWhenPlannerHasNoExecutableSteps() async { + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: ["CLAUDE_CLI_PATH": "/definitely/missing/claude"], + runtime: .cli, + dataSource: .auto, + manualCookieHeader: "foo=bar") + + await self.withNoOAuthCredentials { + do { + _ = try await fetcher.loadLatestUsage(model: "sonnet") + Issue.record("Expected CLI auto no-source fetch to fail.") + } catch let error as ClaudeUsageError { + #expect(error.localizedDescription.contains("Claude planner produced no executable steps.")) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + } } final class ClaudeAutoFetcherStubURLProtocol: URLProtocol { @@ -1166,7 +1280,8 @@ extension ClaudeUsageTests { let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } let delegatedOverride: (@Sendable ( Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() return .attemptedSucceeded } From 995b4762a79e16e2ac19e8d1d7b47c3179a564ab Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 8 Mar 2026 15:20:34 +0530 Subject: [PATCH 0005/1239] Fix Claude debug cache invalidation after settings changes --- Sources/CodexBar/UsageStore.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 2577552561..9f5b6bb514 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -57,7 +57,6 @@ extension UsageStore { Task { @MainActor [weak self] in guard let self else { return } self.observeSettingsChanges() - guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.probeLogs = [:] guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.startTimer() From 607d956a2204d63b51a13fcb514925a657f848ac Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 8 Mar 2026 15:28:35 +0530 Subject: [PATCH 0006/1239] Preserve Claude debug test overrides in detached logs --- Sources/CodexBar/UsageStore.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 9f5b6bb514..4b653f97de 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1191,9 +1191,11 @@ extension UsageStore { guard let claudeDebugConfiguration else { return "Claude debug log configuration unavailable" } - return await Self.debugClaudeLog( - browserDetection: browserDetection, - configuration: claudeDebugConfiguration) + return await claudeDebugExecutionContext.apply { + await Self.debugClaudeLog( + browserDetection: browserDetection, + configuration: claudeDebugConfiguration) + } case .zai: let resolution = ProviderTokenResolver.zaiResolution() let hasAny = resolution != nil From 457b8af8cb272aeaf41fd1a65d95f10399a29da1 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 8 Mar 2026 15:36:44 +0530 Subject: [PATCH 0007/1239] Preserve Claude keychain gate overrides in debug logs --- Sources/CodexBar/UsageStore.swift | 19 ++++++++++++++----- .../ClaudeOAuthKeychainAccessGate.swift | 14 ++++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 4b653f97de..812ebecb39 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1300,6 +1300,7 @@ extension UsageStore { let keychainServiceOverride: String? let credentialsURLOverride: URL? let testingOverrides: ClaudeOAuthCredentialsStore.TestingOverridesSnapshot + let keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.DeniedUntilStore? let cliPathOverride: String? let statusFetchOverride: ClaudeStatusProbe.FetchOverride? #endif @@ -1313,11 +1314,18 @@ extension UsageStore { .withCredentialsURLOverrideForTesting(self.credentialsURLOverride) { await ClaudeOAuthCredentialsStore .withTestingOverridesSnapshotForTask(self.testingOverrides) { - await ClaudeCLIResolver - .withResolvedBinaryPathOverrideForTesting(self.cliPathOverride) { - await ClaudeStatusProbe - .withFetchOverrideForTesting(self.statusFetchOverride) { - await operation() + await ClaudeOAuthKeychainAccessGate + .withDeniedUntilStoreOverrideForTesting(self + .keychainDeniedUntilStoreOverride) + { + await ClaudeCLIResolver + .withResolvedBinaryPathOverrideForTesting(self + .cliPathOverride) + { + await ClaudeStatusProbe + .withFetchOverrideForTesting(self.statusFetchOverride) { + await operation() + } } } } @@ -1339,6 +1347,7 @@ extension UsageStore { keychainServiceOverride: KeychainCacheStore.currentServiceOverrideForTesting, credentialsURLOverride: ClaudeOAuthCredentialsStore.currentCredentialsURLOverrideForTesting, testingOverrides: ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask, + keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.currentDeniedUntilStoreOverrideForTesting, cliPathOverride: ClaudeCLIResolver.currentResolvedBinaryPathOverrideForTesting, statusFetchOverride: ClaudeStatusProbe.currentFetchOverrideForTesting) #else diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainAccessGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainAccessGate.swift index 451a8b23e5..6d43c45d6e 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainAccessGate.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainAccessGate.swift @@ -14,8 +14,10 @@ public enum ClaudeOAuthKeychainAccessGate { private static let cooldownInterval: TimeInterval = 60 * 60 * 6 @TaskLocal private static var taskOverrideShouldAllowPromptForTesting: Bool? #if DEBUG - final class DeniedUntilStore: @unchecked Sendable { - var deniedUntil: Date? + public final class DeniedUntilStore: @unchecked Sendable { + public var deniedUntil: Date? + + public init() {} } @TaskLocal private static var taskDeniedUntilStoreOverrideForTesting: DeniedUntilStore? @@ -106,7 +108,7 @@ public enum ClaudeOAuthKeychainAccessGate { } } - static func withDeniedUntilStoreOverrideForTesting( + public static func withDeniedUntilStoreOverrideForTesting( _ store: DeniedUntilStore?, operation: () throws -> T) rethrows -> T { @@ -115,7 +117,7 @@ public enum ClaudeOAuthKeychainAccessGate { } } - static func withDeniedUntilStoreOverrideForTesting( + public static func withDeniedUntilStoreOverrideForTesting( _ store: DeniedUntilStore?, operation: () async throws -> T) async rethrows -> T { @@ -124,6 +126,10 @@ public enum ClaudeOAuthKeychainAccessGate { } } + public static var currentDeniedUntilStoreOverrideForTesting: DeniedUntilStore? { + self.taskDeniedUntilStoreOverrideForTesting + } + public static func resetForTesting() { self.lock.withLock { state in // Keep deterministic during tests: avoid re-loading UserDefaults written by unrelated code paths. From 95652546a9852cbcd5f8644ae24db30cc2d57fdf Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 8 Mar 2026 15:54:10 +0530 Subject: [PATCH 0008/1239] Add keychain prompt mode and read strategy overrides for testing in UsageStore --- Sources/CodexBar/UsageStore.swift | 28 ++++++++++++++----- .../ClaudeOAuthKeychainPromptMode.swift | 8 ++++-- .../ClaudeOAuthKeychainReadStrategy.swift | 8 ++++-- .../ClaudeDebugDiagnosticsTests.swift | 10 ++++--- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 812ebecb39..f0eff99c70 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1301,6 +1301,8 @@ extension UsageStore { 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 @@ -1318,13 +1320,23 @@ extension UsageStore { .withDeniedUntilStoreOverrideForTesting(self .keychainDeniedUntilStoreOverride) { - await ClaudeCLIResolver - .withResolvedBinaryPathOverrideForTesting(self - .cliPathOverride) - { - await ClaudeStatusProbe - .withFetchOverrideForTesting(self.statusFetchOverride) { - await operation() + await ClaudeOAuthKeychainPromptPreference + .withTaskOverrideForTesting(self.keychainPromptModeOverride) { + await ClaudeOAuthKeychainReadStrategyPreference + .withTaskOverrideForTesting(self + .keychainReadStrategyOverride) + { + await ClaudeCLIResolver + .withResolvedBinaryPathOverrideForTesting(self + .cliPathOverride) + { + await ClaudeStatusProbe + .withFetchOverrideForTesting(self + .statusFetchOverride) + { + await operation() + } + } } } } @@ -1348,6 +1360,8 @@ extension UsageStore { credentialsURLOverride: ClaudeOAuthCredentialsStore.currentCredentialsURLOverrideForTesting, testingOverrides: ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask, keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.currentDeniedUntilStoreOverrideForTesting, + keychainPromptModeOverride: ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting, + keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategyPreference.currentTaskOverrideForTesting, cliPathOverride: ClaudeCLIResolver.currentResolvedBinaryPathOverrideForTesting, statusFetchOverride: ClaudeStatusProbe.currentFetchOverrideForTesting) #else diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift index 3ae21771bc..0ef6064d95 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift @@ -58,7 +58,7 @@ public enum ClaudeOAuthKeychainPromptPreference { } #if DEBUG - static func withTaskOverrideForTesting( + public static func withTaskOverrideForTesting( _ mode: ClaudeOAuthKeychainPromptMode?, operation: () throws -> T) rethrows -> T { @@ -67,7 +67,7 @@ public enum ClaudeOAuthKeychainPromptPreference { } } - static func withTaskOverrideForTesting( + public static func withTaskOverrideForTesting( _ mode: ClaudeOAuthKeychainPromptMode?, operation: () async throws -> T) async rethrows -> T { @@ -75,5 +75,9 @@ public enum ClaudeOAuthKeychainPromptPreference { try await operation() } } + + public static var currentTaskOverrideForTesting: ClaudeOAuthKeychainPromptMode? { + self.taskOverride + } #endif } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift index 7f766d1e4d..83b9b0fceb 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift @@ -25,7 +25,7 @@ public enum ClaudeOAuthKeychainReadStrategyPreference { } #if DEBUG - static func withTaskOverrideForTesting( + public static func withTaskOverrideForTesting( _ strategy: ClaudeOAuthKeychainReadStrategy?, operation: () throws -> T) rethrows -> T { @@ -34,7 +34,7 @@ public enum ClaudeOAuthKeychainReadStrategyPreference { } } - static func withTaskOverrideForTesting( + public static func withTaskOverrideForTesting( _ strategy: ClaudeOAuthKeychainReadStrategy?, operation: () async throws -> T) async rethrows -> T { @@ -42,5 +42,9 @@ public enum ClaudeOAuthKeychainReadStrategyPreference { try await operation() } } + + public static var currentTaskOverrideForTesting: ClaudeOAuthKeychainReadStrategy? { + self.taskOverride + } #endif } diff --git a/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift b/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift index 8fe5b0c7ca..84e8aaaab8 100644 --- a/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift +++ b/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift @@ -12,17 +12,19 @@ struct ClaudeDebugDiagnosticsTests { { let refreshTokenLine = if let refreshToken { """ - "refreshToken": "\(refreshToken)", + "refreshToken": "\(refreshToken)", """ } else { "" } let json = """ { - "accessToken": "\(accessToken)", + "claudeAiOauth": { + "accessToken": "\(accessToken)", \(refreshTokenLine) - "expiresAt": \(Int(expiresAt.timeIntervalSince1970 * 1000)), - "scopes": ["user:profile"] + "expiresAt": \(Int(expiresAt.timeIntervalSince1970 * 1000)), + "scopes": ["user:profile"] + } } """ return Data(json.utf8) From f357d48aa291ed4fe0c13457b219bab1ccfe1f76 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 8 Mar 2026 16:32:32 +0530 Subject: [PATCH 0009/1239] Enhance ClaudeProviderDescriptor to conditionally check availability based on source mode; add test for CLI strategy availability when planner marks it as unavailable. --- .../Claude/ClaudeProviderDescriptor.swift | 6 ++++-- .../ClaudeBaselineCharacterizationTests.swift | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 1786b07284..3918ef04f3 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -155,8 +155,10 @@ private struct ClaudePlannedFetchStrategy: ProviderFetchStrategy { } func isAvailable(_ context: ProviderFetchContext) async -> Bool { - _ = context - return self.plannedStep.isPlausiblyAvailable + if context.sourceMode == .auto { + return self.plannedStep.isPlausiblyAvailable + } + return await self.base.isAvailable(context) } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 04a7fd6f9b..f3be9d59c6 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -125,6 +125,24 @@ struct ClaudeBaselineCharacterizationTests { #expect(strategyIDs == ["claude.web", "claude.cli"]) } + @Test + func explicitCLIPipelineAttemptsStrategyEvenWhenPlannerMarksCLIUnavailable() async { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .cli, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let env = [ + "CLAUDE_CLI_PATH": "/definitely/missing/claude", + ] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .cli, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["claude.cli"]) + #expect(await strategies[0].isAvailable(context)) + } + @Test func autoPipelineRecordsUnavailablePlannedStepsWhenPlannerHasNoExecutableSource() async { let settings = ProviderSettingsSnapshot.make(claude: .init( From c841f2a540282bedb11c7e74694768ac4114fbbd Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 8 Mar 2026 16:48:01 +0530 Subject: [PATCH 0010/1239] Restore Claude CLI path fallback --- .../Claude/ClaudeSourcePlanner.swift | 22 ++++++++---- .../ClaudeBaselineCharacterizationTests.swift | 34 +++++++++---------- .../ClaudeSourcePlannerTests.swift | 19 +++++++++++ 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift index d3e34b46e0..e440e57ef2 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift @@ -131,7 +131,8 @@ public enum ClaudeCLIResolver { #endif public static func resolvedBinaryPath( - environment: [String: String] = ProcessInfo.processInfo.environment) + environment: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current) -> String? { #if DEBUG @@ -139,15 +140,22 @@ public enum ClaudeCLIResolver { return FileManager.default.isExecutableFile(atPath: override) ? override : nil } #endif - if let override = environment["CLAUDE_CLI_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - return FileManager.default.isExecutableFile(atPath: override) ? override : nil + + var normalizedEnvironment = environment + if let override = environment["CLAUDE_CLI_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines) { + if override.isEmpty { + normalizedEnvironment.removeValue(forKey: "CLAUDE_CLI_PATH") + } else { + normalizedEnvironment["CLAUDE_CLI_PATH"] = override + if FileManager.default.isExecutableFile(atPath: override) { + return override + } + } } return BinaryLocator.resolveClaudeBinary( - env: environment, - loginPATH: LoginShellPathCache.shared.current) + env: normalizedEnvironment, + loginPATH: loginPATH) } public static func isAvailable(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index f3be9d59c6..c6f04f548d 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -150,28 +150,28 @@ struct ClaudeBaselineCharacterizationTests { webExtrasEnabled: true, cookieSource: .off, manualCookieHeader: nil)) - let env = [ - "CLAUDE_CLI_PATH": "/definitely/missing/claude", - ] + let env = ["CLAUDE_CLI_PATH": "/definitely/missing/claude"] await self.withNoOAuthCredentials { - let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto, env: env, settings: settings) - #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") { + let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto, env: env, settings: settings) + #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) - let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) - switch outcome.result { - case let .failure(error as ProviderFetchError): - switch error { - case let .noAvailableStrategy(provider): - #expect(provider == .claude) + switch outcome.result { + case let .failure(error as ProviderFetchError): + switch error { + case let .noAvailableStrategy(provider): + #expect(provider == .claude) + } + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + case let .success(result): + Issue.record("Unexpected success: \(result.sourceLabel)") } - case let .failure(error): - Issue.record("Unexpected failure: \(error)") - case let .success(result): - Issue.record("Unexpected success: \(result.sourceLabel)") } } } diff --git a/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift b/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift index 6eaf145ee2..c06a6fdf85 100644 --- a/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift +++ b/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift @@ -97,4 +97,23 @@ struct ClaudeSourcePlannerTests { "planner_step.web=unavailable reason=app-auto-fallback-web", ]) } + + @Test + func cliResolverFallsBackToPATHWhenClaudeCLIPathOverrideIsInvalid() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let binaryURL = tempDir.appendingPathComponent("claude") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: binaryURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryURL.path) + + let resolved = ClaudeCLIResolver.resolvedBinaryPath( + environment: [ + "CLAUDE_CLI_PATH": "/definitely/missing/claude", + "PATH": tempDir.path, + ], + loginPATH: nil) + + #expect(resolved == binaryURL.path) + } } From 02bd8e8d0f760a6f935a58af91407e990100951b Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 10 Mar 2026 14:22:50 +0530 Subject: [PATCH 0011/1239] Improve Claude CLI/PTY status parsing for compact labels and resets --- .../Providers/Claude/ClaudeStatusProbe.swift | 21 ++++++++----------- Tests/CodexBarTests/StatusProbeTests.swift | 12 +++++++++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index f5e768624e..9dade0c4af 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -104,15 +104,11 @@ public struct ClaudeStatusProbe: Sendable { self.normalizedData = Data(normalized.utf8) } - func contains(_ needle: Data) -> Bool { - self.normalizedData.range(of: needle) != nil + func contains(_ needle: String) -> Bool { + self.normalizedData.range(of: Data(needle.utf8)) != nil } } - private static let weeklyLabelNeedle = Data("current week".utf8) - private static let opusLabelNeedle = Data("opus".utf8) - private static let sonnetLabelNeedle = Data("sonnet".utf8) - public static func parse(text: String, statusText: String? = nil) throws -> ClaudeStatusSnapshot { let clean = TextParsing.stripANSICodes(text) let statusClean = statusText.map(TextParsing.stripANSICodes) @@ -151,9 +147,9 @@ public struct ClaudeStatusProbe: Sendable { // may omit the weekly panel entirely, and we should treat that as "unavailable" rather than guessing. let compactContext = usagePanelText.lowercased().filter { !$0.isWhitespace } let hasWeeklyLabel = - labelContext.contains(Self.weeklyLabelNeedle) + labelContext.contains("currentweek") || compactContext.contains("currentweek") - let hasOpusLabel = labelContext.contains(Self.opusLabelNeedle) || labelContext.contains(Self.sonnetLabelNeedle) + let hasOpusLabel = labelContext.contains("opus") || labelContext.contains("sonnet") if sessionPct == nil || (hasWeeklyLabel && weeklyPct == nil) || (hasOpusLabel && opusPct == nil) { let ordered = self.allPercents(usagePanelText) @@ -459,7 +455,7 @@ public struct ClaudeStatusProbe: Sendable { for candidate in window { let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = self.normalizedForLabelSearch(trimmed) - if normalized.hasPrefix("current "), !normalized.contains(label) { break } + if normalized.hasPrefix("current"), !normalized.contains(label) { break } if let reset = self.resetFromLine(candidate) { return reset } } } @@ -480,9 +476,7 @@ public struct ClaudeStatusProbe: Sendable { } private static func normalizedForLabelSearch(_ text: String) -> String { - text.lowercased() - .split(whereSeparator: { $0.isWhitespace }) - .joined(separator: " ") + String(text.lowercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains)) } /// Capture all "Resets ..." strings to surface in the menu. @@ -579,6 +573,9 @@ public struct ClaudeStatusProbe: Sendable { guard var raw = text?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } raw = raw.replacingOccurrences(of: #"(?i)^resets?:?\s*"#, with: "", options: .regularExpression) raw = raw.replacingOccurrences(of: " at ", with: " ", options: .caseInsensitive) + raw = raw.replacingOccurrences(of: #"(?i)\b([A-Za-z]{3})(\d)"#, with: "$1 $2", options: .regularExpression) + raw = raw.replacingOccurrences(of: #",(\d)"#, with: ", $1", options: .regularExpression) + raw = raw.replacingOccurrences(of: #"(?i)(\d)at(?=\d)"#, with: "$1 ", options: .regularExpression) raw = raw.replacingOccurrences( of: #"(?<=\d)\.(\d{2})\b"#, with: ":$1", diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index b999019e45..0977352477 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -340,6 +340,8 @@ struct StatusProbeTests { #expect(snap.sessionPercentLeft == 94) #expect(snap.weeklyPercentLeft == 96) #expect(snap.opusPercentLeft == 99) + #expect(snap.secondaryResetDescription == "ResetsFeb12at1:29pm(Asia/Calcutta)") + #expect(snap.opusResetDescription == "ResetsFeb12at1:29pm(Asia/Calcutta)") } @Test @@ -520,6 +522,16 @@ struct StatusProbeTests { #expect(parsedDateTime == dateExpected) } + @Test + func parsesClaudeResetWithCompactDateAndTimeNoSpaces() throws { + let now = Date(timeIntervalSince1970: 1_773_097_200) // Mar 10, 2026 12:00:00 UTC + let parsed = ClaudeStatusProbe.parseResetDate(from: "ResetsMar13at12:30pm(Asia/Calcutta)", now: now) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Calcutta")) + let expected = calendar.date(from: DateComponents(year: 2026, month: 3, day: 13, hour: 12, minute: 30)) + #expect(parsed == expected) + } + @Test func liveCodexStatus() async throws { guard ProcessInfo.processInfo.environment["LIVE_CODEX_STATUS"] == "1" else { return } From eb023649bb21785da13f2affb9189f2fa0cba844 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 10 Mar 2026 14:32:07 +0530 Subject: [PATCH 0012/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5323656d4..25ab84535e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Providers & Usage - Claude: surface rate-limit errors from the CLI `/usage` probe with a user-friendly message, and harden "Failed to load usage data" matching against whitespace-collapsed output. +- Claude: restore weekly/Sonnet reset parsing from whitespace-collapsed CLI `/usage` output so reset times and pace details still appear after CLI fallback. - Codex: add historical pace risk forecasting and backfill, gate pace computation by display mode, and handle zero-usage days in historical data (#482, supersedes #438). Thanks @tristanmanchester! - OpenRouter: add provider support with credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas! - Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! From 8deef1ef3e749048b174071e750bcb9c8c6516d2 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 10 Mar 2026 17:13:55 +0530 Subject: [PATCH 0013/1239] Fix dashboard day keys and flaky tests --- .../OpenAIDashboardScrapeScript.swift | 8 ++- ...udeOAuthCredentials+TestingOverrides.swift | 4 ++ ...audeOAuthDelegatedRefreshCoordinator.swift | 13 +++++ ...AuthDelegatedRefreshCoordinatorTests.swift | 50 ++++++++++--------- .../HistoricalUsagePaceTestSupport.swift | 22 +++----- ...enAIDashboardNavigationDelegateTests.swift | 20 +++++--- 6 files changed, 71 insertions(+), 46 deletions(-) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift index 25f32a4560..06d7dea612 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift @@ -203,6 +203,12 @@ let openAIDashboardScrapeScript = """ }; const dayKeyFromPayload = (payload) => { if (!payload || typeof payload !== 'object') return null; + const localDayKeyForDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; const keys = ['day', 'date', 'name', 'label', 'x', 'time', 'timestamp']; for (const k of keys) { const v = payload[k]; @@ -215,7 +221,7 @@ let openAIDashboardScrapeScript = """ if (typeof v === 'number' && Number.isFinite(v) && (k === 'timestamp' || k === 'time' || k === 'x')) { try { const d = new Date(v); - if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10); + if (!isNaN(d.getTime())) return localDayKeyForDate(d); } catch {} } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift index dbcd11105a..8d76f07a92 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift @@ -187,6 +187,10 @@ extension ClaudeOAuthCredentialsStore { } } + static func currentSecurityCLIReadOverrideForTesting() -> SecurityCLIReadOverride? { + self.taskSecurityCLIReadOverride ?? self.securityCLIReadOverride + } + static func withSecurityCLIReadAccountOverrideForTesting( _ account: String?, operation: () throws -> T) rethrows -> T diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index d94940602a..07345f1479 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -55,12 +55,25 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { // Detached to avoid inheriting the caller's executor context (e.g. MainActor) and cancellation state. let readStrategy = ClaudeOAuthKeychainReadStrategyPreference.current() let keychainAccessDisabled = KeychainAccessGate.isDisabled + #if DEBUG + let securityCLIReadOverride = ClaudeOAuthCredentialsStore.currentSecurityCLIReadOverrideForTesting() + #endif let task = Task.detached(priority: .utility) { + #if DEBUG + return await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(securityCLIReadOverride) { + await self.performAttempt( + now: now, + timeout: timeout, + readStrategy: readStrategy, + keychainAccessDisabled: keychainAccessDisabled) + } + #else await self.performAttempt( now: now, timeout: timeout, readStrategy: readStrategy, keychainAccessDisabled: keychainAccessDisabled) + #endif } self.inFlightAttemptID = attemptID self.inFlightTask = task diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index 3ab8f87c9c..f71ae0a6b8 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -255,11 +255,11 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { let securityData = self.makeCredentialsData( accessToken: "security-token-a", expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.data(securityData)) - defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 60000), - timeout: 0.1) + let outcome = await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 60000), + timeout: 0.1) + } guard case .attemptedFailed = outcome else { Issue.record("Expected .attemptedFailed outcome") @@ -325,12 +325,13 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in dataBox.store(afterData) } - ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in dataBox.load() }) - defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } - - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61000), - timeout: 0.1) + let outcome = await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in + dataBox.load() + }) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61000), + timeout: 0.1) + } #expect(outcome == .attemptedSucceeded) #expect(fingerprintCounter.count < 1) @@ -390,12 +391,13 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in dataBox.store(afterData) } - ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in dataBox.load() }) - defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } - - let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61500), - timeout: 0.1) + let outcome = await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in + dataBox.load() + }) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61500), + timeout: 0.1) + } guard case .attemptedFailed = outcome else { Issue.record("Expected .attemptedFailed outcome when baseline is unavailable") @@ -428,15 +430,15 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { expiresAt: Date(timeIntervalSinceNow: 3600)) ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in } - ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in + let outcome = await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in securityReadCounter.increment() return securityData - }) - defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } - let outcome = await KeychainAccessGate.withTaskOverrideForTesting(true) { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 62000), - timeout: 0.1) + }) { + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 62000), + timeout: 0.1) + } } guard case .attemptedFailed = outcome else { diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift index 9a04cb2736..e3b2af377a 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift @@ -4,15 +4,7 @@ import Testing @testable import CodexBar extension HistoricalUsagePaceTests { - private static let utcTimeZone: TimeZone = { - if let zone = TimeZone(identifier: "UTC") { - return zone - } - if let zone = TimeZone(secondsFromGMT: 0) { - return zone - } - return TimeZone.current - }() + private static let dashboardTimeZone: TimeZone = .current static func linearCurve(end: Double) -> [Double] { let clampedEnd = max(0, min(100, end)) @@ -45,10 +37,10 @@ extension HistoricalUsagePaceTests { overridesByDayOffset: [Int: Double] = [:]) -> [OpenAIDashboardDailyBreakdown] { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = Self.utcTimeZone + calendar.timeZone = Self.dashboardTimeZone let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = Self.utcTimeZone + formatter.timeZone = Self.dashboardTimeZone formatter.dateFormat = "yyyy-MM-dd" let endDay = calendar.startOfDay(for: endDate) @@ -65,10 +57,10 @@ extension HistoricalUsagePaceTests { static func gregorianDate(year: Int, month: Int, day: Int, hour: Int) -> Date { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = Self.utcTimeZone + calendar.timeZone = Self.dashboardTimeZone var components = DateComponents() components.calendar = calendar - components.timeZone = Self.utcTimeZone + components.timeZone = Self.dashboardTimeZone components.year = year components.month = month components.day = day @@ -91,10 +83,10 @@ extension HistoricalUsagePaceTests { return nil } var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = Self.utcTimeZone + calendar.timeZone = Self.dashboardTimeZone var dateComponents = DateComponents() dateComponents.calendar = calendar - dateComponents.timeZone = Self.utcTimeZone + dateComponents.timeZone = Self.dashboardTimeZone dateComponents.year = year dateComponents.month = month dateComponents.day = day diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index c1045f5dad..b383424a5b 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -61,17 +61,25 @@ struct OpenAIDashboardNavigationDelegateTests { } } - @MainActor @Test("navigation timeout fails with timed out error") func navigationTimeoutFailsWithTimedOutError() async { - var result: Result? - let delegate = NavigationDelegate { result = $0 } + final class DelegateBox: @unchecked Sendable { + var delegate: NavigationDelegate? + } - delegate.armTimeout(seconds: 0.01) - try? await Task.sleep(for: .milliseconds(30)) + let result = await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in + Task { @MainActor in + let box = DelegateBox() + box.delegate = NavigationDelegate { result in + continuation.resume(returning: result) + box.delegate = nil + } + box.delegate?.armTimeout(seconds: 0.01) + } + } switch result { - case let .failure(error as URLError)?: + case let .failure(error as URLError): #expect(error.code == .timedOut) default: #expect(Bool(false)) From bdc30def694d6686d4a758d2515aee829dd881fa Mon Sep 17 00:00:00 2001 From: Alishan Ladhani Date: Sun, 8 Mar 2026 16:28:11 -0400 Subject: [PATCH 0014/1239] Add Gemini Flash Lite meter --- .../Gemini/GeminiProviderDescriptor.swift | 4 +- .../Providers/Gemini/GeminiStatusProbe.swift | 29 +++++++- .../CodexBarTests/GeminiAPITestHelpers.swift | 5 ++ Tests/CodexBarTests/GeminiMenuCardTests.swift | 58 ++++++++++++++++ .../GeminiStatusProbePlanTests.swift | 54 +++++++++++++++ .../GeminiStatusProbeTests.swift | 68 ++++++++++++++++++- 6 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 Tests/CodexBarTests/GeminiMenuCardTests.swift diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index b503b39785..c71f5ca046 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -12,8 +12,8 @@ public enum GeminiProviderDescriptor { displayName: "Gemini", sessionLabel: "Pro", weeklyLabel: "Flash", - opusLabel: nil, - supportsOpus: false, + opusLabel: "Flash Lite", + supportsOpus: true, supportsCredits: false, creditsHint: "", toggleTitle: "Show Gemini usage", diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 509cb03e21..f906ea7c2d 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -31,12 +31,15 @@ public struct GeminiStatusSnapshot: Sendable { } /// Converts Gemini quotas to a unified UsageSnapshot. - /// Groups quotas by tier: Pro (24h window) as primary, Flash (24h window) as secondary. + /// Groups quotas by tier: Pro (24h window) as primary, Flash (24h window) as secondary, + /// Flash Lite (24h window) as tertiary. public func toUsageSnapshot() -> UsageSnapshot { let lower = self.modelQuotas.map { ($0.modelId.lowercased(), $0) } - let flashQuotas = lower.filter { $0.0.contains("flash") }.map(\.1) - let proQuotas = lower.filter { $0.0.contains("pro") }.map(\.1) + let flashLiteQuotas = lower.filter { Self.isFlashLiteModel(id: $0.0) }.map(\.1) + let flashQuotas = lower.filter { Self.isFlashModel(id: $0.0) }.map(\.1) + let proQuotas = lower.filter { Self.isProModel(id: $0.0) }.map(\.1) + let flashLiteMin = flashLiteQuotas.min(by: { $0.percentLeft < $1.percentLeft }) let flashMin = flashQuotas.min(by: { $0.percentLeft < $1.percentLeft }) let proMin = proQuotas.min(by: { $0.percentLeft < $1.percentLeft }) @@ -53,6 +56,13 @@ public struct GeminiStatusSnapshot: Sendable { resetsAt: $0.resetTime, resetDescription: $0.resetDescription) } + let tertiary: RateWindow? = flashLiteMin.map { + RateWindow( + usedPercent: 100 - $0.percentLeft, + windowMinutes: 1440, + resetsAt: $0.resetTime, + resetDescription: $0.resetDescription) + } let identity = ProviderIdentitySnapshot( providerID: .gemini, @@ -62,9 +72,22 @@ public struct GeminiStatusSnapshot: Sendable { return UsageSnapshot( primary: primary, secondary: secondary, + tertiary: tertiary, updatedAt: Date(), identity: identity) } + + private static func isFlashLiteModel(id: String) -> Bool { + id.contains("flash-lite") + } + + private static func isFlashModel(id: String) -> Bool { + id.contains("flash") && !self.isFlashLiteModel(id: id) + } + + private static func isProModel(id: String) -> Bool { + id.contains("pro") + } } public enum GeminiStatusProbeError: LocalizedError, Sendable, Equatable { diff --git a/Tests/CodexBarTests/GeminiAPITestHelpers.swift b/Tests/CodexBarTests/GeminiAPITestHelpers.swift index 0e20efc340..b243f22ab7 100644 --- a/Tests/CodexBarTests/GeminiAPITestHelpers.swift +++ b/Tests/CodexBarTests/GeminiAPITestHelpers.swift @@ -37,6 +37,11 @@ enum GeminiAPITestHelpers { "remainingFraction": 0.9, "resetTime": "2025-01-01T00:00:00Z", ], + [ + "modelId": "gemini-2.5-flash-lite", + "remainingFraction": 0.8, + "resetTime": "2025-01-01T00:00:00Z", + ], ], ]) } diff --git a/Tests/CodexBarTests/GeminiMenuCardTests.swift b/Tests/CodexBarTests/GeminiMenuCardTests.swift new file mode 100644 index 0000000000..ec848f2bc2 --- /dev/null +++ b/Tests/CodexBarTests/GeminiMenuCardTests.swift @@ -0,0 +1,58 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct GeminiMenuCardTests { + @Test + func geminiModelUsesFlashLiteTitleForTertiaryMetric() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .gemini, + accountEmail: "gemini@example.com", + accountOrganization: nil, + loginMethod: "Paid") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1h"), + secondary: RateWindow( + usedPercent: 25, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(7200), + resetDescription: "Resets in 2h"), + tertiary: RateWindow( + usedPercent: 40, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(10800), + resetDescription: "Resets in 3h"), + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.gemini]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .gemini, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "gemini@example.com", plan: "Paid"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Pro", "Flash", "Flash Lite"]) + } +} diff --git a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift index a8b2656dd7..4c6721295f 100644 --- a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift @@ -53,6 +53,9 @@ struct GeminiStatusProbePlanTests { let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) let snapshot = try await probe.fetch() #expect(snapshot.modelQuotas.contains { $0.percentLeft == 40 }) + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary?.remainingPercent == 40) + #expect(usage.tertiary == nil) } @Test @@ -107,6 +110,57 @@ struct GeminiStatusProbePlanTests { let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) let snapshot = try await probe.fetch() #expect(snapshot.modelQuotas.contains { $0.percentLeft == 40 }) + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary?.remainingPercent == 40) + #expect(usage.tertiary == nil) + } + + @Test + func separatesFlashAndFlashLiteQuotaBucketsFromApiResponse() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.remainingPercent == 60.0) + #expect(usage.secondary?.remainingPercent == 90.0) + #expect(usage.tertiary?.remainingPercent == 80.0) } @Test diff --git a/Tests/CodexBarTests/GeminiStatusProbeTests.swift b/Tests/CodexBarTests/GeminiStatusProbeTests.swift index 7ede0ffc33..e13681c3f8 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeTests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeTests.swift @@ -146,11 +146,13 @@ struct GeminiStatusProbeTests { // Test that flash/pro keyword filtering works (model names may change) let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) - let flashQuotas = snap.modelQuotas.filter { $0.modelId.contains("flash") } + let flashQuotas = snap.modelQuotas.filter { $0.modelId.contains("flash") && !$0.modelId.contains("flash-lite") } + let flashLiteQuotas = snap.modelQuotas.filter { $0.modelId.contains("flash-lite") } let proQuotas = snap.modelQuotas.filter { $0.modelId.contains("pro") } // Should have at least one of each tier in sample #expect(!flashQuotas.isEmpty) + #expect(!flashLiteQuotas.isEmpty) #expect(!proQuotas.isEmpty) } @@ -185,6 +187,70 @@ struct GeminiStatusProbeTests { #expect(hasResets) } + @Test + func toUsageSnapshotCreatesSeparateFlashAndFlashLiteMeters() throws { + let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) + let usage = snap.toUsageSnapshot() + + #expect(usage.primary?.remainingPercent == 100.0) + #expect(usage.secondary?.remainingPercent == 99.8) + #expect(usage.tertiary?.remainingPercent == 99.8) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.secondary?.windowMinutes == 1440) + #expect(usage.tertiary?.windowMinutes == 1440) + #expect(usage.secondary?.resetDescription == "Resets in 20h 37m") + #expect(usage.tertiary?.resetDescription == "Resets in 20h 37m") + } + + @Test + func toUsageSnapshotDoesNotLetFlashLiteContaminateFlashBucket() throws { + let output = """ + │ gemini-2.5-flash 10 91.0% (Resets in 12h) │ + │ gemini-2.5-flash-lite 5 33.0% (Resets in 6h) │ + │ gemini-2.5-pro 2 80.0% (Resets in 24h) │ + """ + let snap = try GeminiStatusProbe.parse(text: output) + let usage = snap.toUsageSnapshot() + + #expect(usage.secondary?.remainingPercent == 91.0) + #expect(usage.tertiary?.remainingPercent == 33.0) + #expect(usage.secondary?.resetDescription == "Resets in 12h") + #expect(usage.tertiary?.resetDescription == "Resets in 6h") + } + + @Test + func toUsageSnapshotOmitsTertiaryWhenNoFlashLiteExists() throws { + let output = """ + │ gemini-2.5-flash 10 85.0% (Resets in 12h) │ + │ gemini-2.5-pro 2 95.0% (Resets in 24h) │ + """ + let snap = try GeminiStatusProbe.parse(text: output) + let usage = snap.toUsageSnapshot() + + #expect(usage.secondary?.remainingPercent == 85.0) + #expect(usage.tertiary == nil) + } + + @Test + func toUsageSnapshotUsesLowestRemainingQuotaPerTier() throws { + let output = """ + │ gemini-a-flash 10 91.0% (Resets in 12h) │ + │ gemini-b-flash 5 74.0% (Resets in 10h) │ + │ gemini-c-flash-lite 8 66.0% (Resets in 9h) │ + │ gemini-d-flash-lite 1 42.0% (Resets in 7h) │ + │ gemini-e-pro 2 88.0% (Resets in 24h) │ + │ gemini-f-pro 1 97.0% (Resets in 25h) │ + """ + let snap = try GeminiStatusProbe.parse(text: output) + let usage = snap.toUsageSnapshot() + + #expect(usage.primary?.remainingPercent == 88.0) + #expect(usage.secondary?.remainingPercent == 74.0) + #expect(usage.tertiary?.remainingPercent == 42.0) + #expect(usage.secondary?.resetDescription == "Resets in 10h") + #expect(usage.tertiary?.resetDescription == "Resets in 7h") + } + // MARK: - Live API test @Test From 182c062767411fb870bf967dbae865d6bb929283 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 12 Mar 2026 00:30:37 +0530 Subject: [PATCH 0015/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ab84535e..22a2070940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - OpenRouter: add provider support with credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas! - Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! - Kilo: add provider support with source-mode fallback, clearer credential/login guidance, auto top-up activity labeling, zero-balance credit handling, and pass parsing/menu rendering (#454). Thanks @coreh! +- Gemini: show separate Pro, Flash, and Flash Lite meters by splitting Gemini CLI quota buckets for `gemini-2.5-flash` and `gemini-2.5-flash-lite` (#496). Thanks @aladh - Browser cookie import: match Gecko `*.default*` profile directories case-insensitively so Firefox/Zen cookie detection works with uppercase `.Default` directories (#422). Thanks @bald-ai! - MiniMax: make both Settings "Open Coding Plan" actions region-aware so China mainland selection opens `platform.minimaxi.com` instead of the global domain (#426, fixes #378). Thanks @bald-ai! - Codex: in percent display mode with "show remaining," show remaining credits in the menu bar when session or weekly usage is exhausted (#336). Thanks @teron131! From b015660af8bfcd40c98bfff56f0779adba42dfc1 Mon Sep 17 00:00:00 2001 From: BryantChen Date: Fri, 13 Mar 2026 13:16:56 +0800 Subject: [PATCH 0016/1239] Fix AuggieCLIProbe hanging indefinitely when auggie CLI is unresponsive (#481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix AuggieCLIProbe hanging indefinitely when auggie CLI is unresponsive Replace raw Process + waitUntilExit() with SubprocessRunner which provides a 15-second timeout and SIGTERM→SIGKILL cleanup on hang. Co-Authored-By: Claude Opus 4.6 * Add regression tests for CLI timeout fallback (#474) - SubprocessRunnerTests: verify hung process throws .timedOut - AugmentCLIFetchStrategyFallbackTests: verify all SubprocessRunnerError and AuggieCLIError variants trigger correct fallback behavior Co-Authored-By: Claude Opus 4.6 * Fix SwiftFormat lint in regression tests - docComments: separate implementation comment from @Test declaration - hoistPatternLet: use `case let .timedOut(label)` pattern Co-Authored-By: Claude Opus 4.6 * Remove flaky subprocess timeout integration test The test relied on Task.sleep firing before process.waitUntilExit(), but waitUntilExit() blocks the cooperative thread pool, starving the timeout task on low-core CI runners. The timeout→fallback behavior is already covered by AugmentCLIFetchStrategyFallbackTests. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../Providers/Augment/AuggieCLIProbe.swift | 28 +++--- ...AugmentCLIFetchStrategyFallbackTests.swift | 98 +++++++++++++++++++ 2 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift diff --git a/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift b/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift index 23c31b2936..19eb38e945 100644 --- a/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift @@ -13,6 +13,9 @@ public struct AuggieCLIProbe: Sendable { return try self.parse(output) } + /// Timeout for the `auggie account status` command. + private static let commandTimeout: TimeInterval = 15 + private func runAuggieAccountStatus() async throws -> String { let env = ProcessInfo.processInfo.environment let loginPATH = LoginShellPathCache.shared.current @@ -24,24 +27,15 @@ public struct AuggieCLIProbe: Sendable { env: env, loginPATH: loginPATH) - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = ["account", "status"] - process.environment = pathEnv - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - try process.run() - process.waitUntilExit() - - let outputData = stdout.fileHandleForReading.readDataToEndOfFile() - let errorData = stderr.fileHandleForReading.readDataToEndOfFile() + let result = try await SubprocessRunner.run( + binary: executable, + arguments: ["account", "status"], + environment: pathEnv, + timeout: Self.commandTimeout, + label: "auggie-account-status") - let output = String(data: outputData, encoding: .utf8) ?? "" - let errorOutput = String(data: errorData, encoding: .utf8) ?? "" + let output = result.stdout + let errorOutput = result.stderr guard !output.isEmpty else { if !errorOutput.isEmpty { diff --git a/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift b/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift new file mode 100644 index 0000000000..af66f0e35d --- /dev/null +++ b/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) + +/// Regression tests for #474: verify that CLI timeout errors trigger fallback +/// to the web strategy instead of stalling the refresh cycle. +@Suite +struct AugmentCLIFetchStrategyFallbackTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { + let env: [String: String] = [:] + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + // SubprocessRunnerError is not an AuggieCLIError, so it hits the default + // fallback=true path — the desired behavior for infrastructure errors. + + @Test + func timeoutErrorFallsBackToWeb() { + let strategy = AugmentCLIFetchStrategy() + let context = self.makeContext() + let error = SubprocessRunnerError.timedOut("auggie-account-status") + #expect(strategy.shouldFallback(on: error, context: context) == true) + } + + @Test + func binaryNotFoundFallsBackToWeb() { + let strategy = AugmentCLIFetchStrategy() + let context = self.makeContext() + let error = SubprocessRunnerError.binaryNotFound("/usr/local/bin/auggie") + #expect(strategy.shouldFallback(on: error, context: context) == true) + } + + @Test + func launchFailedFallsBackToWeb() { + let strategy = AugmentCLIFetchStrategy() + let context = self.makeContext() + let error = SubprocessRunnerError.launchFailed("permission denied") + #expect(strategy.shouldFallback(on: error, context: context) == true) + } + + @Test + func notAuthenticatedFallsBackToWeb() { + let strategy = AugmentCLIFetchStrategy() + let context = self.makeContext() + #expect(strategy.shouldFallback(on: AuggieCLIError.notAuthenticated, context: context) == true) + } + + @Test + func noOutputFallsBackToWeb() { + let strategy = AugmentCLIFetchStrategy() + let context = self.makeContext() + #expect(strategy.shouldFallback(on: AuggieCLIError.noOutput, context: context) == true) + } + + @Test + func parseErrorDoesNotFallBack() { + let strategy = AugmentCLIFetchStrategy() + let context = self.makeContext() + #expect(strategy.shouldFallback(on: AuggieCLIError.parseError("bad data"), context: context) == false) + } + + @Test + func nonZeroExitFallsBackToWeb() { + let strategy = AugmentCLIFetchStrategy() + let context = self.makeContext() + let error = SubprocessRunnerError.nonZeroExit(code: 1, stderr: "crash") + #expect(strategy.shouldFallback(on: error, context: context) == true) + } +} + +#endif From e2d47ab2121352df941010af79e5463f28669145 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 13 Mar 2026 11:01:06 +0530 Subject: [PATCH 0017/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22a2070940..6abad36fb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - OpenRouter: add provider support with credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas! - Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! - Kilo: add provider support with source-mode fallback, clearer credential/login guidance, auto top-up activity labeling, zero-balance credit handling, and pass parsing/menu rendering (#454). Thanks @coreh! +- Augment: prevent refresh stalls when `auggie account status` hangs by replacing unbounded CLI waits with timed subprocess execution and fallback handling (#481). Thanks @bryant24hao! - Gemini: show separate Pro, Flash, and Flash Lite meters by splitting Gemini CLI quota buckets for `gemini-2.5-flash` and `gemini-2.5-flash-lite` (#496). Thanks @aladh - Browser cookie import: match Gecko `*.default*` profile directories case-insensitively so Firefox/Zen cookie detection works with uppercase `.Default` directories (#422). Thanks @bald-ai! - MiniMax: make both Settings "Open Coding Plan" actions region-aware so China mainland selection opens `platform.minimaxi.com` instead of the global domain (#426, fixes #378). Thanks @bald-ai! From 631c33cd6c5c858ca1b962e3a6d69b1c1acb1fdc Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 2 Mar 2026 20:34:06 +0530 Subject: [PATCH 0018/1239] Remove root directory mtime caching in Claude cost scanner The scanClaudeRoot function cached the root directory mtime and skipped full directory enumeration when it was unchanged. On POSIX systems a directory mtime only updates for direct child changes, not for files created or modified inside subdirectories. This meant new session logs in existing project folders were never discovered after the first scan, leaving cost data frozen until the cache was manually deleted. The per-file mtime/size cache in processClaudeFile already prevents redundant parsing of unchanged files, so always enumerating only adds the cost of the directory walk itself. Fixes #411 --- .../CostUsage/CostUsageScanner+Claude.swift | 56 +++---------------- 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 5e32060b00..d8cab24417 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -260,14 +260,12 @@ extension CostUsageScanner { private final class ClaudeScanState { var cache: CostUsageCache - var rootCache: [String: Int64] var touched: Set let range: CostUsageDayRange let providerFilter: ClaudeLogProviderFilter init(cache: CostUsageCache, range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter) { self.cache = cache - self.rootCache = cache.roots ?? [:] self.touched = [] self.range = range self.providerFilter = providerFilter @@ -339,9 +337,6 @@ extension CostUsageScanner { path.hasSuffix("/") ? path : "\(path)/" } let rootExists = rootCandidates.contains { FileManager.default.fileExists(atPath: $0) } - let canonicalRootPath = rootCandidates.first(where: { - FileManager.default.fileExists(atPath: $0) - }) ?? rootPath guard rootExists else { let stale = state.cache.files.keys.filter { path in @@ -353,44 +348,16 @@ extension CostUsageScanner { } state.cache.files.removeValue(forKey: path) } - for candidate in rootCandidates { - state.rootCache.removeValue(forKey: candidate) - } - return - } - - let rootAttrs = (try? FileManager.default.attributesOfItem(atPath: canonicalRootPath)) ?? [:] - let rootMtime = (rootAttrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let rootMtimeMs = Int64(rootMtime * 1000) - let cachedRootMtime = rootCandidates.compactMap { state.rootCache[$0] }.first - let canSkipEnumeration = cachedRootMtime == rootMtimeMs && rootMtimeMs > 0 - - if canSkipEnumeration { - let cachedPaths = state.cache.files.keys.filter { path in - prefixes.contains(where: { path.hasPrefix($0) }) - } - for path in cachedPaths { - guard FileManager.default.fileExists(atPath: path) else { - if let old = state.cache.files[path] { - Self.applyFileDays(cache: &state.cache, fileDays: old.days, sign: -1) - } - state.cache.files.removeValue(forKey: path) - continue - } - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 - if size <= 0 { continue } - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let mtimeMs = Int64(mtime * 1000) - Self.processClaudeFile( - url: URL(fileURLWithPath: path), - size: size, - mtimeMs: mtimeMs, - state: state) - } return } + // Always enumerate the directory tree. The per-file mtime/size cache in + // processClaudeFile already skips unchanged files, so the only cost here is + // the directory walk itself. The previous root-mtime optimization skipped + // enumeration entirely when the root directory mtime was unchanged, but on + // POSIX systems a directory mtime only updates for direct child changes — + // not for files created or modified inside subdirectories. This caused new + // session logs to go undetected until the cache was manually cleared. let keys: [URLResourceKey] = [ .isRegularFileKey, .contentModificationDateKey, @@ -419,12 +386,7 @@ extension CostUsageScanner { state: state) } - if rootMtimeMs > 0 { - state.rootCache[canonicalRootPath] = rootMtimeMs - for candidate in rootCandidates where candidate != canonicalRootPath { - state.rootCache.removeValue(forKey: candidate) - } - } + // Root mtime caching removed — see comment above. } static func loadClaudeDaily( @@ -458,7 +420,7 @@ extension CostUsageScanner { cache = scanState.cache touched = scanState.touched - cache.roots = scanState.rootCache.isEmpty ? nil : scanState.rootCache + cache.roots = nil for key in cache.files.keys where !touched.contains(key) { if let old = cache.files[key] { From 223877f7a1d2d98706f4ef01cbf37c3ce49e2200 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 14 Mar 2026 14:08:54 +0530 Subject: [PATCH 0019/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abad36fb9..f35750b5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - MiniMax: make both Settings "Open Coding Plan" actions region-aware so China mainland selection opens `platform.minimaxi.com` instead of the global domain (#426, fixes #378). Thanks @bald-ai! - Codex: in percent display mode with "show remaining," show remaining credits in the menu bar when session or weekly usage is exhausted (#336). Thanks @teron131! - Claude: fix extra-usage double conversion so OAuth/Web values stay on a single normalization path (#472, supersedes #463). Thanks @Priyans-hu! +- Claude: remove root-directory mtime short-circuiting in cost scanning so new session logs inside existing `~/.claude/projects/*` folders are discovered reliably (#462, fixes #411). Thanks @Priyans-hu! - Copilot: harden free-plan quota parsing and fallback behavior by treating underdetermined values as unknown, preserving missing metadata as nil (#432, supersedes #393). Thanks @emanuelst! - Menu: rebuild the merged provider switcher when “Show usage as used” changes so switcher progress updates immediately (#306). Thanks @Flohhhhh! - Update Kiro parsing for `kiro-cli` 1.24+ / Q Developer formats and non-managed plan handling (#288). Thanks @kilhyeonjun! From ec22e08acd3eb7ac06250bfb8d33ce8c5d2b44c8 Mon Sep 17 00:00:00 2001 From: iam-brain <94809115+iam-brain@users.noreply.github.com> Date: Sat, 14 Mar 2026 05:18:28 -0400 Subject: [PATCH 0020/1239] Preserve exact GPT-5 Codex pricing keys (#511) * Preserve exact GPT-5 Codex pricing keys * Suppress zero-cost chart peak * Bump Codex cache artifact version --- .../CodexBar/CostHistoryChartMenuView.swift | 14 +-- Sources/CodexBarCore/UsageFormatter.swift | 9 ++ .../Vendored/CostUsage/CostUsageCache.swift | 12 ++- .../Vendored/CostUsage/CostUsagePricing.swift | 96 ++++++++++++++++--- Tests/CodexBarTests/CLICostTests.swift | 48 ++++++++++ Tests/CodexBarTests/CostUsageCacheTests.swift | 17 ++++ .../CostUsageDecodingTests.swift | 12 +-- .../CostUsageJsonlScannerTests.swift | 68 +++++++++++++ .../CodexBarTests/CostUsagePricingTests.swift | 28 ++++-- .../CodexBarTests/CostUsageScannerTests.swift | 61 ++---------- Tests/CodexBarTests/UsageFormatterTests.swift | 7 ++ 11 files changed, 282 insertions(+), 90 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageCacheTests.swift create mode 100644 Tests/CodexBarTests/CostUsageJsonlScannerTests.swift diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 9c77cf4ef7..9b0d65b895 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -151,7 +151,7 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 for entry in sorted { - guard let costUSD = entry.costUSD, costUSD > 0 else { continue } + guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } let point = Point(date: date, costUSD: costUSD, totalTokens: entry.totalTokens) points.append(point) @@ -180,7 +180,7 @@ struct CostHistoryChartMenuView: View { dateKeys: dateKeys, axisDates: axisDates, barColor: barColor, - peakKey: peak?.key, + peakKey: maxCostUSD > 0 ? peak?.key : nil, maxCostUSD: maxCostUSD) } @@ -310,16 +310,18 @@ struct CostHistoryChartMenuView: View { guard let entry = model.entriesByDateKey[key] else { return nil } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } let parts = breakdown - .compactMap { item -> (name: String, costUSD: Double)? in - guard let costUSD = item.costUSD, costUSD > 0 else { return nil } - return (UsageFormatter.modelDisplayName(item.modelName), costUSD) + .compactMap { item -> (name: String, detail: String, costUSD: Double)? in + guard let costUSD = item.costUSD else { return nil } + let name = UsageFormatter.modelDisplayName(item.modelName) + guard let detail = UsageFormatter.modelCostDetail(item.modelName, costUSD: costUSD) else { return nil } + return (name, detail, costUSD) } .sorted { lhs, rhs in if lhs.costUSD == rhs.costUSD { return lhs.name < rhs.name } return lhs.costUSD > rhs.costUSD } .prefix(3) - .map { "\($0.name) \(UsageFormatter.usdString($0.costUSD))" } + .map { "\($0.name) \($0.detail)" } guard !parts.isEmpty else { return nil } return "Top: \(parts.joined(separator: " · "))" } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 226d435698..b03b18500e 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -206,6 +206,15 @@ public enum UsageFormatter { return cleaned.isEmpty ? raw : cleaned } + public static func modelCostDetail(_ model: String, costUSD: Double?) -> String? { + if let label = CostUsagePricing.codexDisplayLabel(model: model) { + return label + } + + guard let costUSD else { return nil } + return self.usdString(costUSD) + } + /// Cleans a provider plan string: strip ANSI/bracket noise, drop boilerplate words, collapse whitespace, and /// ensure a leading capital if the result starts lowercase. public static func cleanPlanName(_ text: String) -> String { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index e56131a416..2ea800cb53 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,6 +1,15 @@ import Foundation enum CostUsageCacheIO { + private static func artifactVersion(for provider: UsageProvider) -> Int { + switch provider { + case .codex: + 2 + default: + 1 + } + } + private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) @@ -8,9 +17,10 @@ enum CostUsageCacheIO { static func cacheFileURL(provider: UsageProvider, cacheRoot: URL? = nil) -> URL { let root = cacheRoot ?? self.defaultCacheRoot() + let artifactVersion = self.artifactVersion(for: provider) return root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("\(provider.rawValue)-v1.json", isDirectory: false) + .appendingPathComponent("\(provider.rawValue)-v\(artifactVersion).json", isDirectory: false) } static func load(provider: UsageProvider, cacheRoot: URL? = nil) -> CostUsageCache { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index ffc7b1b91a..36ad430e8d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -4,7 +4,8 @@ enum CostUsagePricing { struct CodexPricing: Sendable { let inputCostPerToken: Double let outputCostPerToken: Double - let cacheReadInputCostPerToken: Double + let cacheReadInputCostPerToken: Double? + let displayLabel: String? } struct ClaudePricing: Sendable { @@ -24,31 +25,83 @@ enum CostUsagePricing { "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), "gpt-5-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), + "gpt-5-nano": CodexPricing( + inputCostPerToken: 5e-8, + outputCostPerToken: 4e-7, + cacheReadInputCostPerToken: 5e-9, + displayLabel: nil), + "gpt-5-pro": CodexPricing( + inputCostPerToken: 1.5e-5, + outputCostPerToken: 1.2e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), "gpt-5.1": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex-max": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), "gpt-5.2": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), "gpt-5.2-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), - "gpt-5.3": CodexPricing( - inputCostPerToken: 1.75e-6, - outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.2-pro": CodexPricing( + inputCostPerToken: 2.1e-5, + outputCostPerToken: 1.68e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), "gpt-5.3-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.3-codex-spark": CodexPricing( + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheReadInputCostPerToken: 0, + displayLabel: "Research Preview"), + "gpt-5.4": CodexPricing( + inputCostPerToken: 2.5e-6, + outputCostPerToken: 1.5e-5, + cacheReadInputCostPerToken: 2.5e-7, + displayLabel: nil), + "gpt-5.4-pro": CodexPricing( + inputCostPerToken: 3e-5, + outputCostPerToken: 1.8e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), ] private static let claude: [String: ClaudePricing] = [ @@ -169,13 +222,25 @@ enum CostUsagePricing { if trimmed.hasPrefix("openai/") { trimmed = String(trimmed.dropFirst("openai/".count)) } - if let codexRange = trimmed.range(of: "-codex") { - let base = String(trimmed[.. String? { + let key = self.normalizeCodexModel(model) + return self.codex[key]?.displayLabel + } + static func normalizeClaudeModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("anthropic.") { @@ -210,8 +275,9 @@ enum CostUsagePricing { guard let pricing = self.codex[key] else { return nil } let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) + let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * pricing.cacheReadInputCostPerToken + + Double(cached) * cachedRate + Double(max(0, outputTokens)) * pricing.outputCostPerToken } diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index d376d383ea..4385066389 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -86,4 +86,52 @@ struct CLICostTests { #expect(json.contains("\"totalCost\"")) #expect(json.contains("1700000000")) } + + @Test + func encodesExactCodexModelIDsAndZeroCostBreakdowns() throws { + let payload = CostPayload( + provider: "codex", + source: "local", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + sessionTokens: 155, + sessionCostUSD: 0, + last30DaysTokens: 155, + last30DaysCostUSD: 0, + daily: [ + CostDailyEntryPayload( + date: "2025-12-21", + inputTokens: 120, + outputTokens: 15, + cacheReadTokens: 20, + cacheCreationTokens: nil, + totalTokens: 155, + costUSD: 0, + modelsUsed: ["gpt-5.3-codex-spark", "gpt-5.2-codex"], + modelBreakdowns: [ + CostModelBreakdownPayload(modelName: "gpt-5.3-codex-spark", costUSD: 0), + CostModelBreakdownPayload(modelName: "gpt-5.2-codex", costUSD: 1.23), + ]), + ], + totals: CostTotalsPayload( + totalInputTokens: 120, + totalOutputTokens: 15, + cacheReadTokens: 20, + cacheCreationTokens: nil, + totalTokens: 155, + totalCostUSD: 0), + error: nil) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + Issue.record("Failed to decode cost payload JSON") + return + } + + #expect(json.contains("\"gpt-5.3-codex-spark\"")) + #expect(json.contains("\"gpt-5.2-codex\"")) + #expect(!json.contains("\"gpt-5.2\"")) + #expect(json.contains("\"cost\":0")) + } } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift new file mode 100644 index 0000000000..735a2acacd --- /dev/null +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -0,0 +1,17 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageCacheTests { + @Test + func cacheFileURL_usesCodexSpecificArtifactVersion() { + let root = URL(fileURLWithPath: "/tmp/codexbar-cost-cache", isDirectory: true) + + let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) + let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) + + #expect(codexURL.lastPathComponent == "codex-v2.json") + #expect(claudeURL.lastPathComponent == "claude-v1.json") + } +} diff --git a/Tests/CodexBarTests/CostUsageDecodingTests.swift b/Tests/CodexBarTests/CostUsageDecodingTests.swift index 178b9e0706..677d4fb85a 100644 --- a/Tests/CodexBarTests/CostUsageDecodingTests.swift +++ b/Tests/CodexBarTests/CostUsageDecodingTests.swift @@ -119,7 +119,7 @@ struct CostUsageDecodingTests { "totalTokens": 30, "costUSD": 0.12, "models": { - "gpt-5.2": { + "gpt-5.2-codex": { "inputTokens": 10, "outputTokens": 20, "totalTokens": 30, @@ -138,7 +138,7 @@ struct CostUsageDecodingTests { let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) #expect(report.data.count == 1) #expect(report.data[0].costUSD == 0.12) - #expect(report.data[0].modelsUsed == ["gpt-5.2"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex"]) } @Test @@ -192,7 +192,7 @@ struct CostUsageDecodingTests { "date": "Dec 20, 2025", "totalTokens": 30, "costUSD": 0.12, - "modelsUsed": ["gpt-5.2"], + "modelsUsed": ["gpt-5.2-codex"], "models": { "ignored-model": { "totalTokens": 30 } } @@ -202,7 +202,7 @@ struct CostUsageDecodingTests { """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - #expect(report.data[0].modelsUsed == ["gpt-5.2"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex"]) } @Test @@ -214,14 +214,14 @@ struct CostUsageDecodingTests { "date": "Dec 20, 2025", "totalTokens": 30, "costUSD": 0.12, - "models": ["gpt-5.2", "gpt-5.2-mini"] + "models": ["gpt-5.2-codex", "gpt-5.2-mini"] } ] } """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - #expect(report.data[0].modelsUsed == ["gpt-5.2", "gpt-5.2-mini"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex", "gpt-5.2-mini"]) } @Test diff --git a/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift new file mode 100644 index 0000000000..cdf5c725a4 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageJsonlScannerTests { + @Test + func jsonlScannerHandlesLinesAcrossReadChunks() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("large-lines.jsonl", isDirectory: false) + let largeLine = String(repeating: "x", count: 300_000) + let contents = "\(largeLine)\nsmall\n" + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [(count: Int, truncated: Bool)] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 400_000, + prefixBytes: 400_000) + { line in + scanned.append((line.bytes.count, line.wasTruncated)) + } + + #expect(endOffset == Int64(Data(contents.utf8).count)) + #expect(scanned.count == 2) + #expect(scanned[0].count == 300_000) + #expect(scanned[0].truncated == false) + #expect(scanned[1].count == 5) + #expect(scanned[1].truncated == false) + } + + @Test + func jsonlScannerMarksPrefixLimitedLinesAsTruncated() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("truncated-lines.jsonl", isDirectory: false) + let shortLine = "ok" + let longLine = String(repeating: "a", count: 2000) + let contents = "\(shortLine)\n\(longLine)\n" + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [CostUsageJsonl.Line] = [] + _ = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 10000, + prefixBytes: 64) + { line in + scanned.append(line) + } + + #expect(scanned.count == 2) + #expect(String(data: scanned[0].bytes, encoding: .utf8) == "ok") + #expect(scanned[0].wasTruncated == false) + #expect(scanned[1].bytes.isEmpty) + #expect(scanned[1].wasTruncated == true) + } + + private func makeTemporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "codexbar-cost-usage-jsonl-\(UUID().uuidString)", + isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index f70e8d555d..662b72c78c 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -4,11 +4,13 @@ import Testing @Suite struct CostUsagePricingTests { @Test - func normalizesCodexModelVariants() { - #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5") - #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-codex") == "gpt-5.2") - #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-max") == "gpt-5.1") - #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-max") == "gpt-5.3") + func normalizesCodexModelVariantsExactly() { + #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-codex") == "gpt-5.2-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-max") == "gpt-5.1-codex-max") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-pro-2026-03-05") == "gpt-5.4-pro") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-2026-03-05") == "gpt-5.3-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") } @Test @@ -22,15 +24,27 @@ struct CostUsagePricingTests { } @Test - func codexCostSupportsGpt53CodexMax() { + func codexCostSupportsGpt53Codex() { let cost = CostUsagePricing.codexCostUSD( - model: "gpt-5.3-codex-max", + model: "gpt-5.3-codex", inputTokens: 100, cachedInputTokens: 10, outputTokens: 5) #expect(cost != nil) } + @Test + func codexCostReturnsZeroForResearchPreviewModel() { + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.3-codex-spark", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + #expect(cost == 0) + #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.3-codex-spark") == "Research Preview") + #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.2-codex") == nil) + } + @Test func normalizesClaudeOpus41DatedVariants() { #expect(CostUsagePricing.normalizeClaudeModel("claude-opus-4-1-20250805") == "claude-opus-4-1") diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index ecad8208d7..beab9e042b 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -56,7 +56,10 @@ struct CostUsageScannerTests { now: day, options: options) #expect(first.data.count == 1) - #expect(first.data[0].modelsUsed == ["gpt-5.2"]) + #expect(first.data[0].modelsUsed == ["gpt-5.2-codex"]) + #expect(first.data[0].modelBreakdowns == [ + CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD), + ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) @@ -85,6 +88,7 @@ struct CostUsageScannerTests { now: day, options: options) #expect(second.data.count == 1) + #expect(second.data[0].modelsUsed == ["gpt-5.2-codex"]) #expect(second.data[0].totalTokens == 176) #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } @@ -476,6 +480,7 @@ struct CostUsageScannerTests { let model = "openai/gpt-5.2-codex" let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(normalized == "gpt-5.2-codex") let turnContext: [String: Any] = [ "type": "turn_context", "timestamp": iso0, @@ -838,60 +843,6 @@ struct CostUsageScannerTests { #expect(report.data[0].outputTokens == 15) #expect(report.data[0].totalTokens == 45) } - - @Test - func jsonlScannerHandlesLinesAcrossReadChunks() throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let fileURL = env.root.appendingPathComponent("large-lines.jsonl", isDirectory: false) - let largeLine = String(repeating: "x", count: 300_000) - let contents = "\(largeLine)\nsmall\n" - try contents.write(to: fileURL, atomically: true, encoding: .utf8) - - var scanned: [(count: Int, truncated: Bool)] = [] - let endOffset = try CostUsageJsonl.scan( - fileURL: fileURL, - maxLineBytes: 400_000, - prefixBytes: 400_000) - { line in - scanned.append((line.bytes.count, line.wasTruncated)) - } - - #expect(endOffset == Int64(Data(contents.utf8).count)) - #expect(scanned.count == 2) - #expect(scanned[0].count == 300_000) - #expect(scanned[0].truncated == false) - #expect(scanned[1].count == 5) - #expect(scanned[1].truncated == false) - } - - @Test - func jsonlScannerMarksPrefixLimitedLinesAsTruncated() throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let fileURL = env.root.appendingPathComponent("truncated-lines.jsonl", isDirectory: false) - let shortLine = "ok" - let longLine = String(repeating: "a", count: 2000) - let contents = "\(shortLine)\n\(longLine)\n" - try contents.write(to: fileURL, atomically: true, encoding: .utf8) - - var scanned: [CostUsageJsonl.Line] = [] - _ = try CostUsageJsonl.scan( - fileURL: fileURL, - maxLineBytes: 10000, - prefixBytes: 64) - { line in - scanned.append(line) - } - - #expect(scanned.count == 2) - #expect(String(data: scanned[0].bytes, encoding: .utf8) == "ok") - #expect(scanned[0].wasTruncated == false) - #expect(scanned[1].bytes.isEmpty) - #expect(scanned[1].wasTruncated == true) - } } private struct CostUsageTestEnvironment { diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 072b299df6..a14c9fe3b3 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -99,6 +99,13 @@ struct UsageFormatterTests { #expect(UsageFormatter.modelDisplayName("gpt-4o-2024-08-06") == "gpt-4o") #expect(UsageFormatter.modelDisplayName("Claude Opus 4.5 2025 1101") == "Claude Opus 4.5") #expect(UsageFormatter.modelDisplayName("claude-sonnet-4-5") == "claude-sonnet-4-5") + #expect(UsageFormatter.modelDisplayName("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") + } + + @Test + func modelCostDetailUsesResearchPreviewLabel() { + #expect(UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0) == "Research Preview") + #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42) == "$0.42") } @Test From 5c9e93e553589c5356d48260cb035e6220dc9f56 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 14 Mar 2026 15:26:23 +0530 Subject: [PATCH 0021/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f35750b5f8..f6d5bcbdd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Claude: surface rate-limit errors from the CLI `/usage` probe with a user-friendly message, and harden "Failed to load usage data" matching against whitespace-collapsed output. - Claude: restore weekly/Sonnet reset parsing from whitespace-collapsed CLI `/usage` output so reset times and pace details still appear after CLI fallback. - Codex: add historical pace risk forecasting and backfill, gate pace computation by display mode, and handle zero-usage days in historical data (#482, supersedes #438). Thanks @tristanmanchester! +- Codex: preserve exact GPT-5 model IDs in local cost history, add GPT-5.4 pricing, and label zero-cost `gpt-5.3-codex-spark` sessions as "Research Preview" in cost breakdowns (#511). Thanks @iam-brain! - OpenRouter: add provider support with credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas! - Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! - Kilo: add provider support with source-mode fallback, clearer credential/login guidance, auto top-up activity labeling, zero-balance credit handling, and pass parsing/menu rendering (#454). Thanks @coreh! From fa59e39df4d99a54ce11655177ef4b2f0b8625e7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 15 Mar 2026 04:19:14 +0800 Subject: [PATCH 0022/1239] fix: show all enabled providers in merged switcher (#525) * fix: show all enabled providers in switcher regardless of availability The merged menu switcher, merge-icons logic, icon visibility, and animation state filtered providers by credential availability via enabledProviders(), silently hiding user-enabled providers that lacked configured credentials. Add enabledProvidersForDisplay() that skips availability filtering and use it across the UI pipeline (switcher data source, merged-mode detection, icon visibility, animation state, menu card width, and provider resolution fallbacks). Data paths (refresh, snapshots, widgets, CLI) retain availability filtering. fallbackProvider intentionally keeps the filtered list so the codex fallback icon appears when no provider has credentials. Closes #203 * fix: prefer available provider in merged-mode fallbacks - primaryProviderForUnifiedIcon: revert fallback to enabledProviders() so animation continues while available providers are still loading - resolvedMenuProvider: prefer isProviderAvailable() match so default menu content aligns with the status bar icon Addresses review feedback from ratulsarna on PR #525. --- .../StatusItemController+Animation.swift | 12 ++++++---- .../CodexBar/StatusItemController+Menu.swift | 24 ++++++++++--------- Sources/CodexBar/StatusItemController.swift | 6 +++-- Sources/CodexBar/UsageStore.swift | 5 ++++ Tests/CodexBarTests/StatusMenuTests.swift | 4 ++-- 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 960d9cf6ac..5f422862fc 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -23,11 +23,11 @@ extension StatusItemController { } let blinkingEnabled = self.isBlinkingAllowed() - // Cache enabled providers to avoid repeated enablement lookups. - let enabledProviders = self.store.enabledProviders() - let anyEnabled = !enabledProviders.isEmpty || self.store.debugForceAnimation + // Use display list so merged-mode visibility stays consistent with shouldMergeIcons. + let displayProviders = self.store.enabledProvidersForDisplay() + let anyEnabled = !displayProviders.isEmpty || self.store.debugForceAnimation let anyVisible = UsageProvider.allCases.contains { self.isVisible($0) } - let mergeIcons = self.settings.mergeIcons && enabledProviders.count > 1 + let mergeIcons = self.shouldMergeIcons let shouldBlink = mergeIcons ? anyEnabled : anyVisible if blinkingEnabled, shouldBlink { if self.blinkTask == nil { @@ -278,7 +278,7 @@ extension StatusItemController { let tilt: CGFloat = style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28 let statusIndicator: ProviderStatusIndicator = { - for provider in self.store.enabledProviders() { + for provider in self.store.enabledProvidersForDisplay() { let indicator = self.store.statusIndicator(for: provider) if indicator.hasIssue { return indicator } } @@ -499,6 +499,8 @@ extension StatusItemController { return provider } } + // Use availability-filtered list: fallback must pick a provider that can + // actually animate, otherwise shouldAnimate() fails on credential-less providers. if let enabled = self.store.enabledProviders().first { return enabled } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 5d91736244..f0ac611aa3 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -99,10 +99,10 @@ extension StatusItemController { self.lastMenuProvider = menuProvider provider = menuProvider } else if menu === self.fallbackMenu { - self.lastMenuProvider = self.store.enabledProviders().first ?? .codex + self.lastMenuProvider = self.store.enabledProvidersForDisplay().first ?? .codex provider = nil } else { - let resolved = self.store.enabledProviders().first ?? .codex + let resolved = self.store.enabledProvidersForDisplay().first ?? .codex self.lastMenuProvider = resolved provider = resolved } @@ -147,7 +147,7 @@ extension StatusItemController { } private func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { - let enabledProviders = self.store.enabledProviders() + let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 ? self.resolvedSwitcherSelection( @@ -598,7 +598,7 @@ extension StatusItemController { let view = TokenAccountSwitcherView( accounts: display.accounts, selectedIndex: display.activeIndex, - width: self.menuCardWidth(for: self.store.enabledProviders(), menu: menu), + width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) @@ -618,12 +618,14 @@ extension StatusItemController { } private func resolvedMenuProvider(enabledProviders: [UsageProvider]? = nil) -> UsageProvider? { - let enabled = enabledProviders ?? self.store.enabledProviders() + let enabled = enabledProviders ?? self.store.enabledProvidersForDisplay() if enabled.isEmpty { return .codex } if let selected = self.selectedMenuProvider, enabled.contains(selected) { return selected } - return enabled.first + // Prefer an available provider so the default menu content matches the status icon. + // Falls back to first display provider when all lack credentials. + return enabled.first(where: { self.store.isProviderAvailable($0) }) ?? enabled.first } private func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { @@ -704,7 +706,7 @@ extension StatusItemController { if menu === self.fallbackMenu { return nil } - return self.store.enabledProviders().first ?? .codex + return self.store.enabledProvidersForDisplay().first ?? .codex } private func scheduleOpenMenuRefresh(for menu: NSMenu) { @@ -735,7 +737,7 @@ extension StatusItemController { } private func delayedRefreshRetryProviders(for menu: NSMenu) -> [UsageProvider] { - let enabledProviders = self.store.enabledProviders() + let enabledProviders = self.store.enabledProvidersForDisplay() guard !enabledProviders.isEmpty else { return [] } let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) @@ -766,7 +768,7 @@ extension StatusItemController { } for item in cardItems { guard let view = item.view else { continue } - let width = self.menuCardWidth(for: self.store.enabledProviders(), menu: menu) + let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) let height = self.menuCardHeight(for: view, width: width) view.frame = NSRect( origin: .zero, @@ -1343,7 +1345,7 @@ extension StatusItemController { } private func refreshHostedSubviewHeights(in menu: NSMenu) { - let enabledProviders = self.store.enabledProviders() + let enabledProviders = self.store.enabledProvidersForDisplay() let width = self.menuCardWidth(for: enabledProviders, menu: menu) for item in menu.items { @@ -1360,7 +1362,7 @@ extension StatusItemController { snapshotOverride: UsageSnapshot? = nil, errorOverride: String? = nil) -> UsageMenuCardView.Model? { - let target = provider ?? self.store.enabledProviders().first ?? .codex + let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) let snapshot = snapshotOverride ?? self.store.snapshot(for: target) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 5ccf8ab38c..a83420ee90 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -359,7 +359,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateVisibility() { - let anyEnabled = !self.store.enabledProviders().isEmpty + let anyEnabled = !self.store.enabledProvidersForDisplay().isEmpty let force = self.store.debugForceAnimation let mergeIcons = self.shouldMergeIcons if mergeIcons { @@ -388,6 +388,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } var fallbackProvider: UsageProvider? { + // Intentionally uses availability-filtered list: fallback activates when no provider + // can actually work, ensuring at least a codex icon is always visible. self.store.enabledProviders().isEmpty ? .codex : nil } @@ -464,7 +466,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } var shouldMergeIcons: Bool { - self.settings.mergeIcons && self.store.enabledProviders().count > 1 + self.settings.mergeIcons && self.store.enabledProvidersForDisplay().count > 1 } func switchAccountSubtitle(for target: UsageProvider) -> String? { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e3f0bcae4f..dfa5771832 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -286,6 +286,11 @@ final class UsageStore { return enabled.filter { self.isProviderAvailable($0) } } + /// Enabled providers without availability filtering. Used for display (switcher, merge-icons). + func enabledProvidersForDisplay() -> [UsageProvider] { + self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) + } + var statusChecksEnabled: Bool { self.settings.statusChecksEnabled } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3f04c3ff79..60922b5dd6 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -272,7 +272,7 @@ struct StatusMenuTests { controller.menuWillOpen(menu) let buttons = self.switcherButtons(in: menu) - #expect(buttons.count == store.enabledProviders().count + 1) + #expect(buttons.count == store.enabledProvidersForDisplay().count + 1) #expect(buttons.contains(where: { $0.tag == 0 })) #expect(buttons.first(where: { $0.state == .on })?.tag == 2) } @@ -883,7 +883,7 @@ extension StatusMenuTests { let ids = self.representedIDs(in: menu) let switcherButtons = self.switcherButtons(in: menu) - #expect(switcherButtons.count == store.enabledProviders().count) + #expect(switcherButtons.count == store.enabledProvidersForDisplay().count) #expect(switcherButtons.contains(where: { $0.title == "Overview" }) == false) #expect(switcherButtons.contains(where: { $0.state == .on && $0.tag == 0 })) #expect(ids.contains("menuCard")) From 480262f0bc009a8c05b6affe0da2f54ff3e9ebdf Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 01:50:38 +0530 Subject: [PATCH 0023/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d5bcbdd5..2a002cac68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Merged menu: add an Overview switcher tab that shows up to three provider usage rows in provider order (#416). - Settings: add "Overview tab providers" controls to choose/deselect Overview providers, with persisted selection reconciliation as enabled providers change (#416). - Menu: hide contextual provider actions while Overview is selected and rebuild switcher state when overview availability changes (#416). +- Merged menu: keep Merge Icons, the switcher, and Overview tied to user-enabled providers even when some providers are temporarily unavailable, while defaulting menu content and icon state to an available provider when possible (#525). Thanks @Astro-Han! ### Claude OAuth & Keychain - Use a `claude-code/` User-Agent for OAuth usage requests instead of a generic identifier. From 501d1122902143717bf4e268c63a72d997b2911f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:33:53 -0700 Subject: [PATCH 0024/1239] fix: remove deprecated keychain no-ui flag --- CHANGELOG.md | 34 +++++++++---------- .../KeychainAccessPreflight.swift | 4 +-- Sources/CodexBarCore/KeychainNoUIQuery.swift | 5 --- docs/KEYCHAIN_FIX.md | 2 +- docs/claude-comparison-since-0.18.0beta2.md | 2 +- 5 files changed, 21 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a002cac68..be513b12f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,51 +2,51 @@ ## Unreleased ### Highlights +- Add Kilo provider support with API/CLI source modes, widget integration, and pass/credit handling (#454). Built on work by @coreh. +- Add Ollama provider, including token-account support in Settings and CLI (#380). Thanks @CryptoSageSnr! +- Add OpenRouter provider for credit-based usage tracking (#396). Thanks @chountalas! +- Add Codex historical pace with risk forecasting, backfill, and zero-usage-day handling (#482, supersedes #438). Thanks @tristanmanchester! - Add a merged-menu Overview tab with configurable providers and row-to-provider navigation (#416). @ratulsarna - Add an experimental option to suppress Claude Keychain prompts (#388). -- Add OpenRouter provider for credit-based usage tracking (#396). Thanks @chountalas! -- Add Ollama provider, including token-account support in Settings and CLI (#380). Thanks @CryptoSageSnr! -- Add Kilo provider support with API/CLI source modes, widget integration, and pass/credit handling (#454). Built on work by @coreh. - Reduce CPU/energy regressions and JSONL scanner overhead in Codex/web usage paths (#402, #392). Thanks @bald-ai and @asonawalla! -- Add Codex historical pace with risk forecasting, backfill, and zero-usage-day handling (#482, supersedes #438). Thanks @tristanmanchester! ### Providers & Usage -- Claude: surface rate-limit errors from the CLI `/usage` probe with a user-friendly message, and harden "Failed to load usage data" matching against whitespace-collapsed output. -- Claude: restore weekly/Sonnet reset parsing from whitespace-collapsed CLI `/usage` output so reset times and pace details still appear after CLI fallback. - Codex: add historical pace risk forecasting and backfill, gate pace computation by display mode, and handle zero-usage days in historical data (#482, supersedes #438). Thanks @tristanmanchester! -- Codex: preserve exact GPT-5 model IDs in local cost history, add GPT-5.4 pricing, and label zero-cost `gpt-5.3-codex-spark` sessions as "Research Preview" in cost breakdowns (#511). Thanks @iam-brain! -- OpenRouter: add provider support with credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas! -- Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! - Kilo: add provider support with source-mode fallback, clearer credential/login guidance, auto top-up activity labeling, zero-balance credit handling, and pass parsing/menu rendering (#454). Thanks @coreh! -- Augment: prevent refresh stalls when `auggie account status` hangs by replacing unbounded CLI waits with timed subprocess execution and fallback handling (#481). Thanks @bryant24hao! +- Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! +- OpenRouter: add provider support with credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas! - Gemini: show separate Pro, Flash, and Flash Lite meters by splitting Gemini CLI quota buckets for `gemini-2.5-flash` and `gemini-2.5-flash-lite` (#496). Thanks @aladh -- Browser cookie import: match Gecko `*.default*` profile directories case-insensitively so Firefox/Zen cookie detection works with uppercase `.Default` directories (#422). Thanks @bald-ai! -- MiniMax: make both Settings "Open Coding Plan" actions region-aware so China mainland selection opens `platform.minimaxi.com` instead of the global domain (#426, fixes #378). Thanks @bald-ai! - Codex: in percent display mode with "show remaining," show remaining credits in the menu bar when session or weekly usage is exhausted (#336). Thanks @teron131! +- Claude: surface rate-limit errors from the CLI `/usage` probe with a user-friendly message, and harden "Failed to load usage data" matching against whitespace-collapsed output. +- Claude: restore weekly/Sonnet reset parsing from whitespace-collapsed CLI `/usage` output so reset times and pace details still appear after CLI fallback. - Claude: fix extra-usage double conversion so OAuth/Web values stay on a single normalization path (#472, supersedes #463). Thanks @Priyans-hu! - Claude: remove root-directory mtime short-circuiting in cost scanning so new session logs inside existing `~/.claude/projects/*` folders are discovered reliably (#462, fixes #411). Thanks @Priyans-hu! - Copilot: harden free-plan quota parsing and fallback behavior by treating underdetermined values as unknown, preserving missing metadata as nil (#432, supersedes #393). Thanks @emanuelst! -- Menu: rebuild the merged provider switcher when “Show usage as used” changes so switcher progress updates immediately (#306). Thanks @Flohhhhh! -- Update Kiro parsing for `kiro-cli` 1.24+ / Q Developer formats and non-managed plan handling (#288). Thanks @kilhyeonjun! -- Kimi: in automatic metric mode, prioritize the 5-hour rate-limit window for menu bar and merged highest-usage calculations (#390). Thanks @ajaxjiang96! - OpenCode: treat explicit `null` subscription responses as missing usage data, skip POST fallback, and return a clearer workspace-specific error (#412). - OpenCode: surface clearer HTTP errors. Thanks @SalimBinYousuf1! +- Codex: preserve exact GPT-5 model IDs in local cost history, add GPT-5.4 pricing, and label zero-cost `gpt-5.3-codex-spark` sessions as "Research Preview" in cost breakdowns (#511). Thanks @iam-brain! +- Augment: prevent refresh stalls when `auggie account status` hangs by replacing unbounded CLI waits with timed subprocess execution and fallback handling (#481). Thanks @bryant24hao! +- Update Kiro parsing for `kiro-cli` 1.24+ / Q Developer formats and non-managed plan handling (#288). Thanks @kilhyeonjun! +- Kimi: in automatic metric mode, prioritize the 5-hour rate-limit window for menu bar and merged highest-usage calculations (#390). Thanks @ajaxjiang96! +- Browser cookie import: match Gecko `*.default*` profile directories case-insensitively so Firefox/Zen cookie detection works with uppercase `.Default` directories (#422). Thanks @bald-ai! +- MiniMax: make both Settings "Open Coding Plan" actions region-aware so China mainland selection opens `platform.minimaxi.com` instead of the global domain (#426, fixes #378). Thanks @bald-ai! +- Menu: rebuild the merged provider switcher when “Show usage as used” changes so switcher progress updates immediately (#306). Thanks @Flohhhhh! - Warp: update API key setup guidance. - Claude: update the "not installed" help link to the current Claude Code documentation URL (#431). Thanks @skebby11! - Fix Claude setup message package name (#376). Thanks @daegwang! ### Menu & Settings +- Merged menu: keep Merge Icons, the switcher, and Overview tied to user-enabled providers even when some providers are temporarily unavailable, while defaulting menu content and icon state to an available provider when possible (#525). Thanks @Astro-Han! - Merged menu: add an Overview switcher tab that shows up to three provider usage rows in provider order (#416). - Settings: add "Overview tab providers" controls to choose/deselect Overview providers, with persisted selection reconciliation as enabled providers change (#416). - Menu: hide contextual provider actions while Overview is selected and rebuild switcher state when overview availability changes (#416). -- Merged menu: keep Merge Icons, the switcher, and Overview tied to user-enabled providers even when some providers are temporarily unavailable, while defaulting menu content and icon state to an available provider when possible (#525). Thanks @Astro-Han! ### Claude OAuth & Keychain -- Use a `claude-code/` User-Agent for OAuth usage requests instead of a generic identifier. - Add an experimental Claude OAuth Security-CLI reader path and option in settings. - Apply stored prompt mode and fallback policy to silent/noninteractive keychain probes. - Add cooldown for background OAuth keychain retries. - Disable experimental toggle when keychain access is disabled. +- Use a `claude-code/` User-Agent for OAuth usage requests instead of a generic identifier. ### Performance & Reliability - Codex/OpenAI web: reduce CPU and energy overhead by shortening failed CLI probe windows, capping web retry timeouts, and using adaptive idle blink scheduling (#402). Thanks @bald-ai! diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 0ca26604ee..260435ff51 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -139,8 +139,8 @@ public enum KeychainAccessPreflight { kSecAttrService as String: service, kSecMatchLimit as String: kSecMatchLimitOne, // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because - // some macOS configurations have been observed to show the legacy keychain prompt even when - // `kSecUseAuthenticationUIFail` is set. + // some macOS configurations still surface legacy prompts more aggressively when reading secret data, + // even with a non-interactive LAContext. kSecReturnAttributes as String: true, ] KeychainNoUIQuery.apply(to: &query) diff --git a/Sources/CodexBarCore/KeychainNoUIQuery.swift b/Sources/CodexBarCore/KeychainNoUIQuery.swift index 29ef456a74..debfa66e4d 100644 --- a/Sources/CodexBarCore/KeychainNoUIQuery.swift +++ b/Sources/CodexBarCore/KeychainNoUIQuery.swift @@ -9,11 +9,6 @@ enum KeychainNoUIQuery { let context = LAContext() context.interactionNotAllowed = true query[kSecUseAuthenticationContext as String] = context - - // NOTE: While Apple recommends using LAContext.interactionNotAllowed, that alone is not sufficient to - // prevent the legacy keychain "Allow/Deny" prompt on some configurations. We also set the UI policy to fail - // so SecItemCopyMatching returns errSecInteractionNotAllowed instead of showing UI. - query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail } } #endif diff --git a/docs/KEYCHAIN_FIX.md b/docs/KEYCHAIN_FIX.md index 61deda6bcf..e10f151a1b 100644 --- a/docs/KEYCHAIN_FIX.md +++ b/docs/KEYCHAIN_FIX.md @@ -48,7 +48,7 @@ Load order for credentials: 5. Claude CLI keychain service: `Claude Code-credentials` (promptable fallback). Prompt mitigation: -- Non-interactive keychain probes use `KeychainNoUIQuery` (`LAContext.interactionNotAllowed` + `kSecUseAuthenticationUIFail`). +- Non-interactive keychain probes use `KeychainNoUIQuery` with `LAContext.interactionNotAllowed`. - Pre-alert is shown only when preflight suggests interaction may be required. - Denials are cooled down in the background via `claudeOAuthKeychainDeniedUntil` (`ClaudeOAuthKeychainAccessGate`). User actions (menu open / manual refresh) clear this cooldown. diff --git a/docs/claude-comparison-since-0.18.0beta2.md b/docs/claude-comparison-since-0.18.0beta2.md index b2797a7fa0..6d7f665bc5 100644 --- a/docs/claude-comparison-since-0.18.0beta2.md +++ b/docs/claude-comparison-since-0.18.0beta2.md @@ -14,7 +14,7 @@ Focus areas: ## High-level Changes - OAuth moved from "load token and fail if expired" to "auto-refresh when expired". -- Claude keychain reads now use stricter non-interactive probes (`kSecUseAuthenticationUIFail`) before any promptable path. +- Claude keychain reads now use stricter non-interactive probes (`LAContext.interactionNotAllowed`) before any promptable path. - New Claude keychain prompt cooldown gate: 6-hour suppression after denial. - New OAuth refresh failure gate: - `invalid_grant` => terminal block until auth fingerprint changes. From 8dfcaf7b3fe4174bb5de95bc10aee70f9c53615c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:55:56 -0700 Subject: [PATCH 0025/1239] fix: stabilize version detection and clean formatting --- Sources/CodexBar/ClaudeLoginRunner.swift | 2 +- Sources/CodexBar/CursorLoginRunner.swift | 6 +- Sources/CodexBar/HistoricalUsagePace.swift | 10 +-- Sources/CodexBar/IconRenderer.swift | 4 +- Sources/CodexBar/KeychainMigration.swift | 2 +- Sources/CodexBar/MenuCardView.swift | 6 +- .../PreferencesProviderErrorView.swift | 2 +- .../Providers/Shared/ProviderRuntime.swift | 2 +- .../ProviderTokenAccountSelection.swift | 2 +- .../CodexBar/SessionQuotaNotifications.swift | 2 +- Sources/CodexBar/SettingsStoreState.swift | 2 +- .../StatusItemController+Actions.swift | 2 +- .../CodexBar/StatusItemController+Menu.swift | 4 +- Sources/CodexBar/UpdateChannel.swift | 2 +- Sources/CodexBar/UsagePaceText.swift | 2 +- .../CodexBar/UsageStore+TokenAccounts.swift | 2 +- Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/CLIExitCode.swift | 2 +- Sources/CodexBarCLI/CLIOptions.swift | 4 +- .../CodexBarCLI/CLIOutputPreferences.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- Sources/CodexBarCore/BrowserDetection.swift | 4 +- Sources/CodexBarCore/CostUsageModels.swift | 2 +- .../Host/PTY/TTYCommandRunner.swift | 2 +- ...OpenAIDashboardBrowserCookieImporter.swift | 2 +- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 2 +- .../Providers/Amp/AmpUsageFetcher.swift | 2 +- .../Providers/Amp/AmpUsageParser.swift | 2 +- .../Antigravity/AntigravityStatusProbe.swift | 4 +- ...deOAuthCredentials+SecurityCLIReader.swift | 6 +- ...udeOAuthCredentials+TestingOverrides.swift | 2 +- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 10 +-- ...audeOAuthDelegatedRefreshCoordinator.swift | 2 +- .../ClaudeOAuthRefreshFailureGate.swift | 2 +- .../ClaudeOAuth/ClaudeOAuthUsageFetcher.swift | 6 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 2 +- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 2 +- .../Codex/CodexWebDashboardStrategy.swift | 4 +- .../Factory/FactoryLocalStorageImporter.swift | 8 +-- .../Factory/FactoryStatusProbe.swift | 4 +- .../Providers/Gemini/GeminiStatusProbe.swift | 2 +- .../Providers/Kiro/KiroStatusProbe.swift | 2 +- .../MiniMax/MiniMaxLocalStorageImporter.swift | 12 ++-- .../MiniMax/MiniMaxProviderDescriptor.swift | 6 +- .../MiniMax/MiniMaxUsageFetcher.swift | 12 ++-- .../Providers/Ollama/OllamaUsageFetcher.swift | 6 +- .../Providers/Ollama/OllamaUsageParser.swift | 6 +- .../OpenCode/OpenCodeUsageFetcher.swift | 2 +- .../OpenRouter/OpenRouterUsageStats.swift | 2 +- .../ProviderCandidateRetryRunner.swift | 2 +- .../Providers/ProviderVersionDetector.swift | 42 ++++++++---- .../Providers/Warp/WarpUsageFetcher.swift | 4 +- .../Vendored/CostUsage/CostUsageCache.swift | 6 +- .../Vendored/CostUsage/CostUsageJsonl.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 4 +- .../Vendored/CostUsage/CostUsageScanner.swift | 10 +-- .../CodexBarTests/AmpUsageFetcherTests.swift | 9 ++- Tests/CodexBarTests/AmpUsageParserTests.swift | 13 ++-- .../AntigravityStatusProbeTests.swift | 3 +- Tests/CodexBarTests/AppDelegateTests.swift | 3 +- ...AugmentCLIFetchStrategyFallbackTests.swift | 15 ++--- .../BatteryDrainDiagnosticTests.swift | 12 ++-- .../BrowserCookieOrderLabelTests.swift | 5 +- .../CodexBarTests/BrowserDetectionTests.swift | 20 +++--- .../CLIArgumentParsingTests.swift | 13 ++-- Tests/CodexBarTests/CLICostTests.swift | 9 ++- Tests/CodexBarTests/CLIEntryTests.swift | 33 +++++---- Tests/CodexBarTests/CLIOutputTests.swift | 5 +- .../CLIProviderSelectionTests.swift | 17 +++-- Tests/CodexBarTests/CLISnapshotTests.swift | 33 +++++---- Tests/CodexBarTests/CLIWebFallbackTests.swift | 11 ++- ...uthCredentialsStorePromptPolicyTests.swift | 24 ++++--- ...sStoreSecurityCLIFallbackPolicyTests.swift | 4 +- ...AuthCredentialsStoreSecurityCLITests.swift | 34 +++++----- .../ClaudeOAuthCredentialsStoreTests.swift | 32 ++++----- ...AuthDelegatedRefreshCoordinatorTests.swift | 18 ++--- ...deOAuthDelegatedRefreshRecoveryTests.swift | 8 ++- ...eOAuthFetchStrategyAvailabilityTests.swift | 22 +++--- .../ClaudeOAuthKeychainAccessGateTests.swift | 8 +-- .../ClaudeOAuthRefreshDispositionTests.swift | 9 ++- .../ClaudeOAuthRefreshFailureGateTests.swift | 22 +++--- Tests/CodexBarTests/ClaudeOAuthTests.swift | 35 +++++----- .../CodexBarTests/ClaudeResilienceTests.swift | 7 +- Tests/CodexBarTests/ClaudeUsageTests.swift | 59 ++++++++-------- Tests/CodexBarTests/CodexOAuthTests.swift | 15 ++--- Tests/CodexBarTests/CodexbarTests.swift | 13 ++-- .../CodexBarTests/ConfigValidationTests.swift | 13 ++-- .../CookieHeaderCacheTests.swift | 4 +- .../CopilotUsageModelsTests.swift | 33 +++++---- Tests/CodexBarTests/CostUsageCacheTests.swift | 3 +- .../CostUsageDecodingTests.swift | 31 +++++---- .../CostUsageJsonlPerformanceTests.swift | 2 +- .../CostUsageJsonlScannerTests.swift | 5 +- .../CodexBarTests/CostUsagePricingTests.swift | 17 +++-- .../CodexBarTests/CostUsageScannerTests.swift | 27 ++++---- .../CursorStatusProbeTests.swift | 35 +++++----- .../FactoryStatusProbeFetchTests.swift | 2 +- .../FactoryStatusProbeTests.swift | 18 +++-- .../CodexBarTests/GeminiLoginAlertTests.swift | 7 +- Tests/CodexBarTests/GeminiMenuCardTests.swift | 3 +- .../GeminiStatusProbeAPITests.swift | 24 +++---- .../GeminiStatusProbePlanTests.swift | 18 ++--- .../GeminiStatusProbeTests.swift | 41 ++++++------ .../GoogleWorkspaceStatusTests.swift | 5 +- .../HistoricalUsagePaceTests.swift | 47 +++++++------ Tests/CodexBarTests/InstallOriginTests.swift | 3 +- .../JetBrainsIDEDetectorTests.swift | 3 +- .../JetBrainsStatusProbeTests.swift | 27 ++++---- .../KeyboardShortcutsBundleTests.swift | 4 +- .../KeychainCacheStoreTests.swift | 6 +- .../KeychainMigrationTests.swift | 5 +- .../KiloSettingsReaderTests.swift | 11 ++- .../CodexBarTests/KiloUsageFetcherTests.swift | 47 +++++++------ .../KimiK2SettingsReaderTests.swift | 8 +-- .../KimiK2UsageFetcherTests.swift | 11 ++- Tests/CodexBarTests/KimiProviderTests.swift | 41 +++++------- .../CodexBarTests/KiroStatusProbeTests.swift | 47 +++++++------ Tests/CodexBarTests/LiveAccountTests.swift | 4 +- Tests/CodexBarTests/LoadingPatternTests.swift | 7 +- .../CodexBarTests/MenuCardKiloPassTests.swift | 3 +- Tests/CodexBarTests/MenuCardModelTests.swift | 39 ++++++----- .../MenuDescriptorKiloTests.swift | 7 +- .../MiniMaxAPITokenFetchTests.swift | 6 +- .../MiniMaxLocalStorageImporterTests.swift | 9 ++- .../CodexBarTests/MiniMaxProviderTests.swift | 43 ++++++------ .../OllamaUsageFetcherRetryMappingTests.swift | 2 +- .../OllamaUsageFetcherTests.swift | 29 ++++---- .../OllamaUsageParserTests.swift | 17 +++-- ...IDashboardBrowserCookieImporterTests.swift | 3 +- ...enAIDashboardFetcherCreditsWaitTests.swift | 19 +++--- ...enAIDashboardNavigationDelegateTests.swift | 21 +++--- .../OpenAIDashboardOffscreenHostTests.swift | 5 +- .../OpenAIDashboardParserTests.swift | 19 +++--- .../OpenAIDashboardWebViewCacheTests.swift | 36 +++++----- .../OpenAIWebAccountSwitchTests.swift | 5 +- .../OpenCodeUsageFetcherErrorTests.swift | 10 +-- .../OpenCodeUsageParserTests.swift | 13 ++-- .../OpenRouterUsageStatsTests.swift | 16 ++--- Tests/CodexBarTests/PathBuilderTests.swift | 29 ++++---- .../PreferencesPaneSmokeTests.swift | 5 +- .../ProviderCandidateRetryRunnerTests.swift | 9 ++- .../ProviderConfigEnvironmentTests.swift | 13 ++-- .../ProviderIconResourcesTests.swift | 3 +- .../ProviderMetadataStatusLinkTests.swift | 3 +- .../CodexBarTests/ProviderRegistryTests.swift | 5 +- .../ProviderSettingsDescriptorTests.swift | 15 ++--- .../ProviderToggleStoreTests.swift | 7 +- .../ProviderTokenResolverTests.swift | 17 +++-- .../ProviderVersionDetectorTests.swift | 25 +++++++ .../ProvidersPaneCoverageTests.swift | 9 ++- .../SessionQuotaNotificationLogicTests.swift | 11 ++- .../SettingsStoreAdditionalTests.swift | 13 ++-- .../SettingsStoreCoverageTests.swift | 27 ++++---- Tests/CodexBarTests/SettingsStoreTests.swift | 67 ++++++++++--------- .../StatusItemAnimationTests.swift | 43 +++++------- .../StatusItemControllerMenuTests.swift | 13 ++-- Tests/CodexBarTests/StatusMenuTests.swift | 39 ++++++----- Tests/CodexBarTests/StatusProbeTests.swift | 57 ++++++++-------- .../CodexBarTests/SubprocessRunnerTests.swift | 3 +- .../SubscriptionDetectionTests.swift | 17 +++-- .../SyntheticProviderTests.swift | 10 ++- .../CodexBarTests/TTYCommandRunnerTests.swift | 24 +++---- Tests/CodexBarTests/TTYIntegrationTests.swift | 4 +- Tests/CodexBarTests/TextParsingTests.swift | 5 +- ...kenAccountEnvironmentPrecedenceTests.swift | 11 ++- .../TokenAccountStoreTests.swift | 8 +-- Tests/CodexBarTests/UpdateChannelTests.swift | 7 +- Tests/CodexBarTests/UsageFormatterTests.swift | 51 +++++++------- Tests/CodexBarTests/UsagePaceTests.swift | 9 ++- Tests/CodexBarTests/UsagePaceTextTests.swift | 9 ++- .../UsageStoreCoverageTests.swift | 13 ++-- .../UsageStoreHighestUsageTests.swift | 13 ++-- .../UsageStorePathDebugTests.swift | 3 +- ...sageStoreSessionQuotaTransitionTests.swift | 5 +- .../CodexBarTests/WarpUsageFetcherTests.swift | 21 +++--- Tests/CodexBarTests/WebKitTeardownTests.swift | 3 +- Tests/CodexBarTests/WidgetSnapshotTests.swift | 7 +- .../CodexBarTests/ZaiAvailabilityTests.swift | 5 +- Tests/CodexBarTests/ZaiProviderTests.swift | 42 ++++++------ 179 files changed, 1115 insertions(+), 1189 deletions(-) create mode 100644 Tests/CodexBarTests/ProviderVersionDetectorTests.swift diff --git a/Sources/CodexBar/ClaudeLoginRunner.swift b/Sources/CodexBar/ClaudeLoginRunner.swift index dc13fd53b4..e9f89934f2 100644 --- a/Sources/CodexBar/ClaudeLoginRunner.swift +++ b/Sources/CodexBar/ClaudeLoginRunner.swift @@ -3,7 +3,7 @@ import Darwin import Foundation struct ClaudeLoginRunner { - enum Phase: Sendable { + enum Phase { case requesting case waitingBrowser } diff --git a/Sources/CodexBar/CursorLoginRunner.swift b/Sources/CodexBar/CursorLoginRunner.swift index 880722539c..f2b48f215d 100644 --- a/Sources/CodexBar/CursorLoginRunner.swift +++ b/Sources/CodexBar/CursorLoginRunner.swift @@ -7,15 +7,15 @@ import WebKit /// Captures session cookies after successful authentication. @MainActor final class CursorLoginRunner: NSObject { - enum Phase: Sendable { + enum Phase { case loading case waitingLogin case success case failed(String) } - struct Result: Sendable { - enum Outcome: Sendable { + struct Result { + enum Outcome { case success case cancelled case failed(String) diff --git a/Sources/CodexBar/HistoricalUsagePace.swift b/Sources/CodexBar/HistoricalUsagePace.swift index b018a18315..60befc4a02 100644 --- a/Sources/CodexBar/HistoricalUsagePace.swift +++ b/Sources/CodexBar/HistoricalUsagePace.swift @@ -1,16 +1,16 @@ import CodexBarCore import Foundation -enum HistoricalUsageWindowKind: String, Codable, Sendable { +enum HistoricalUsageWindowKind: String, Codable { case secondary } -enum HistoricalUsageRecordSource: String, Codable, Sendable { +enum HistoricalUsageRecordSource: String, Codable { case live case backfill } -struct HistoricalUsageRecord: Codable, Sendable { +struct HistoricalUsageRecord: Codable { let v: Int let provider: UsageProvider let windowKind: HistoricalUsageWindowKind @@ -57,13 +57,13 @@ struct HistoricalUsageRecord: Codable, Sendable { } } -struct HistoricalWeekProfile: Sendable { +struct HistoricalWeekProfile { let resetsAt: Date let windowMinutes: Int let curve: [Double] } -struct CodexHistoricalDataset: Sendable { +struct CodexHistoricalDataset { static let gridPointCount = 169 let weeks: [HistoricalWeekProfile] } diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 648a3fc5f4..15e103da1d 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -10,7 +10,7 @@ enum IconRenderer { private static let outputScale: CGFloat = 2 private static let canvasPx = Int(outputSize.width * outputScale) - private struct PixelGrid: Sendable { + private struct PixelGrid { let scale: CGFloat func pt(_ px: Int) -> CGFloat { @@ -87,7 +87,7 @@ enum IconRenderer { } } - private struct RectPx: Hashable, Sendable { + private struct RectPx: Hashable { let x: Int let y: Int let w: Int diff --git a/Sources/CodexBar/KeychainMigration.swift b/Sources/CodexBar/KeychainMigration.swift index edb9fc1ba4..51bf840ca3 100644 --- a/Sources/CodexBar/KeychainMigration.swift +++ b/Sources/CodexBar/KeychainMigration.swift @@ -8,7 +8,7 @@ enum KeychainMigration { private static let log = CodexBarLog.logger(LogCategories.keychainMigration) private static let migrationKey = "KeychainMigrationV1Completed" - struct MigrationItem: Hashable, Sendable { + struct MigrationItem: Hashable { let service: String let account: String? diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 789a517e11..34ac518466 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -5,7 +5,7 @@ import SwiftUI /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels. struct UsageMenuCardView: View { struct Model { - enum PercentStyle: String, Sendable { + enum PercentStyle: String { case left case used @@ -47,7 +47,7 @@ struct UsageMenuCardView: View { case error } - struct TokenUsageSection: Sendable { + struct TokenUsageSection { let sessionLine: String let monthLine: String let hintLine: String? @@ -55,7 +55,7 @@ struct UsageMenuCardView: View { let errorCopyText: String? } - struct ProviderCostSection: Sendable { + struct ProviderCostSection { let title: String let percentUsed: Double let spendLine: String diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift index 55d45fcbb9..0fa246d881 100644 --- a/Sources/CodexBar/PreferencesProviderErrorView.swift +++ b/Sources/CodexBar/PreferencesProviderErrorView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct ProviderErrorDisplay: Sendable { +struct ProviderErrorDisplay { let preview: String let full: String } diff --git a/Sources/CodexBar/Providers/Shared/ProviderRuntime.swift b/Sources/CodexBar/Providers/Shared/ProviderRuntime.swift index bac370d092..188543ed9a 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderRuntime.swift @@ -7,7 +7,7 @@ struct ProviderRuntimeContext { let store: UsageStore } -enum ProviderRuntimeAction: Sendable { +enum ProviderRuntimeAction { case forceSessionRefresh case openAIWebAccessToggled(Bool) } diff --git a/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift b/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift index 58c18de55c..86c2618ceb 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift @@ -1,7 +1,7 @@ import CodexBarCore import Foundation -struct TokenAccountOverride: Sendable { +struct TokenAccountOverride { let provider: UsageProvider let account: ProviderTokenAccount } diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 5b0ed851c8..962b5a61fa 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -2,7 +2,7 @@ import CodexBarCore import Foundation @preconcurrency import UserNotifications -enum SessionQuotaTransition: Equatable, Sendable { +enum SessionQuotaTransition: Equatable { case none case depleted case restored diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index ff3d640bd2..98e01406df 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -1,6 +1,6 @@ import Foundation -struct SettingsDefaultsState: Sendable { +struct SettingsDefaultsState { var refreshFrequency: RefreshFrequency var launchAtLogin: Bool var debugMenuEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index efe63ffd86..e92843bc52 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -277,7 +277,7 @@ extension StatusItemController { self.presentLoginAlert(title: info.title, message: info.message) } - struct LoginAlertInfo: Equatable, Sendable { + struct LoginAlertInfo: Equatable { let title: String let message: String } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f0ac611aa3..f7fb1163fe 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1322,7 +1322,7 @@ extension StatusItemController { } private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { - let ids: Set = [ + let ids: Set = [ "usageBreakdownChart", "creditsHistoryChart", "costHistoryChart", @@ -1334,7 +1334,7 @@ extension StatusItemController { } private func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { - let ids: Set = [ + let ids: Set = [ "usageBreakdownChart", "creditsHistoryChart", ] diff --git a/Sources/CodexBar/UpdateChannel.swift b/Sources/CodexBar/UpdateChannel.swift index d2b0a74df6..aabf87bc6b 100644 --- a/Sources/CodexBar/UpdateChannel.swift +++ b/Sources/CodexBar/UpdateChannel.swift @@ -1,6 +1,6 @@ import Foundation -enum UpdateChannel: String, CaseIterable, Codable, Sendable { +enum UpdateChannel: String, CaseIterable, Codable { case stable case beta diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index f533d636f0..94d1ed5653 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -2,7 +2,7 @@ import CodexBarCore import Foundation enum UsagePaceText { - struct WeeklyDetail: Sendable { + struct WeeklyDetail { let leftLabel: String let rightLabel: String? let expectedUsedPercent: Double diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 3e55ffa9ff..f8cfd2f87c 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -1,7 +1,7 @@ import CodexBarCore import Foundation -struct TokenAccountUsageSnapshot: Identifiable, Sendable { +struct TokenAccountUsageSnapshot: Identifiable { let id: UUID let account: ProviderTokenAccount let snapshot: UsageSnapshot? diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index dfa5771832..41e53ccd9b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -70,7 +70,7 @@ extension UsageStore { @MainActor @Observable final class UsageStore { - enum StartupBehavior: Sendable { + enum StartupBehavior { case automatic case full case testing diff --git a/Sources/CodexBarCLI/CLIExitCode.swift b/Sources/CodexBarCLI/CLIExitCode.swift index c8ca4a9256..d658552d4f 100644 --- a/Sources/CodexBarCLI/CLIExitCode.swift +++ b/Sources/CodexBarCLI/CLIExitCode.swift @@ -1,4 +1,4 @@ -enum ExitCode: Int32, Sendable { +enum ExitCode: Int32 { case success = 0 case failure = 1 case binaryNotFound = 2 diff --git a/Sources/CodexBarCLI/CLIOptions.swift b/Sources/CodexBarCLI/CLIOptions.swift index ae057cf7b6..3939aa4082 100644 --- a/Sources/CodexBarCLI/CLIOptions.swift +++ b/Sources/CodexBarCLI/CLIOptions.swift @@ -76,7 +76,7 @@ struct UsageOptions: CommanderParsable { var augmentDebug: Bool = false } -enum ProviderSelection: Sendable, ExpressibleFromArgument { +enum ProviderSelection: ExpressibleFromArgument { case single(UsageProvider) case both case all @@ -120,7 +120,7 @@ enum ProviderSelection: Sendable, ExpressibleFromArgument { } } -enum OutputFormat: String, Sendable, ExpressibleFromArgument { +enum OutputFormat: String, ExpressibleFromArgument { case text case json diff --git a/Sources/CodexBarCLI/CLIOutputPreferences.swift b/Sources/CodexBarCLI/CLIOutputPreferences.swift index e1e517319b..50f8bdd1d5 100644 --- a/Sources/CodexBarCLI/CLIOutputPreferences.swift +++ b/Sources/CodexBarCLI/CLIOutputPreferences.swift @@ -1,7 +1,7 @@ import Commander import Foundation -struct CLIOutputPreferences: Sendable { +struct CLIOutputPreferences { let format: OutputFormat let jsonOnly: Bool let pretty: Bool diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 7324fa8374..dc079e4577 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -2,7 +2,7 @@ import CodexBarCore import Commander import Foundation -struct TokenAccountCLISelection: Sendable { +struct TokenAccountCLISelection { let label: String? let index: Int? let allAccounts: Bool diff --git a/Sources/CodexBarCore/BrowserDetection.swift b/Sources/CodexBarCore/BrowserDetection.swift index 4cd2721ac9..74828cd5de 100644 --- a/Sources/CodexBarCore/BrowserDetection.swift +++ b/Sources/CodexBarCore/BrowserDetection.swift @@ -22,13 +22,13 @@ public final class BrowserDetection: Sendable { let timestamp: Date } - private enum ProbeKind: Int, Hashable, Sendable { + private enum ProbeKind: Int, Hashable { case appInstalled case usableProfileData case usableCookieStore } - private struct CacheKey: Hashable, Sendable { + private struct CacheKey: Hashable { let browser: Browser let kind: ProbeKind } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 60f5e75981..4293f19ce4 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -377,7 +377,7 @@ public struct CostUsageMonthlyReport: Sendable, Decodable { } } -private struct CostUsageLegacyTotals: Sendable, Decodable { +private struct CostUsageLegacyTotals: Decodable { let totalInputTokens: Int? let totalOutputTokens: Int? let cacheReadTokens: Int? diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 7208cfb135..cdab14aba3 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -196,7 +196,7 @@ public struct TTYCommandRunner { return resolvedTargets } - struct RollingBuffer: Sendable { + struct RollingBuffer { private let maxNeedle: Int private var tail = Data() diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 8f211c5c3d..cf3deda4a7 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -679,7 +679,7 @@ public struct OpenAIDashboardBrowserCookieImporter { cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") } - private struct Candidate: Sendable { + private struct Candidate { let label: String let cookies: [HTTPCookie] } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 04cdca243e..dba3a36fe7 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -244,7 +244,7 @@ public struct OpenAIDashboardFetcher { throw FetchError.noDashboardData(body: lastBody ?? "") } - struct CreditsHistoryWaitContext: Sendable { + struct CreditsHistoryWaitContext { let now: Date let anyDashboardSignalAt: Date? let creditsHeaderVisibleAt: Date? diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift index 3ef31c4cb7..43b1118c75 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift @@ -323,7 +323,7 @@ public struct AmpUsageFetcher: Sendable { } } - private struct ResponseInfo: Sendable { + private struct ResponseInfo { let statusCode: Int let url: String } diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageParser.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageParser.swift index d0a68fca34..69cf0bb48a 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageParser.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageParser.swift @@ -17,7 +17,7 @@ enum AmpUsageParser { updatedAt: now) } - private struct FreeTierUsage: Sendable { + private struct FreeTierUsage { let quota: Double let used: Double let hourlyReplenishment: Double diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index b4452b4c88..444ae97b64 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -294,7 +294,7 @@ public struct AntigravityStatusProbe: Sendable { // MARK: - Port detection - private struct ProcessInfoResult: Sendable { + private struct ProcessInfoResult { let pid: Int let extensionPort: Int? let csrfToken: String @@ -444,7 +444,7 @@ public struct AntigravityStatusProbe: Sendable { let body: [String: Any] } - private struct RequestContext: Sendable { + private struct RequestContext { let httpsPort: Int let httpPort: Int? let csrfToken: String diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift index 72cb6dd96e..f6a7e48ad5 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift @@ -11,7 +11,7 @@ extension ClaudeOAuthCredentialsStore { private static let securityBinaryPath = "/usr/bin/security" private static let securityCLIReadTimeout: TimeInterval = 1.5 - struct SecurityCLIReadRequest: Sendable { + struct SecurityCLIReadRequest { let account: String? } @@ -23,14 +23,14 @@ extension ClaudeOAuthCredentialsStore { } #if os(macOS) - private enum SecurityCLIReadError: Error, Sendable { + private enum SecurityCLIReadError: Error { case binaryUnavailable case launchFailed case timedOut case nonZeroExit(status: Int32, stderrLength: Int) } - private struct SecurityCLIReadCommandResult: Sendable { + private struct SecurityCLIReadCommandResult { let status: Int32 let stdout: Data let stderrLength: Int diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift index 8d76f07a92..1fb1e56d65 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift @@ -102,7 +102,7 @@ extension ClaudeOAuthCredentialsStore { } } - enum SecurityCLIReadOverride: Sendable { + enum SecurityCLIReadOverride { case data(Data?) case timedOut case nonZeroExit diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index e3e52c51f1..72d592770e 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -41,18 +41,18 @@ public enum ClaudeOAuthCredentialsStore { private static let claudeKeychainChangeCheckMinimumInterval: TimeInterval = 60 private static let reauthenticateHint = "Run `claude` to re-authenticate." - struct ClaudeKeychainFingerprint: Codable, Equatable, Sendable { + struct ClaudeKeychainFingerprint: Codable, Equatable { let modifiedAt: Int? let createdAt: Int? let persistentRefHash: String? } - struct CredentialsFileFingerprint: Codable, Equatable, Sendable { + struct CredentialsFileFingerprint: Codable, Equatable { let modifiedAtMs: Int? let size: Int } - struct CacheEntry: Codable, Sendable { + struct CacheEntry: Codable { let data: Data let storedAt: Date let owner: ClaudeOAuthCredentialOwner? @@ -1084,7 +1084,7 @@ public enum ClaudeOAuthCredentialsStore { } #if os(macOS) - private struct ClaudeKeychainCandidate: Sendable { + private struct ClaudeKeychainCandidate { let persistentRef: Data let account: String? let modifiedAt: Date? @@ -1730,7 +1730,7 @@ extension ClaudeOAuthCredentialsStore { rateLimitTier: existingRateLimitTier) } - private enum RefreshFailureDisposition: String, Sendable { + private enum RefreshFailureDisposition: String { case terminalInvalidGrant case transientBackoff } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index 07345f1479..82dbdfed25 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -175,7 +175,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { try await ClaudeStatusProbe.touchOAuthAuthPath(timeout: timeout) } - private enum KeychainChangeObservationBaseline: Sendable { + private enum KeychainChangeObservationBaseline { case securityFramework(fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) case securityCLI(data: Data?) } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift index 59aa13241a..e9caa25a53 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift @@ -9,7 +9,7 @@ public enum ClaudeOAuthRefreshFailureGate { case transient(until: Date, failures: Int) } - struct AuthFingerprint: Codable, Equatable, Sendable { + struct AuthFingerprint: Codable, Equatable { let keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint? let credentialsFile: String? } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 5899eb189e..22003fb237 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -108,7 +108,7 @@ enum ClaudeOAuthUsageFetcher { } } -struct OAuthUsageResponse: Decodable, Sendable { +struct OAuthUsageResponse: Decodable { let fiveHour: OAuthUsageWindow? let sevenDay: OAuthUsageWindow? let sevenDayOAuthApps: OAuthUsageWindow? @@ -128,7 +128,7 @@ struct OAuthUsageResponse: Decodable, Sendable { } } -struct OAuthUsageWindow: Decodable, Sendable { +struct OAuthUsageWindow: Decodable { let utilization: Double? let resetsAt: String? @@ -138,7 +138,7 @@ struct OAuthUsageWindow: Decodable, Sendable { } } -struct OAuthExtraUsage: Decodable, Sendable { +struct OAuthExtraUsage: Decodable { let isEnabled: Bool? let monthlyLimit: Double? let usedCredits: Double? diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 989db8e060..ce48d24c10 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -72,7 +72,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { ProcessInfo.processInfo.environment["CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW"] == "1" } - private struct ClaudeOAuthKeychainPromptPolicy: Sendable { + private struct ClaudeOAuthKeychainPromptPolicy { let mode: ClaudeOAuthKeychainPromptMode let isApplicable: Bool let interaction: ProviderInteraction diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index e004b47c12..a419243606 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -719,7 +719,7 @@ public enum ClaudeWebAPIFetcher { return nil } - private struct ProbeParseResult: Sendable { + private struct ProbeParseResult { let keys: [String] let emails: [String] let planHints: [String] diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index b1c8e91c8b..2e03d51a31 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -43,7 +43,7 @@ public struct CodexWebDashboardStrategy: ProviderFetchStrategy { } } -private struct OpenAIWebCodexResult: Sendable { +private struct OpenAIWebCodexResult { let usage: UsageSnapshot let credits: CreditsSnapshot? let dashboard: OpenAIDashboardSnapshot @@ -60,7 +60,7 @@ private enum OpenAIWebCodexError: LocalizedError { } } -private struct OpenAIWebOptions: Sendable { +private struct OpenAIWebOptions { let timeout: TimeInterval let debugDumpHTML: Bool let verbose: Bool diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryLocalStorageImporter.swift b/Sources/CodexBarCore/Providers/Factory/FactoryLocalStorageImporter.swift index b245c6981a..08f0cde2ab 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryLocalStorageImporter.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryLocalStorageImporter.swift @@ -6,7 +6,7 @@ import SweetCookieKit #if os(macOS) enum FactoryLocalStorageImporter { - struct TokenInfo: Sendable { + struct TokenInfo { let refreshToken: String let accessToken: String? let organizationID: String? @@ -65,12 +65,12 @@ enum FactoryLocalStorageImporter { // MARK: - Chrome local storage discovery - private enum LocalStorageSourceKind: Sendable { + private enum LocalStorageSourceKind { case chromeLevelDB(URL) case safariSQLite(URL) } - private struct LocalStorageCandidate: Sendable { + private struct LocalStorageCandidate { let label: String let kind: LocalStorageSourceKind } @@ -192,7 +192,7 @@ enum FactoryLocalStorageImporter { // MARK: - Token extraction - private struct WorkOSTokenMatch: Sendable { + private struct WorkOSTokenMatch { let refreshToken: String let accessToken: String? let organizationID: String? diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index b4994a1d14..f0766927a8 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -590,7 +590,7 @@ public struct FactoryStatusProbe: Sendable { "client_01HNM792M5G5G1A2THWPXKFMXB", ] - private struct WorkOSAuthResponse: Decodable, Sendable { + private struct WorkOSAuthResponse: Decodable { let access_token: String let refresh_token: String? let organization_id: String? @@ -685,7 +685,7 @@ public struct FactoryStatusProbe: Sendable { throw FactoryStatusProbeError.noSessionCookie } - private enum FetchAttemptResult: Sendable { + private enum FetchAttemptResult { case success(FactoryStatusSnapshot) case failure(Error) case skipped diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index f906ea7c2d..d978630aa9 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -360,7 +360,7 @@ public struct GeminiStatusProbe: Sendable { return nil } - private struct CodeAssistStatus: Sendable { + private struct CodeAssistStatus { let tier: GeminiUserTierId? let projectId: String? diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 2e90d136ab..2855284850 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -110,7 +110,7 @@ public struct KiroStatusProbe: Sendable { return try self.parse(output: output) } - private struct KiroCLIResult: Sendable { + private struct KiroCLIResult { let stdout: String let stderr: String let terminationStatus: Int32 diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxLocalStorageImporter.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxLocalStorageImporter.swift index fcd1ebcce6..51f92891d3 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxLocalStorageImporter.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxLocalStorageImporter.swift @@ -5,7 +5,7 @@ import SweetCookieKit #if os(macOS) enum MiniMaxLocalStorageImporter { - struct TokenInfo: Sendable { + struct TokenInfo { let accessToken: String let groupID: String? let sourceLabel: String @@ -106,21 +106,21 @@ enum MiniMaxLocalStorageImporter { // MARK: - Chrome local storage discovery - private enum LocalStorageSourceKind: Sendable { + private enum LocalStorageSourceKind { case chromeLevelDB(URL) } - private struct LocalStorageCandidate: Sendable { + private struct LocalStorageCandidate { let label: String let kind: LocalStorageSourceKind } - private struct SessionStorageCandidate: Sendable { + private struct SessionStorageCandidate { let label: String let url: URL } - private struct IndexedDBCandidate: Sendable { + private struct IndexedDBCandidate { let label: String let url: URL } @@ -335,7 +335,7 @@ enum MiniMaxLocalStorageImporter { // MARK: - Token extraction - private struct LocalStorageSnapshot: Sendable { + private struct LocalStorageSnapshot { let tokens: [String] let groupID: String? } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 5bf0a9c525..762177efe6 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -217,17 +217,17 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { false } - private struct TokenContext: Sendable { + private struct TokenContext { let tokensByLabel: [String: [String]] let groupIDByLabel: [String: String] } - private struct FetchContext: Sendable { + private struct FetchContext { let region: MiniMaxAPIRegion let environment: [String: String] } - private enum FetchAttemptResult: Sendable { + private enum FetchAttemptResult { case success(MiniMaxUsageSnapshot) case failure(Error) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index debe082886..93edc6d6c6 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -8,7 +8,7 @@ public struct MiniMaxUsageFetcher: Sendable { private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let codingPlanRemainsPath = "v1/api/openplatform/coding_plan/remains" - private struct RemainsContext: Sendable { + private struct RemainsContext { let authorizationToken: String? let groupID: String? } @@ -325,7 +325,7 @@ public struct MiniMaxUsageFetcher: Sendable { } } -struct MiniMaxCodingPlanPayload: Decodable, Sendable { +struct MiniMaxCodingPlanPayload: Decodable { let baseResp: MiniMaxBaseResponse? let data: MiniMaxCodingPlanData @@ -346,7 +346,7 @@ struct MiniMaxCodingPlanPayload: Decodable, Sendable { } } -struct MiniMaxCodingPlanData: Decodable, Sendable { +struct MiniMaxCodingPlanData: Decodable { let baseResp: MiniMaxBaseResponse? let currentSubscribeTitle: String? let planName: String? @@ -377,11 +377,11 @@ struct MiniMaxCodingPlanData: Decodable, Sendable { } } -struct MiniMaxComboCard: Decodable, Sendable { +struct MiniMaxComboCard: Decodable { let title: String? } -struct MiniMaxModelRemains: Decodable, Sendable { +struct MiniMaxModelRemains: Decodable { let currentIntervalTotalCount: Int? let currentIntervalUsageCount: Int? let startTime: Int? @@ -406,7 +406,7 @@ struct MiniMaxModelRemains: Decodable, Sendable { } } -struct MiniMaxBaseResponse: Decodable, Sendable { +struct MiniMaxBaseResponse: Decodable { let statusCode: Int? let statusMessage: String? diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 9e5c4b0689..86bcb9ea5f 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -228,12 +228,12 @@ public struct OllamaUsageFetcher: Sendable { private static let settingsURL = URL(string: "https://ollama.com/settings")! @MainActor private static var recentDumps: [String] = [] - private struct CookieCandidate: Sendable { + private struct CookieCandidate { let cookieHeader: String let sourceLabel: String } - enum RetryableParseFailure: Error, Sendable { + enum RetryableParseFailure: Error { case missingUsageData } @@ -568,7 +568,7 @@ public struct OllamaUsageFetcher: Sendable { } } - private struct ResponseInfo: Sendable { + private struct ResponseInfo { let statusCode: Int let url: String } diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift index f93f358649..0a15badcf6 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift @@ -3,12 +3,12 @@ import Foundation enum OllamaUsageParser { private static let primaryUsageLabels = ["Session usage", "Hourly usage"] - enum ParseFailure: Sendable, Equatable { + enum ParseFailure: Equatable { case notLoggedIn case missingUsageData } - enum ClassifiedParseResult: Sendable { + enum ClassifiedParseResult { case success(OllamaUsageSnapshot) case failure(ParseFailure) } @@ -47,7 +47,7 @@ enum OllamaUsageParser { updatedAt: now)) } - private struct UsageBlock: Sendable { + private struct UsageBlock { let usedPercent: Double let resetsAt: Date? } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index ed27f1d4f7..48d90e27b9 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -581,7 +581,7 @@ public struct OpenCodeUsageFetcher: Sendable { updatedAt: now) } - private struct WindowCandidate: Sendable { + private struct WindowCandidate { let id: UUID let percent: Double let resetInSec: Int diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index fbcc3e44c5..251e2c47d7 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -266,7 +266,7 @@ public struct OpenRouterUsageFetcher: Sendable { } /// Fetches key quota/rate-limit info from /key endpoint - private struct OpenRouterKeyFetchResult: Sendable { + private struct OpenRouterKeyFetchResult { let data: OpenRouterKeyData? let fetched: Bool } diff --git a/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift b/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift index 5b20482362..f3009d24c8 100644 --- a/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift +++ b/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift @@ -1,6 +1,6 @@ import Foundation -enum ProviderCandidateRetryRunnerError: Error, Sendable { +enum ProviderCandidateRetryRunnerError: Error { case noCandidates } diff --git a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift index 15dd131daa..0c9197462a 100644 --- a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift +++ b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift @@ -1,3 +1,8 @@ +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif import Foundation public enum ProviderVersionDetector { @@ -45,13 +50,19 @@ public enum ProviderVersionDetector { return nil } - private static func run(path: String, args: [String]) -> String? { + static func run(path: String, args: [String], timeout: TimeInterval = 2.0) -> String? { let proc = Process() proc.executableURL = URL(fileURLWithPath: path) proc.arguments = args let out = Pipe() proc.standardOutput = out proc.standardError = Pipe() + proc.standardInput = nil + + let exitSemaphore = DispatchSemaphore(value: 0) + proc.terminationHandler = { _ in + exitSemaphore.signal() + } do { try proc.run() @@ -59,19 +70,9 @@ public enum ProviderVersionDetector { return nil } - let deadline = Date().addingTimeInterval(2.0) - while proc.isRunning, Date() < deadline { - usleep(50000) - } - if proc.isRunning { - proc.terminate() - let killDeadline = Date().addingTimeInterval(0.5) - while proc.isRunning, Date() < killDeadline { - usleep(20000) - } - if proc.isRunning { - kill(proc.processIdentifier, SIGKILL) - } + let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success + if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) { + return nil } let data = out.fileHandleForReading.readDataToEndOfFile() @@ -82,4 +83,17 @@ public enum ProviderVersionDetector { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } + + private static func forceExit(_ proc: Process, exitSemaphore: DispatchSemaphore) -> Bool { + guard proc.isRunning else { return true } + + proc.terminate() + if exitSemaphore.wait(timeout: .now() + 0.5) == .success { + return true + } + + guard proc.isRunning else { return true } + kill(proc.processIdentifier, SIGKILL) + return exitSemaphore.wait(timeout: .now() + 1.0) == .success + } } diff --git a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift index 79ec2e8f0b..c5e2bf8a41 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift @@ -293,13 +293,13 @@ public struct WarpUsageFetcher: Sendable { bonusNextExpirationRemaining: bonus.nextExpirationRemaining) } - private struct BonusGrant: Sendable { + private struct BonusGrant { let granted: Int let remaining: Int let expiration: Date? } - private struct BonusSummary: Sendable { + private struct BonusSummary { let remaining: Int let total: Int let nextExpiration: Date? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 2ea800cb53..cb4a872e2b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -53,7 +53,7 @@ enum CostUsageCacheIO { } } -struct CostUsageCache: Codable, Sendable { +struct CostUsageCache: Codable { var version: Int = 1 var lastScanUnixMs: Int64 = 0 @@ -67,7 +67,7 @@ struct CostUsageCache: Codable, Sendable { var roots: [String: Int64]? } -struct CostUsageFileUsage: Codable, Sendable { +struct CostUsageFileUsage: Codable { var mtimeUnixMs: Int64 var size: Int64 var days: [String: [String: [Int]]] @@ -77,7 +77,7 @@ struct CostUsageFileUsage: Codable, Sendable { var sessionId: String? } -struct CostUsageCodexTotals: Codable, Sendable { +struct CostUsageCodexTotals: Codable { var input: Int var cached: Int var output: Int diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index 5105f97593..5929e5b07a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -1,7 +1,7 @@ import Foundation enum CostUsageJsonl { - struct Line: Sendable { + struct Line { let bytes: Data let wasTruncated: Bool } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 36ad430e8d..92e36f95f4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1,14 +1,14 @@ import Foundation enum CostUsagePricing { - struct CodexPricing: Sendable { + struct CodexPricing { let inputCostPerToken: Double let outputCostPerToken: Double let cacheReadInputCostPerToken: Double? let displayLabel: String? } - struct ClaudePricing: Sendable { + struct ClaudePricing { let inputCostPerToken: Double let outputCostPerToken: Double let cacheCreationInputCostPerToken: Double diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a5ef942b51..76ceb20c70 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1,13 +1,13 @@ import Foundation enum CostUsageScanner { - enum ClaudeLogProviderFilter: Sendable { + enum ClaudeLogProviderFilter { case all case vertexAIOnly case excludeVertexAI } - struct Options: Sendable { + struct Options { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? var cacheRoot: URL? @@ -31,7 +31,7 @@ enum CostUsageScanner { } } - struct CodexParseResult: Sendable { + struct CodexParseResult { let days: [String: [String: [Int]]] let parsedBytes: Int64 let lastModel: String? @@ -44,7 +44,7 @@ enum CostUsageScanner { var seenFileIds: Set = [] } - struct ClaudeParseResult: Sendable { + struct ClaudeParseResult { let days: [String: [String: [Int]]] let parsedBytes: Int64 } @@ -78,7 +78,7 @@ enum CostUsageScanner { // MARK: - Day keys - struct CostUsageDayRange: Sendable { + struct CostUsageDayRange { let sinceKey: String let untilKey: String let scanSinceKey: String diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index 8a4e8506cb..135f58ee4f 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -2,24 +2,23 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct AmpUsageFetcherTests { @Test - func attachesCookieForAmpHosts() { + func `attaches cookie for amp hosts`() { #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com/settings"))) #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ampcode.com"))) #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ampcode.com/path"))) } @Test - func rejectsNonAmpHosts() { + func `rejects non amp hosts`() { #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com"))) #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com.evil.com"))) #expect(!AmpUsageFetcher.shouldAttachCookie(to: nil)) } @Test - func detectsLoginRedirects() throws { + func `detects login redirects`() throws { let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(signIn)) @@ -34,7 +33,7 @@ struct AmpUsageFetcherTests { } @Test - func ignoresNonLoginURLs() throws { + func `ignores non login UR ls`() throws { let settings = try #require(URL(string: "https://ampcode.com/settings")) #expect(!AmpUsageFetcher.isLoginRedirect(settings)) diff --git a/Tests/CodexBarTests/AmpUsageParserTests.swift b/Tests/CodexBarTests/AmpUsageParserTests.swift index 89ab59bdc3..380b06e49a 100644 --- a/Tests/CodexBarTests/AmpUsageParserTests.swift +++ b/Tests/CodexBarTests/AmpUsageParserTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct AmpUsageParserTests { @Test - func parsesFreeTierUsageFromSettingsHTML() throws { + func `parses free tier usage from settings HTML`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """ + + + """ + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int, + contentType: String) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (response, Data(body.utf8)) + } +} + +final class OpenCodeGoStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "opencode.ai" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift new file mode 100644 index 0000000000..d2bcfd1700 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -0,0 +1,214 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoUsageParserTests { + @Test + func `parses workspace ids`() { + let text = ";0x00000089;((self.$R=self.$R||{})[\"codexbar\"]=[]," + + "($R=>$R[0]=[$R[1]={id:\"wrk_01K6AR1ZET89H8NB691FQ2C2VB\",name:\"Default\",slug:null}])" + + "($R[\"codexbar\"]))" + let ids = OpenCodeGoUsageFetcher.parseWorkspaceIDs(text: text) + #expect(ids == ["wrk_01K6AR1ZET89H8NB691FQ2C2VB"]) + } + + @Test + func `parses subscription usage from seroval response`() throws { + let text = + "$R[16]($R[30],$R[41]={rollingUsage:$R[42]={status:\"ok\",resetInSec:5944,usagePercent:17}," + + "weeklyUsage:$R[43]={status:\"ok\",resetInSec:278201,usagePercent:75}," + + "monthlyUsage:$R[44]={status:\"ok\",resetInSec:880201,usagePercent:91}});" + let now = Date(timeIntervalSince1970: 0) + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 91) + #expect(snapshot.rollingResetInSec == 5944) + #expect(snapshot.weeklyResetInSec == 278_201) + #expect(snapshot.monthlyResetInSec == 880_201) + } + + @Test + func `parses subscription usage from live go page hydration`() throws { + let rollingResetInSec = 17591 + let weeklyResetInSec = 444_552 + let monthlyResetInSec = 2_591_424 + let text = + "_$HY.r[\"lite.subscription.get[\\\"wrk_LIVE123\\\"]\"]=$R[17]=$R[2]($R[18]={p:0,s:0,f:0});" + + "$R[24]($R[18],$R[27]={mine:!0,useBalance:!1," + + "rollingUsage:$R[28]={status:\"ok\",resetInSec:\(rollingResetInSec),usagePercent:0}," + + "weeklyUsage:$R[29]={status:\"ok\",resetInSec:\(weeklyResetInSec),usagePercent:0}," + + "monthlyUsage:$R[30]={status:\"ok\",resetInSec:\(monthlyResetInSec),usagePercent:0}});" + let now = Date(timeIntervalSince1970: 0) + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 0) + #expect(snapshot.weeklyUsagePercent == 0) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 0) + #expect(snapshot.rollingResetInSec == rollingResetInSec) + #expect(snapshot.weeklyResetInSec == weeklyResetInSec) + #expect(snapshot.monthlyResetInSec == monthlyResetInSec) + } + + @Test + func `parses subscription from JSON with reset at and ratio percentages`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let rollingResetAt = now.addingTimeInterval(3600) + let monthlyResetAt = now.addingTimeInterval(86400) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "usage": [ + "rollingUsage": [ + "usagePercent": 0.25, + "resetAt": formatter.string(from: rollingResetAt), + ], + "weeklyUsage": [ + "usagePercent": 75, + "resetInSec": 7200, + ], + "monthlyUsage": [ + "usagePercent": 0.9, + "resetAt": formatter.string(from: monthlyResetAt), + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 90) + #expect(snapshot.rollingResetInSec == 3600) + #expect(snapshot.weeklyResetInSec == 7200) + #expect(snapshot.monthlyResetInSec == 86400) + } + + @Test + func `computes usage percent from totals and treats monthly as optional`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": [ + "used": 25, + "limit": 100, + "resetInSec": 600, + ], + "weeklyUsage": [ + "used": 50, + "limit": 200, + "resetInSec": 3600, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 25) + #expect(snapshot.hasMonthlyUsage == false) + #expect(snapshot.monthlyUsagePercent == 0) + #expect(snapshot.monthlyResetInSec == 0) + #expect(usage.tertiary == nil) + } + + @Test + func `parses subscription from nested candidate windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "windows": [ + "primaryWindow": [ + "used": 15, + "limit": 100, + "resetInSec": 600, + ], + "weeklyQuota": [ + "used": 80, + "limit": 200, + "resetInSec": 7200, + ], + "monthlyBucket": [ + "used": 90, + "limit": 300, + "resetInSec": 86400, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 15) + #expect(snapshot.weeklyUsagePercent == 40) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 30) + #expect(snapshot.monthlyResetInSec == 86400) + } + + @Test + func `candidate fallback does not fabricate weekly from non weekly windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "windows": [ + "primaryWindow": [ + "used": 15, + "limit": 100, + "resetInSec": 600, + ], + "monthlyBucket": [ + "used": 90, + "limit": 300, + "resetInSec": 86400, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(throws: OpenCodeGoUsageError.self) { + _ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + } + } + + @Test + func `clamps invalid percentages`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": [ + "usagePercent": 150, + "resetInSec": 60, + ], + "weeklyUsage": [ + "usagePercent": -10, + "resetInSec": 120, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 100) + #expect(snapshot.weeklyUsagePercent == 0) + } + + @Test + func `parse subscription throws when required fields are missing`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let text = "{\"monthlyUsage\":{\"usagePercent\":50,\"resetInSec\":123}}" + + #expect(throws: OpenCodeGoUsageError.self) { + _ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + } + } +} diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift index fcd638b1b2..073b72f98a 100644 --- a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift @@ -4,13 +4,15 @@ import Testing @Suite(.serialized) struct OpenCodeUsageFetcherErrorTests { + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OpenCodeStubURLProtocol.self] + return URLSession(configuration: config) + } + @Test func `extracts api error from uppercase HTML title`() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -24,7 +26,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -39,11 +42,7 @@ struct OpenCodeUsageFetcherErrorTests { @Test func `extracts api error from detail field`() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -57,7 +56,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -72,11 +72,7 @@ struct OpenCodeUsageFetcherErrorTests { @Test func `subscription get null skips post and returns graceful error`() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -103,7 +99,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -124,11 +121,7 @@ struct OpenCodeUsageFetcherErrorTests { @Test func `subscription get payload does not fallback to post`() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -149,7 +142,8 @@ struct OpenCodeUsageFetcherErrorTests { let snapshot = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) #expect(snapshot.rollingUsagePercent == 17) #expect(snapshot.weeklyUsagePercent == 75) @@ -158,11 +152,7 @@ struct OpenCodeUsageFetcherErrorTests { @Test func `subscription get missing fields falls back to post`() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -195,13 +185,43 @@ struct OpenCodeUsageFetcherErrorTests { let snapshot = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) #expect(snapshot.rollingUsagePercent == 22) #expect(snapshot.weeklyUsagePercent == 44) #expect(methods == ["GET", "POST"]) } + @Test + func `fetcher sends only auth cookie to opencode host`() async throws { + defer { + OpenCodeStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + + let body = """ + { + "rollingUsage": { "usagePercent": 17, "resetInSec": 600 }, + "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } + } + """ + return Self.makeResponse(url: url, body: body, statusCode: 200, contentType: "application/json") + } + + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(observedCookie == "auth=test") + } + private static func makeResponse( url: URL, body: String, diff --git a/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift b/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift new file mode 100644 index 0000000000..be73d4e50c --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodexBarCore + +struct OpenCodeWebCookieSupportTests { + @Test + func `request cookie header keeps only opencode auth cookies`() { + let header = OpenCodeWebCookieSupport.requestCookieHeader( + from: "provider=google; auth=session123; theme=dark; __Host-auth=host456") + + #expect(header == "auth=session123; __Host-auth=host456") + } + + @Test + func `request cookie header returns nil when auth cookie is missing`() { + let header = OpenCodeWebCookieSupport.requestCookieHeader(from: "provider=google; theme=dark") + + #expect(header == nil) + } +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 1a6127e747..7189d0c2ab 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -16,6 +16,7 @@ struct ProviderIconResourcesTests { "minimax", "cursor", "opencode", + "opencodego", "alibaba", "gemini", "antigravity", diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 18cc20714c..2722d41d94 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -89,6 +89,36 @@ struct ProvidersPaneCoverageTests { #expect(row?.value == "Pro") } + @Test + func `opencode manual cookie source hides cached browser trailing text`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-opencode-manual") + let store = Self.makeUsageStore(settings: settings) + settings.opencodeCookieSource = .manual + CookieHeaderCache.store(provider: .opencode, cookieHeader: "auth=cache", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .opencode) } + + let pane = ProvidersPane(settings: settings, store: store) + let picker = pane._test_settingsPickers(for: .opencode).first { $0.id == "opencode-cookie-source" } + + #expect(picker?.dynamicSubtitle?() == "Paste a Cookie header captured from the billing page.") + #expect(picker?.trailingText?() == nil) + } + + @Test + func `opencode go manual cookie source hides cached browser trailing text`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-opencodego-manual") + let store = Self.makeUsageStore(settings: settings) + settings.opencodegoCookieSource = .manual + CookieHeaderCache.store(provider: .opencodego, cookieHeader: "auth=cache", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .opencodego) } + + let pane = ProvidersPane(settings: settings, store: store) + let picker = pane._test_settingsPickers(for: .opencodego).first { $0.id == "opencodego-cookie-source" } + + #expect(picker?.dynamicSubtitle?() == "Paste a Cookie header captured from the billing page.") + #expect(picker?.trailingText?() == nil) + } + @Test func `codex providers pane uses managed account fallback instead of ambient account`() throws { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-codex-managed-fallback") diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 92578c83b1..e870d31247 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -131,6 +131,28 @@ struct SettingsStoreCoverageTests { #expect(snapshot.manualCookieHeader?.isEmpty == true) } + @Test + func `opencode go token accounts force manual cookie routing`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .opencodego, label: "Go", token: "auth=go-cookie") + + let snapshot = settings.opencodegoSettingsSnapshot(tokenOverride: nil) + + #expect(settings.opencodegoCookieSource == .manual) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "auth=go-cookie") + } + + @Test + func `opencode go snapshot preserves nil workspace id when settings are unset`() { + let settings = Self.makeSettingsStore() + + let snapshot = settings.opencodegoSettingsSnapshot(tokenOverride: nil) + + #expect(settings.opencodegoWorkspaceID.isEmpty) + #expect(snapshot.workspaceID == nil) + } + @Test func `token cost usage source detection`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index b00fc9796e..7061c9be55 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -778,6 +778,7 @@ struct SettingsStoreTests { .claude, .cursor, .opencode, + .opencodego, .alibaba, .factory, .antigravity, From f2eda48aedd79fbac11ac1e5861fc8247b215100 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 00:49:30 +0100 Subject: [PATCH 0126/1239] test: shorten subprocess timeout fixtures --- Tests/CodexBarTests/SubprocessRunnerTests.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index cb81ed88e7..c133f657ca 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -28,7 +28,7 @@ struct SubprocessRunnerTests { do { _ = try await SubprocessRunner.run( binary: "/bin/sleep", - arguments: ["30"], + arguments: ["5"], environment: ProcessInfo.processInfo.environment, timeout: 1, label: "hung-process-test") @@ -44,8 +44,8 @@ struct SubprocessRunnerTests { } let elapsed = Date().timeIntervalSince(start) - // Must complete in well under 30s (the sleep duration). Allow generous bound for CI. - #expect(elapsed < 10, "Timeout should fire in ~1s, not wait for process to exit naturally") + // Must complete in well under 5s (the sleep duration). Allow generous bound for CI. + #expect(elapsed < 3, "Timeout should fire in ~1s, not wait for process to exit naturally") } /// Multiple concurrent hung subprocesses must all time out independently, proving that @@ -62,7 +62,7 @@ struct SubprocessRunnerTests { do { _ = try await SubprocessRunner.run( binary: "/bin/sleep", - arguments: ["30"], + arguments: ["5"], environment: ProcessInfo.processInfo.environment, timeout: 2, label: "concurrent-hung-\(i)") @@ -80,10 +80,10 @@ struct SubprocessRunnerTests { } let elapsed = Date().timeIntervalSince(start) - // All 8 should time out in ~2s (parallel), not 8×30s (sequential/starved). - // Use generous 15s bound for slow CI. + // All 8 should time out in ~2s (parallel), not wait for the 5s sleep. + // Use a generous 4s bound for slow CI. #expect( - elapsed < 15, + elapsed < 4, "All \(count) concurrent timeouts should fire in ~2s, took \(elapsed)s") } @@ -95,7 +95,7 @@ struct SubprocessRunnerTests { do { _ = try await SubprocessRunner.run( binary: "/bin/sleep", - arguments: ["30"], + arguments: ["1"], environment: ProcessInfo.processInfo.environment, timeout: 0.1, label: "race-stress-\(i)") From 77d6c6c18cccf89e8053059457b956d478877d63 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 01:15:24 +0100 Subject: [PATCH 0127/1239] docs: unify 0.20 changelog --- CHANGELOG.md | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b55ecfacb5..aedbfeac7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,37 +3,26 @@ ## 0.20 — 2026-04-07 ### Highlights -- Add support for switching between system Codex accounts/profiles. No need to manually logout/login between Codex accounts. @ratulsarna -- Add workspace attribution to codex accounts to allow for workspace labeling and same-email multi-workspace accounts. -- Improve Codex multi-account support with canonical identity reconciliation, workspace-aware attribution, safer OpenAI dashboard ownership. Thanks @monterrr for the help! +- Codex: switch between system accounts/profiles without manually logging out and back in. @ratulsarna +- Add Perplexity provider support with recurring, bonus, and purchased-credit tracking, Pro/Max plan detection, browser-cookie auto-import, and manual-cookie fallback (#449). Thanks @BeelixGit! - Add OpenCode Go as a separate provider with 5-hour, weekly, and monthly web usage tracking, widget integration, and browser-cookie support. +- Claude: fix token and cost inflation caused by cross-file double counting of subagent JSONL logs, fix streaming chunk deduplication, and add `claude-sonnet-4-6` pricing. Thanks @enzonaute for the investigation! +- Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman! ### Providers & Usage -- Claude: fix token and cost inflation caused by cross-file double counting of subagent JSONL logs, fix streaming chunk dedup to keep the final cumulative chunk instead of the first partial one, and add `claude-sonnet-4-6` pricing. Thanks @enzonaute for the investigation! -- Codex: reconcile live-system and managed accounts by canonical identity, preserve account-scoped usage/history/dashboard state, -- Codex: improve workspace-based account attribution, allow OAuth CLI fallback, and tighten OpenAI web ownership gating so quota and credits only attach to the matching account. +- Perplexity: add recurring, bonus, and purchased-credit tracking; plan detection for Pro/Max; browser-cookie auto-import; and manual-cookie fallback (#449). Thanks @BeelixGit! +- OpenCode Go: add a dedicated provider, parse live authenticated workspace Go usage from the web app, keep monthly optional and honor workspace env overrides. +- Codex: add workspace attribution for account labels and same-email multi-workspace accounts. +- Codex: reconcile live-system and managed accounts by canonical identity, preserve account-scoped usage/history/dashboard state, allow OAuth CLI fallback, and tighten OpenAI web ownership gating so quota and credits only attach to the matching account. Thanks @monterrr and @Rag30 for the initial effort and ideas! +- Codex: normalize weekly-only rate limits across OAuth and CLI/RPC so free-plan accounts render as Weekly instead of a fake Session, preserve unknown single-window payloads in the primary lane, hide the empty Session lane in widgets, and accept weekly-only Codex CLI `/status`/RPC data without failing. @ratulsarna - Codex: refactor the provider end to end into clearer components and better division of responsibilities. - OpenCode: preserve product separation between Zen and Go, improve null/unsupported usage handling, and harden cookie/domain behavior for authenticated web fetches. -- OpenCode Go: add a dedicated provider, parse live authenticated workspace Go usage from the web app, keep monthly optional and honor workspace env overrides. - Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman! ### Menu & Settings - Codex: add UI for switching the system-level Codex account and promoting a managed account into the live system slot. - Codex: hide display-only OpenAI web extras in widgets and fix buy-credits / credits-only presentation regressions. - Claude: enable “Avoid Keychain prompts” by default, remove the experimental label, and preserve user-action cooldown clearing plus startup bootstrap when Security.framework fallback is still needed. - -## 0.20.0-beta.1 — 2026-04-01 - -### Highlights -- Add basic multi-account support to Codex. Thanks @monterrr and @Rag30 for the initial effort and ideas! -- Add Perplexity provider with recurring, bonus, and purchased-credit tracking; plan detection (Pro/Max); and browser-cookie auto-import with manual-cookie fallback (#449). Thanks @BeelixGit! - -### Providers & Usage -- Add the foundation for multi-account support to Codex and basic UX for adding and switching accounts. @ratulsarna -- Codex: normalize weekly-only rate limits across OAuth and CLI/RPC so free-plan accounts render as Weekly instead of a fake Session, preserve unknown single-window payloads in the primary lane, hide the empty Session lane in widgets, and accept weekly-only Codex CLI `/status`/RPC data without failing. @ratulsarna -- Perplexity: add provider support with credit tracking for recurring (monthly), bonus (promotional), and purchased on-demand credits; plan detection (Pro/Max); and browser-cookie auto-import with manual-cookie fallback (#449). Thanks @BeelixGit! - -### Menu & Settings - Fix alignment of menu chart hover coordinates on macOS. Thanks @cuidong233! ## 0.19.0 — 2026-03-23 From a02b8d2c62b11f9b1a9f0509b214a0a508ca7acd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 01:21:09 +0100 Subject: [PATCH 0128/1239] style: apply SwiftFormat --- .../CodexAccountPromotionExecution.swift | 4 +-- .../CodexAccountPromotionPlanning.swift | 12 +++---- .../CodexAccountPromotionPreparation.swift | 14 ++++----- .../CodexAccountPromotionService.swift | 8 ++--- .../CodexBar/CodexAccountReconciliation.swift | 4 +-- Sources/CodexBar/CodexHistoryOwnership.swift | 2 +- Sources/CodexBar/CodexOwnershipContext.swift | 2 +- .../ManagedCodexAccountCoordinator.swift | 2 +- .../CodexBar/ManagedCodexAccountService.swift | 4 +-- .../Codex/CodexConsumerProjection.swift | 16 +++++----- .../Codex/UsageStore+CodexAccountState.swift | 4 +-- Sources/CodexBar/UsageStore.swift | 2 +- .../Codex/CodexAccountReconciliation.swift | 2 +- .../CodexOpenAIWorkspaceIdentityCache.swift | 2 +- .../Perplexity/PerplexityCookieImporter.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 4 +-- .../CodexAccountPromotionServiceTests.swift | 2 +- .../CodexAccountPromotionTestSupport.swift | 2 +- .../CodexAccountsSettingsSectionTests.swift | 4 +-- .../ManagedCodexAccountServiceTests.swift | 6 ++-- .../PerplexityCookieCacheTests.swift | 14 ++++----- .../PerplexityCookieHeaderTests.swift | 17 +++++----- .../PerplexityProviderTests.swift | 16 +++++----- .../PerplexityUsageFetcherTests.swift | 31 +++++++++---------- .../StatusMenuCodexSwitcherTests.swift | 4 +-- .../CodexBarTests/SubprocessRunnerTests.swift | 8 ++--- 26 files changed, 93 insertions(+), 95 deletions(-) diff --git a/Sources/CodexBar/CodexAccountPromotionExecution.swift b/Sources/CodexBar/CodexAccountPromotionExecution.swift index ba8b4a4d0d..25b8a27c72 100644 --- a/Sources/CodexBar/CodexAccountPromotionExecution.swift +++ b/Sources/CodexBar/CodexAccountPromotionExecution.swift @@ -1,12 +1,12 @@ import CodexBarCore import Foundation -private struct CodexPreparedImportedAccount: Sendable { +private struct CodexPreparedImportedAccount { let account: ManagedCodexAccount let homeURL: URL } -struct CodexDisplacedLivePreservationExecutionResult: Sendable, Equatable { +struct CodexDisplacedLivePreservationExecutionResult: Equatable { let displacedLiveDisposition: CodexAccountPromotionResult.DisplacedLiveDisposition } diff --git a/Sources/CodexBar/CodexAccountPromotionPlanning.swift b/Sources/CodexBar/CodexAccountPromotionPlanning.swift index f37e489714..ed54274858 100644 --- a/Sources/CodexBar/CodexAccountPromotionPlanning.swift +++ b/Sources/CodexBar/CodexAccountPromotionPlanning.swift @@ -1,34 +1,34 @@ import CodexBarCore import Foundation -enum CodexDisplacedLivePreservationNoneReason: Sendable, Equatable { +enum CodexDisplacedLivePreservationNoneReason: Equatable { case liveMissing case targetMatchesLiveAuthIdentity } -enum CodexDisplacedLivePreservationRejectReason: Sendable, Equatable { +enum CodexDisplacedLivePreservationRejectReason: Equatable { case liveUnreadable case liveAPIKeyOnlyUnsupported case liveIdentityMissingForPreservation case conflictingReadableManagedHome } -enum CodexDisplacedLivePreservationImportReason: Sendable, Equatable { +enum CodexDisplacedLivePreservationImportReason: Equatable { case noExistingManagedDestination } -enum CodexDisplacedLivePreservationRefreshReason: Sendable, Equatable { +enum CodexDisplacedLivePreservationRefreshReason: Equatable { case readableHomeIdentityMatch case readableHomeIdentityMatchUsingPersistedEmailFallback } -enum CodexDisplacedLivePreservationRepairReason: Sendable, Equatable { +enum CodexDisplacedLivePreservationRepairReason: Equatable { case persistedProviderMatchWithMissingHome case persistedProviderMatchWithUnreadableHome case persistedLegacyEmailMatch } -enum CodexDisplacedLivePreservationPlan: Sendable { +enum CodexDisplacedLivePreservationPlan { case none(reason: CodexDisplacedLivePreservationNoneReason) case reject(reason: CodexDisplacedLivePreservationRejectReason) case importNew(reason: CodexDisplacedLivePreservationImportReason) diff --git a/Sources/CodexBar/CodexAccountPromotionPreparation.swift b/Sources/CodexBar/CodexAccountPromotionPreparation.swift index 43fc6a387e..bb686a5c18 100644 --- a/Sources/CodexBar/CodexAccountPromotionPreparation.swift +++ b/Sources/CodexBar/CodexAccountPromotionPreparation.swift @@ -1,7 +1,7 @@ import CodexBarCore import Foundation -struct PreparedIdentity: Sendable, Equatable { +struct PreparedIdentity: Equatable { let email: String? let identity: CodexIdentity let providerAccountID: String? @@ -9,7 +9,7 @@ struct PreparedIdentity: Sendable, Equatable { let workspaceAccountID: String? } -struct PreparedAuthMaterial: Sendable { +struct PreparedAuthMaterial { let homeURL: URL let rawData: Data let credentials: CodexOAuthCredentials @@ -17,13 +17,13 @@ struct PreparedAuthMaterial: Sendable { let authIdentity: PreparedIdentity } -enum PreparedManagedHomeState: Sendable { +enum PreparedManagedHomeState { case readable(PreparedAuthMaterial) case missing(homeURL: URL) case unreadable(homeURL: URL) } -struct PreparedStoredManagedAccount: Sendable { +struct PreparedStoredManagedAccount { let persisted: ManagedCodexAccount let persistedIdentity: PreparedIdentity let homeState: PreparedManagedHomeState @@ -38,14 +38,14 @@ struct PreparedStoredManagedAccount: Sendable { } } -enum PreparedLiveHomeState: Sendable { +enum PreparedLiveHomeState { case missing(homeURL: URL) case unreadable(homeURL: URL) case apiKeyOnly(PreparedAuthMaterial) case readable(PreparedAuthMaterial) } -struct PreparedLiveAccount: Sendable { +struct PreparedLiveAccount { let homeState: PreparedLiveHomeState var homeURL: URL { @@ -67,7 +67,7 @@ struct PreparedLiveAccount: Sendable { } } -struct PreparedPromotionContext: Sendable { +struct PreparedPromotionContext { let snapshot: CodexAccountReconciliationSnapshot let managedAccounts: ManagedCodexAccountSet let storedManagedAccounts: [PreparedStoredManagedAccount] diff --git a/Sources/CodexBar/CodexAccountPromotionService.swift b/Sources/CodexBar/CodexAccountPromotionService.swift index b6a5755816..c8a4abb73e 100644 --- a/Sources/CodexBar/CodexAccountPromotionService.swift +++ b/Sources/CodexBar/CodexAccountPromotionService.swift @@ -114,13 +114,13 @@ struct UsageStoreCodexAccountScopedRefresher: CodexAccountScopedRefreshing { } } -struct CodexAccountPromotionResult: Sendable, Equatable { - enum Outcome: Sendable, Equatable { +struct CodexAccountPromotionResult: Equatable { + enum Outcome: Equatable { case promoted case convergedNoOp } - enum DisplacedLiveDisposition: Sendable, Equatable { + enum DisplacedLiveDisposition: Equatable { case none case alreadyManaged(managedAccountID: UUID) case imported(managedAccountID: UUID) @@ -133,7 +133,7 @@ struct CodexAccountPromotionResult: Sendable, Equatable { let resultingActiveSource: CodexActiveSource } -enum CodexAccountPromotionError: Error, Sendable, Equatable { +enum CodexAccountPromotionError: Error, Equatable { case targetManagedAccountNotFound case targetManagedAccountAuthMissing case targetManagedAccountAuthUnreadable diff --git a/Sources/CodexBar/CodexAccountReconciliation.swift b/Sources/CodexBar/CodexAccountReconciliation.swift index cdeb3a98c7..782ec12ac2 100644 --- a/Sources/CodexBar/CodexAccountReconciliation.swift +++ b/Sources/CodexBar/CodexAccountReconciliation.swift @@ -1,7 +1,7 @@ import CodexBarCore import Foundation -struct CodexVisibleAccount: Equatable, Sendable, Identifiable { +struct CodexVisibleAccount: Equatable, Identifiable { let id: String let email: String let workspaceLabel: String? @@ -62,7 +62,7 @@ struct CodexVisibleAccount: Equatable, Sendable, Identifiable { } } -struct CodexVisibleAccountProjection: Equatable, Sendable { +struct CodexVisibleAccountProjection: Equatable { let visibleAccounts: [CodexVisibleAccount] let activeVisibleAccountID: String? let liveVisibleAccountID: String? diff --git a/Sources/CodexBar/CodexHistoryOwnership.swift b/Sources/CodexBar/CodexHistoryOwnership.swift index f2fe6e5f54..e9f613fd06 100644 --- a/Sources/CodexBar/CodexHistoryOwnership.swift +++ b/Sources/CodexBar/CodexHistoryOwnership.swift @@ -2,7 +2,7 @@ import CodexBarCore import CryptoKit import Foundation -enum CodexHistoryPersistedOwner: Equatable, Sendable { +enum CodexHistoryPersistedOwner: Equatable { case canonical(String) case legacyEmailHash(String) case legacyOpaqueScoped(String) diff --git a/Sources/CodexBar/CodexOwnershipContext.swift b/Sources/CodexBar/CodexOwnershipContext.swift index 7427db95c3..09326cbfd5 100644 --- a/Sources/CodexBar/CodexOwnershipContext.swift +++ b/Sources/CodexBar/CodexOwnershipContext.swift @@ -2,7 +2,7 @@ import CodexBarCore import CryptoKit import Foundation -struct CodexOwnershipContext: Sendable { +struct CodexOwnershipContext { let canonicalKey: String? let canonicalEmailHashKey: String? let historicalLegacyEmailHash: String? diff --git a/Sources/CodexBar/ManagedCodexAccountCoordinator.swift b/Sources/CodexBar/ManagedCodexAccountCoordinator.swift index 9060acc00b..066a25532f 100644 --- a/Sources/CodexBar/ManagedCodexAccountCoordinator.swift +++ b/Sources/CodexBar/ManagedCodexAccountCoordinator.swift @@ -2,7 +2,7 @@ import CodexBarCore import Foundation import Observation -enum ManagedCodexAccountCoordinatorError: Error, Equatable, Sendable { +enum ManagedCodexAccountCoordinatorError: Error, Equatable { case authenticationInProgress } diff --git a/Sources/CodexBar/ManagedCodexAccountService.swift b/Sources/CodexBar/ManagedCodexAccountService.swift index 5e4871a7b7..b28bd58e22 100644 --- a/Sources/CodexBar/ManagedCodexAccountService.swift +++ b/Sources/CodexBar/ManagedCodexAccountService.swift @@ -18,13 +18,13 @@ protocol ManagedCodexWorkspaceResolving: Sendable { func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? } -enum ManagedCodexAccountServiceError: Error, Equatable, Sendable { +enum ManagedCodexAccountServiceError: Error, Equatable { case loginFailed case missingEmail case unsafeManagedHome(String) } -struct ManagedCodexHomeFactory: ManagedCodexHomeProducing, Sendable { +struct ManagedCodexHomeFactory: ManagedCodexHomeProducing { let root: URL init(root: URL = Self.defaultRootURL(), fileManager: FileManager = .default) { diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index ce2747eac8..15d2a63b97 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -87,34 +87,34 @@ struct CodexUIErrorMapper { } struct CodexConsumerProjection { - enum Surface: Sendable { + enum Surface { case liveCard case overrideCard case widget case menuBar } - enum RateLane: String, Sendable { + enum RateLane: String { case session case weekly } - enum SupplementalMetric: String, Sendable { + enum SupplementalMetric: String { case codeReview } - struct PlanUtilizationLane: Sendable { + struct PlanUtilizationLane { let role: PlanUtilizationSeriesName let window: RateWindow } - enum DashboardVisibility: Sendable { + enum DashboardVisibility { case hidden case displayOnly case attached } - struct CreditsProjection: Sendable { + struct CreditsProjection { let snapshot: CreditsSnapshot? let userFacingError: String? @@ -123,7 +123,7 @@ struct CodexConsumerProjection { } } - struct UserFacingErrors: Sendable { + struct UserFacingErrors { let usage: String? let credits: String? let dashboard: String? @@ -141,7 +141,7 @@ struct CodexConsumerProjection { let now: Date } - enum MenuBarFallback: Sendable { + enum MenuBarFallback { case none case creditsBalance } diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index d39c3f2a0a..03e42f23da 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -1,7 +1,7 @@ import CodexBarCore import Foundation -enum CodexAccountScopedRefreshPhase: Sendable { +enum CodexAccountScopedRefreshPhase { case invalidated case usage case credits @@ -9,7 +9,7 @@ enum CodexAccountScopedRefreshPhase: Sendable { case completed } -struct CodexAccountScopedRefreshGuard: Equatable, Sendable { +struct CodexAccountScopedRefreshGuard: Equatable { let source: CodexActiveSource let identity: CodexIdentity let accountKey: String? diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index a629d1d095..42acabb03b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -76,7 +76,7 @@ extension UsageStore { @MainActor @Observable final class UsageStore { - enum CodexCreditsSource: Sendable { + enum CodexCreditsSource { case none case api case dashboardWeb diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift index 59c14be005..c8cb607951 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift @@ -257,7 +257,7 @@ public enum CodexIdentityMatcher { } } -private struct RuntimeManagedCodexAccount: Sendable { +private struct RuntimeManagedCodexAccount { let email: String let identity: CodexIdentity } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceIdentityCache.swift b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceIdentityCache.swift index 63303deae3..2809c8c3ca 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceIdentityCache.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceIdentityCache.swift @@ -3,7 +3,7 @@ import Foundation public struct CodexOpenAIWorkspaceIdentityCache: @unchecked Sendable { public static let currentVersion = 1 - private struct Payload: Codable, Sendable { + private struct Payload: Codable { let version: Int var labelsByWorkspaceAccountID: [String: String] diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift index e23f6c167e..2b698bb8f8 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift @@ -232,7 +232,7 @@ public enum PerplexityCookieImporter { } } -enum PerplexityCookieImportError: LocalizedError, Sendable { +enum PerplexityCookieImportError: LocalizedError { case noCookies var errorDescription: String? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 52180a5c82..a982934ae6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -50,12 +50,12 @@ enum CostUsageScanner { let parsedBytes: Int64 } - enum ClaudePathRole: String, Codable, Sendable { + enum ClaudePathRole: String, Codable { case parent case subagent } - struct ClaudeUsageRow: Codable, Sendable { + struct ClaudeUsageRow: Codable { let dayKey: String let model: String let sessionId: String? diff --git a/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift b/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift index f0e180dfba..b489bebf13 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift @@ -677,7 +677,7 @@ struct CodexAccountPromotionServiceTests { } } -private struct HomePathWorkspaceResolver: ManagedCodexWorkspaceResolving, Sendable { +private struct HomePathWorkspaceResolver: ManagedCodexWorkspaceResolving { let byHomePath: [String: CodexOpenAIWorkspaceIdentity] func resolveWorkspaceIdentity( diff --git a/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift b/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift index 477a289881..b4598f4cda 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift @@ -452,7 +452,7 @@ enum PromotionTestError: Error, Equatable { case unexpectedDisposition } -private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving, Sendable { +private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { let identities: [String: CodexOpenAIWorkspaceIdentity] func resolveWorkspaceIdentity( diff --git a/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift b/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift index 17556ec072..5b5b57d719 100644 --- a/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift +++ b/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift @@ -372,7 +372,7 @@ extension CodexAccountsSettingsSectionTests { } } -private struct TestManagedCodexHomeFactoryForSettingsSectionTests: ManagedCodexHomeProducing, Sendable { +private struct TestManagedCodexHomeFactoryForSettingsSectionTests: ManagedCodexHomeProducing { let root: URL private let nextID = UUID().uuidString @@ -385,7 +385,7 @@ private struct TestManagedCodexHomeFactoryForSettingsSectionTests: ManagedCodexH } } -private struct StubManagedCodexLoginRunnerForSettingsSectionTests: ManagedCodexLoginRunning, Sendable { +private struct StubManagedCodexLoginRunnerForSettingsSectionTests: ManagedCodexLoginRunning { let result: CodexLoginRunner.Result func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { diff --git a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift index 64829a0406..48c0063afb 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift @@ -726,7 +726,7 @@ private final class TestManagedCodexHomeFactory: ManagedCodexHomeProducing, @unc } } -private struct UnsafeManagedCodexHomeFactory: ManagedCodexHomeProducing, Sendable { +private struct UnsafeManagedCodexHomeFactory: ManagedCodexHomeProducing { let root: URL let homeURL: URL @@ -739,7 +739,7 @@ private struct UnsafeManagedCodexHomeFactory: ManagedCodexHomeProducing, Sendabl } } -private struct StubManagedCodexLoginRunner: ManagedCodexLoginRunning, Sendable { +private struct StubManagedCodexLoginRunner: ManagedCodexLoginRunning { let result: CodexLoginRunner.Result func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result { @@ -785,7 +785,7 @@ private final class StubManagedCodexIdentityReader: ManagedCodexIdentityReading, } } -private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving, Sendable { +private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { let identities: [String: CodexOpenAIWorkspaceIdentity] init(identities: [String: CodexOpenAIWorkspaceIdentity] = [:]) { diff --git a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift index 246a053ec0..a734d9edf6 100644 --- a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift +++ b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift @@ -24,7 +24,7 @@ struct PerplexityCookieCacheTests { // MARK: - Cache round-trip @Test - func cacheRoundTripProducesValidCookieOverride() { + func `cache round trip produces valid cookie override`() { KeychainCacheStore.setTestStoreForTesting(true) defer { CookieHeaderCache.clear(provider: .perplexity) @@ -48,7 +48,7 @@ struct PerplexityCookieCacheTests { // MARK: - isAvailable returns true when cache has entry @Test - func isAvailableReturnsTrueWhenCachePopulated() { + func `is available returns true when cache populated`() { KeychainCacheStore.setTestStoreForTesting(true) defer { CookieHeaderCache.clear(provider: .perplexity) @@ -72,7 +72,7 @@ struct PerplexityCookieCacheTests { // MARK: - Cache cleared on invalidToken @Test - func cacheClearedOnInvalidToken() { + func `cache cleared on invalid token`() { KeychainCacheStore.setTestStoreForTesting(true) defer { CookieHeaderCache.clear(provider: .perplexity) @@ -96,7 +96,7 @@ struct PerplexityCookieCacheTests { // MARK: - Cache NOT cleared on non-auth errors @Test - func cacheNotClearedOnNetworkError() { + func `cache not cleared on network error`() { KeychainCacheStore.setTestStoreForTesting(true) defer { CookieHeaderCache.clear(provider: .perplexity) @@ -121,7 +121,7 @@ struct PerplexityCookieCacheTests { } @Test - func cacheNotClearedOnAPIError() { + func `cache not cleared on API error`() { KeychainCacheStore.setTestStoreForTesting(true) defer { CookieHeaderCache.clear(provider: .perplexity) @@ -148,7 +148,7 @@ struct PerplexityCookieCacheTests { // MARK: - Bare token stored as default cookie name @Test - func bareTokenRoundTripsWithDefaultCookieName() { + func `bare token round trips with default cookie name`() { KeychainCacheStore.setTestStoreForTesting(true) defer { CookieHeaderCache.clear(provider: .perplexity) @@ -168,7 +168,7 @@ struct PerplexityCookieCacheTests { } @Test - func offModeIgnoresCachedSessionCookie() async { + func `off mode ignores cached session cookie`() async { KeychainCacheStore.setTestStoreForTesting(true) defer { CookieHeaderCache.clear(provider: .perplexity) diff --git a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift index 77c74a2c27..39b7d2a52b 100644 --- a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift +++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct PerplexityCookieHeaderTests { @Test - func bareTokenUsesDefaultSessionCookieName() { + func `bare token uses default session cookie name`() { let override = PerplexityCookieHeader.override(from: "abc123") #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName) #expect(override?.token == "abc123") @@ -13,7 +12,7 @@ struct PerplexityCookieHeaderTests { } @Test - func extractsSecureNextAuthSessionCookieFromHeader() { + func `extracts secure next auth session cookie from header`() { let header = "foo=bar; __Secure-next-auth.session-token=token-a; baz=qux" let override = PerplexityCookieHeader.override(from: header) #expect(override?.name == "__Secure-next-auth.session-token") @@ -21,7 +20,7 @@ struct PerplexityCookieHeaderTests { } @Test - func extractsAuthJSSessionCookieFromHeader() { + func `extracts auth JS session cookie from header`() { let header = "foo=bar; __Secure-authjs.session-token=token-b; baz=qux" let override = PerplexityCookieHeader.override(from: header) #expect(override?.name == "__Secure-authjs.session-token") @@ -29,7 +28,7 @@ struct PerplexityCookieHeaderTests { } @Test - func prefersAuthJSSessionCookieWhenBothNamesExist() { + func `prefers auth JS session cookie when both names exist`() { let header = """ __Secure-next-auth.session-token=legacy-token; __Secure-authjs.session-token=live-token """ @@ -39,7 +38,7 @@ struct PerplexityCookieHeaderTests { } @Test - func reassemblesChunkedNextAuthSessionCookieFromHeader() { + func `reassembles chunked next auth session cookie from header`() { let header = """ foo=bar; __Secure-next-auth.session-token.1=chunk-b; __Secure-next-auth.session-token.0=chunk-a """ @@ -49,7 +48,7 @@ struct PerplexityCookieHeaderTests { } @Test - func reassemblesChunkedAuthJSSessionCookieFromHeader() { + func `reassembles chunked auth JS session cookie from header`() { let header = "foo=bar; authjs.session-token.0=chunk-a; authjs.session-token.1=chunk-b" let override = PerplexityCookieHeader.override(from: header) #expect(override?.name == "authjs.session-token") @@ -57,14 +56,14 @@ struct PerplexityCookieHeaderTests { } @Test - func unsupportedCookieHeaderReturnsNil() { + func `unsupported cookie header returns nil`() { let override = PerplexityCookieHeader.override(from: "foo=bar; hello=world") #expect(override == nil) } #if os(macOS) @Test - func importerSessionInfoReassemblesChunkedSessionCookies() throws { + func `importer session info reassembles chunked session cookies`() throws { let cookies = try [ #require(self.makeCookie(name: "__Secure-authjs.session-token.0", value: "chunk-a")), #require(self.makeCookie(name: "__Secure-authjs.session-token.1", value: "chunk-b")), diff --git a/Tests/CodexBarTests/PerplexityProviderTests.swift b/Tests/CodexBarTests/PerplexityProviderTests.swift index fa475f8b9f..b81a468447 100644 --- a/Tests/CodexBarTests/PerplexityProviderTests.swift +++ b/Tests/CodexBarTests/PerplexityProviderTests.swift @@ -95,7 +95,7 @@ struct PerplexityProviderTests { } @Test - func offModeIgnoresEnvironmentSessionCookie() async { + func `off mode ignores environment session cookie`() async { let strategy = PerplexityWebFetchStrategy() let settings = ProviderSettingsSnapshot.make( perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( @@ -109,7 +109,7 @@ struct PerplexityProviderTests { } @Test - func manualModeInvalidCookieDoesNotFallBackToCacheOrEnvironment() async { + func `manual mode invalid cookie does not fall back to cache or environment`() async { await self.withIsolatedCacheStore { CookieHeaderCache.store( provider: .perplexity, @@ -142,7 +142,7 @@ struct PerplexityProviderTests { } @Test - func environmentTokenDoesNotPopulateBrowserCookieCache() async throws { + func `environment token does not populate browser cookie cache`() async throws { try await self.withIsolatedCacheStore { PerplexityCookieImporter.invalidateImportSessionCache() PerplexityCookieImporter.importSessionsOverrideForTesting = nil @@ -176,7 +176,7 @@ struct PerplexityProviderTests { } @Test - func manualTokenDoesNotPopulateBrowserCookieCache() async throws { + func `manual token does not populate browser cookie cache`() async throws { try await self.withIsolatedCacheStore { let strategy = PerplexityWebFetchStrategy() let settings = ProviderSettingsSnapshot.make( @@ -197,7 +197,7 @@ struct PerplexityProviderTests { } @Test - func bareEnvironmentTokenFallsBackToAuthJSCookieName() async throws { + func `bare environment token falls back to auth JS cookie name`() async throws { try await self.withIsolatedCacheStore { PerplexityCookieImporter.invalidateImportSessionCache() PerplexityCookieImporter.importSessionsOverrideForTesting = nil @@ -241,7 +241,7 @@ struct PerplexityProviderTests { } @Test - func validEnvironmentCookieWinsAfterInvalidBrowserSession() async throws { + func `valid environment cookie wins after invalid browser session`() async throws { try await self.withIsolatedCacheStore { PerplexityCookieImporter.invalidateImportSessionCache() PerplexityCookieImporter.importSessionsOverrideForTesting = nil @@ -292,7 +292,7 @@ struct PerplexityProviderTests { } @Test - func laterBrowserSessionWinsAfterEarlierImportedSessionFailsAuth() async throws { + func `later browser session wins after earlier imported session fails auth`() async throws { try await self.withIsolatedCacheStore { PerplexityCookieImporter.invalidateImportSessionCache() PerplexityCookieImporter.importSessionOverrideForTesting = nil @@ -351,7 +351,7 @@ struct PerplexityProviderTests { } @Test - func autoModeReusesBrowserImportBetweenAvailabilityAndFetch() async throws { + func `auto mode reuses browser import between availability and fetch`() async throws { try await self.withIsolatedCacheStore { let importCount = LockedCounter() PerplexityCookieImporter.invalidateImportSessionCache() diff --git a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift index cd115081a2..0afd0cbcc2 100644 --- a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift +++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift @@ -2,7 +2,6 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct PerplexityUsageFetcherTests { // Fixed "now" so expiry comparisons are deterministic private static let now = Date(timeIntervalSince1970: 1_740_000_000) // Feb 20, 2026 @@ -13,7 +12,7 @@ struct PerplexityUsageFetcherTests { // MARK: - JSON Parsing @Test - func parsesFullResponseWithRecurringAndPromotionalCredits() throws { + func `parses full response with recurring and promotional credits`() throws { let json = """ { "balance_cents": 7250, @@ -40,7 +39,7 @@ struct PerplexityUsageFetcherTests { } @Test - func waterfallAttributionRecurringThenPurchasedThenPromo() throws { + func `waterfall attribution recurring then purchased then promo`() throws { // Usage exceeds recurring, spills into purchased, then promo let json = """ { @@ -62,7 +61,7 @@ struct PerplexityUsageFetcherTests { } @Test - func expiredPromotionalGrantsAreExcluded() throws { + func `expired promotional grants are excluded`() throws { let json = """ { "balance_cents": 0, @@ -83,7 +82,7 @@ struct PerplexityUsageFetcherTests { } @Test - func emptyCreditGrantsProducesZeroRecurring() throws { + func `empty credit grants produces zero recurring`() throws { let json = """ { "balance_cents": 0, @@ -102,7 +101,7 @@ struct PerplexityUsageFetcherTests { } @Test - func malformedJSONThrowsParseFailed() { + func `malformed JSON throws parse failed`() { let json = """ { "balance_cents": "not a number", "credit_grants": null } """ @@ -117,7 +116,7 @@ struct PerplexityUsageFetcherTests { // MARK: - Plan Name Inference @Test - func planNameInference() throws { + func `plan name inference`() throws { func makeSnapshot(recurringCents: Double) throws -> PerplexityUsageSnapshot { let json = """ { @@ -142,7 +141,7 @@ struct PerplexityUsageFetcherTests { // MARK: - toUsageSnapshot @Test - func toUsageSnapshotAlwaysHasSecondaryAndTertiary() throws { + func `to usage snapshot always has secondary and tertiary`() throws { let json = """ { "balance_cents": 0, @@ -163,7 +162,7 @@ struct PerplexityUsageFetcherTests { } @Test - func toUsageSnapshotZeroRecurringBarIsFullyDepleted() throws { + func `to usage snapshot zero recurring bar is fully depleted`() throws { let json = """ { "balance_cents": 0, @@ -182,7 +181,7 @@ struct PerplexityUsageFetcherTests { } @Test - func toUsageSnapshotOmitsPrimaryWhenOnlyFallbackCreditsRemain() throws { + func `to usage snapshot omits primary when only fallback credits remain`() throws { let json = """ { "balance_cents": 6000, @@ -203,7 +202,7 @@ struct PerplexityUsageFetcherTests { } @Test - func toUsageSnapshotEmptyPoolsBarsAreFullyDepleted() throws { + func `to usage snapshot empty pools bars are fully depleted`() throws { let json = """ { "balance_cents": 0, @@ -228,7 +227,7 @@ struct PerplexityUsageFetcherTests { // MARK: - Purchased credits from credit_grants @Test - func purchasedCreditsFromCreditGrantsArray() throws { + func `purchased credits from credit grants array`() throws { // Purchased credits appear as credit_grant type="purchased" instead of // current_period_purchased_cents. The snapshot should pick them up. let json = """ @@ -257,7 +256,7 @@ struct PerplexityUsageFetcherTests { } @Test - func purchasedCreditsPreferGrantsOverFieldWhenBothPresent() throws { + func `purchased credits prefer grants over field when both present`() throws { // When both current_period_purchased_cents AND credit_grants type="purchased" // are provided, the larger value wins. let json = """ @@ -284,7 +283,7 @@ struct PerplexityUsageFetcherTests { } @Test - func purchasedCreditsFromFieldWhenNoGrantType() throws { + func `purchased credits from field when no grant type`() throws { // Legacy path: current_period_purchased_cents is set but no "purchased" grant let json = """ { @@ -308,7 +307,7 @@ struct PerplexityUsageFetcherTests { } @Test - func realWorldMaxPlanWithAllThreePools() throws { + func `real world max plan with all three pools`() throws { // Real-world scenario: Max plan, 10k recurring + 40k purchased + 55k bonus // Total 105,000 available, 23,065 remaining → 81,935 used let json = """ @@ -342,7 +341,7 @@ struct PerplexityUsageFetcherTests { } @Test - func toUsageSnapshotPrimaryPercentMatchesUsage() throws { + func `to usage snapshot primary percent matches usage`() throws { let json = """ { "balance_cents": 0, diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index 3a77707991..eae588822e 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -589,7 +589,7 @@ private final class InMemoryManagedCodexAccountStoreForStatusMenuTests: ManagedC } } -private struct TestManagedCodexHomeFactoryForStatusMenuTests: ManagedCodexHomeProducing, Sendable { +private struct TestManagedCodexHomeFactoryForStatusMenuTests: ManagedCodexHomeProducing { let root: URL func makeHomeURL() -> URL { @@ -601,7 +601,7 @@ private struct TestManagedCodexHomeFactoryForStatusMenuTests: ManagedCodexHomePr } } -private struct StubManagedCodexIdentityReaderForStatusMenuTests: ManagedCodexIdentityReading, Sendable { +private struct StubManagedCodexIdentityReaderForStatusMenuTests: ManagedCodexIdentityReading { let email: String func loadAccountIdentity(homePath _: String) throws -> CodexAuthBackedAccount { diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index c133f657ca..1390dd9e37 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -23,7 +23,7 @@ struct SubprocessRunnerTests { /// the cooperative thread pool, starving the timeout task. The fix moves blocking calls /// to `DispatchQueue.global()`, making this test reliable. @Test - func throwsTimedOutWhenProcessHangs() async throws { + func `throws timed out when process hangs`() async throws { let start = Date() do { _ = try await SubprocessRunner.run( @@ -52,7 +52,7 @@ struct SubprocessRunnerTests { /// one blocked subprocess does not starve the timeout mechanism of others. /// This is the core scenario that caused the original permanent-refresh-stall bug. @Test - func concurrentHungProcessesAllTimeOut() async { + func `concurrent hung processes all time out`() async { let start = Date() let count = 8 @@ -90,7 +90,7 @@ struct SubprocessRunnerTests { /// Stress-test the timeout race guard: with very short timeouts, the exit-code task /// and the timeout task race tightly, exercising the KillFlag synchronization path. @Test - func timeoutRaceStress() async { + func `timeout race stress`() async { for i in 0..<20 { do { _ = try await SubprocessRunner.run( @@ -113,7 +113,7 @@ struct SubprocessRunnerTests { /// Verify that many concurrent SubprocessRunner calls complete without starving each other. @Test - func concurrentCallsDoNotStarve() async throws { + func `concurrent calls do not starve`() async throws { try await withThrowingTaskGroup(of: SubprocessResult.self) { group in for i in 0..<20 { group.addTask { From 5ffb067dc2b9003203170cde656a6eb5053b740c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 01:23:29 +0100 Subject: [PATCH 0129/1239] test: fix Swift Testing main actor isolation --- Tests/CodexBarTests/SettingsStoreTests.swift | 42 ++++++++++++------- ...StatusItemAnimationCodexCreditsTests.swift | 1 + 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 7061c9be55..08fd2ecfef 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -4,8 +4,26 @@ import Observation import Testing @testable import CodexBar +@Suite(.serialized) @MainActor struct SettingsStoreTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test func `default refresh frequency is five minutes`() throws { let suite = "SettingsStoreTests-default" @@ -662,20 +680,18 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + let didChange = ObservationFlag() withObservationTracking { _ = store.menuObservationToken } onChange: { - Task { @MainActor in - didChange = true - } + didChange.set() } store.statusChecksEnabled.toggle() try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.get() == true) } @Test @@ -691,20 +707,18 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + let didChange = ObservationFlag() withObservationTracking { _ = store.codexCookieSource } onChange: { - Task { @MainActor in - didChange = true - } + didChange.set() } store.codexCookieSource = .manual try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.get() == true) } @Test @@ -720,20 +734,18 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + let didChange = ObservationFlag() withObservationTracking { _ = store.menuObservationToken } onChange: { - Task { @MainActor in - didChange = true - } + didChange.set() } store.codexActiveSource = .liveSystem try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.get() == true) } @Test diff --git a/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift index 7e5e01b9e4..eafebe544b 100644 --- a/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift @@ -3,6 +3,7 @@ import CodexBarCore import Testing @testable import CodexBar +@Suite(.serialized) @MainActor struct StatusItemAnimationCodexCreditsTests { private func makeStatusBarForTesting() -> NSStatusBar { From 6c77d7dde13513865abaecf2be21a026328ee46b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 01:28:38 +0100 Subject: [PATCH 0130/1239] test: avoid live keychain in Augment debug tests --- .../Augment/AugmentStatusProbe.swift | 10 ++++--- .../AugmentStatusProbeTests.swift | 30 +++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift index 43459fd1f1..dfb43d5ebf 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift @@ -589,14 +589,16 @@ public struct AugmentStatusProbe: Sendable { } /// Debug probe that returns raw API responses - public func debugRawProbe() async -> String { + public func debugRawProbe(cookieHeaderOverride: String? = nil) async -> String { let stamp = ISO8601DateFormatter().string(from: Date()) var lines: [String] = [] lines.append("=== Augment Debug Probe @ \(stamp) ===") lines.append("") do { - let snapshot = try await self.fetch(logger: { msg in lines.append("[log] \(msg)") }) + let snapshot = try await self.fetch( + cookieHeaderOverride: cookieHeaderOverride, + logger: { msg in lines.append("[log] \(msg)") }) lines.append("") lines.append("Probe Success") lines.append("") @@ -616,13 +618,13 @@ public struct AugmentStatusProbe: Sendable { } let output = lines.joined(separator: "\n") - Task { @MainActor in Self.recordDump(output) } + await MainActor.run { Self.recordDump(output) } return output } catch { lines.append("") lines.append("Probe Failed: \(error.localizedDescription)") let output = lines.joined(separator: "\n") - Task { @MainActor in Self.recordDump(output) } + await MainActor.run { Self.recordDump(output) } return output } } diff --git a/Tests/CodexBarTests/AugmentStatusProbeTests.swift b/Tests/CodexBarTests/AugmentStatusProbeTests.swift index 912f367337..83b14904de 100644 --- a/Tests/CodexBarTests/AugmentStatusProbeTests.swift +++ b/Tests/CodexBarTests/AugmentStatusProbeTests.swift @@ -2,12 +2,16 @@ import XCTest @testable import CodexBarCore final class AugmentStatusProbeTests: XCTestCase { - func test_debugRawProbe_returnsFormattedOutput() async { + private func failingProbe() throws -> AugmentStatusProbe { + try AugmentStatusProbe(baseURL: XCTUnwrap(URL(string: "http://127.0.0.1:1")), timeout: 0.1) + } + + func test_debugRawProbe_returnsFormattedOutput() async throws { // Given: A probe instance - let probe = AugmentStatusProbe() + let probe = try self.failingProbe() // When: We call debugRawProbe - let output = await probe.debugRawProbe() + let output = await probe.debugRawProbe(cookieHeaderOverride: "session=test") // Then: The output should contain expected debug information XCTAssertTrue(output.contains("=== Augment Debug Probe @"), "Should contain debug header") @@ -29,10 +33,10 @@ final class AugmentStatusProbeTests: XCTestCase { func test_debugRawProbe_capturesFailureInDumps() async throws { // Given: A probe with an invalid base URL that will fail - let invalidProbe = try AugmentStatusProbe(baseURL: XCTUnwrap(URL(string: "https://invalid.example.com"))) + let invalidProbe = try self.failingProbe() // When: We call debugRawProbe which should fail - let output = await invalidProbe.debugRawProbe() + let output = await invalidProbe.debugRawProbe(cookieHeaderOverride: "session=test") // Then: The output should indicate failure XCTAssertTrue(output.contains("Probe Failed"), "Should contain failure message") @@ -45,11 +49,11 @@ final class AugmentStatusProbeTests: XCTestCase { func test_latestDumps_maintainsRingBuffer() async throws { // Given: Multiple failed probes to fill the ring buffer - let invalidProbe = try AugmentStatusProbe(baseURL: XCTUnwrap(URL(string: "https://invalid.example.com"))) + let invalidProbe = try self.failingProbe() // When: We generate more than 5 dumps (the ring buffer size) for _ in 1...7 { - _ = await invalidProbe.debugRawProbe() + _ = await invalidProbe.debugRawProbe(cookieHeaderOverride: "session=test") // Small delay to ensure different timestamps try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds } @@ -60,24 +64,24 @@ final class AugmentStatusProbeTests: XCTestCase { XCTAssertLessThanOrEqual(separatorCount, 5, "Should maintain at most 5 dumps in ring buffer") } - func test_debugRawProbe_includesTimestamp() async { + func test_debugRawProbe_includesTimestamp() async throws { // Given: A probe instance - let probe = AugmentStatusProbe() + let probe = try self.failingProbe() // When: We call debugRawProbe - let output = await probe.debugRawProbe() + let output = await probe.debugRawProbe(cookieHeaderOverride: "session=test") // Then: The output should include an ISO8601 timestamp XCTAssertTrue(output.contains("@"), "Should contain timestamp marker") XCTAssertTrue(output.contains("==="), "Should contain debug header markers") } - func test_debugRawProbe_includesCreditsBalance() async { + func test_debugRawProbe_includesCreditsBalance() async throws { // Given: A probe instance - let probe = AugmentStatusProbe() + let probe = try self.failingProbe() // When: We call debugRawProbe - let output = await probe.debugRawProbe() + let output = await probe.debugRawProbe(cookieHeaderOverride: "session=test") // Then: The output should mention credits balance (either in success or failure) XCTAssertTrue( From 4fe748513fd98fa6ceefd94d4b1704480322b627 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 01:46:59 +0100 Subject: [PATCH 0131/1239] test: isolate release gate fixtures --- .../OpenAIWeb/OpenAIDashboardWebViewCache.swift | 7 +++++++ Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index d50671216d..fa2d2dcab2 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -252,6 +252,13 @@ final class OpenAIDashboardWebViewCache { } private func prepareWebView(_ webView: WKWebView, usageURL: URL, timeout: TimeInterval) async throws { + #if DEBUG + if usageURL.absoluteString == "about:blank" { + _ = webView.loadHTMLString("", baseURL: nil) + return + } + #endif + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in let delegate = NavigationDelegate { result in cont.resume(with: result) diff --git a/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift b/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift index da05f05a58..e67706257c 100644 --- a/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift +++ b/Tests/CodexBarTests/ClaudeDebugDiagnosticsTests.swift @@ -125,7 +125,7 @@ struct ClaudeDebugDiagnosticsTests { return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { - await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( data: nil, fingerprint: nil) @@ -207,7 +207,7 @@ struct ClaudeDebugDiagnosticsTests { return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { - await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( data: nil, fingerprint: nil) @@ -276,7 +276,7 @@ struct ClaudeDebugDiagnosticsTests { return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { - await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( data: nil, fingerprint: nil) @@ -441,7 +441,7 @@ struct ClaudeDebugDiagnosticsTests { return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { - await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( data: nil, fingerprint: nil) From 33c9c03b9bab4f93a79795fdf9cbd2a1e82b4f87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 02:16:00 +0100 Subject: [PATCH 0132/1239] test: disable keychain prompts under tests --- .../CodexBarCore/BrowserCookieAccessGate.swift | 11 +++++++++-- Sources/CodexBarCore/KeychainAccessGate.swift | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index db0de16b62..2efd75204a 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -18,7 +18,7 @@ public enum BrowserCookieAccessGate { public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { guard browser.usesKeychainForCookieDecryption else { return true } guard !KeychainAccessGate.isDisabled else { return false } - return self.lock.withLock { state in + let shouldCheckKeychain = self.lock.withLock { state in self.loadIfNeeded(&state) if let blockedUntil = state.deniedUntilByBrowser[browser.rawValue] { if blockedUntil > now { @@ -30,7 +30,14 @@ public enum BrowserCookieAccessGate { state.deniedUntilByBrowser.removeValue(forKey: browser.rawValue) self.persist(state) } - if self.chromiumKeychainRequiresInteraction() { + return true + } + guard shouldCheckKeychain else { return false } + + let requiresInteraction = self.chromiumKeychainRequiresInteraction() + return self.lock.withLock { state in + self.loadIfNeeded(&state) + if requiresInteraction { state.deniedUntilByBrowser[browser.rawValue] = now.addingTimeInterval(self.cooldownInterval) self.persist(state) self.log.info( diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 6984d6280f..f202e26a8d 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -13,6 +13,13 @@ public enum KeychainAccessGate { get { if let taskOverrideValue { return taskOverrideValue } if let overrideValue { return overrideValue } + #if DEBUG + if Self.isRunningUnderTests, + ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" + { + return true + } + #endif if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = UserDefaults(suiteName: Self.appGroupID), shared.bool(forKey: Self.flagKey) @@ -29,6 +36,15 @@ public enum KeychainAccessGate { } } + #if DEBUG + private nonisolated(unsafe) static var isRunningUnderTests: Bool { + let processName = ProcessInfo.processInfo.processName + return processName == "swiftpm-testing-helper" + || processName.hasSuffix("PackageTests") + || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + #endif + static func withTaskOverrideForTesting( _ disabled: Bool?, operation: () throws -> T) rethrows -> T From 6e332b355ea6685d7e20125271373294189c3d2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 02:43:30 +0100 Subject: [PATCH 0133/1239] test: stabilize release gate --- Sources/CodexBar/StatusItemController.swift | 4 ++++ Tests/CodexBarTests/HistoricalUsagePaceTests.swift | 1 + Tests/CodexBarTests/StatusItemAnimationTests.swift | 1 + 3 files changed, 6 insertions(+) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index cbbe2fe2c4..995f367d1c 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -575,6 +575,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } deinit { + let animationDriver = self.animationDriver + Task { @MainActor in + animationDriver?.stop() + } self.blinkTask?.cancel() self.loginTask?.cancel() NotificationCenter.default.removeObserver(self) diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift index d75f1007ea..a8a2200b63 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +@Suite(.serialized) struct HistoricalUsagePaceTests { @Test func `history store reconstructs deterministic monotone curve`() async throws { diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 915c82532e..13c5e0246b 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -3,6 +3,7 @@ import CodexBarCore import Testing @testable import CodexBar +@Suite(.serialized) @MainActor struct StatusItemAnimationTests { private func maxAlpha(in rep: NSBitmapImageRep) -> CGFloat { From 9aa7b2503ff5f89c19bdb6cc03b6b08ca42f3efc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 02:54:12 +0100 Subject: [PATCH 0134/1239] test: isolate status menu notifications --- Sources/CodexBar/StatusItemController.swift | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 995f367d1c..c818e13cca 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -204,7 +204,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin preferencesSelection: PreferencesSelection, managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator(), codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil, - statusBar: NSStatusBar = .system) + statusBar: NSStatusBar = .system, + observeProviderConfigNotifications: Bool = !SettingsStore.isRunningTests) { if SettingsStore.isRunningTests { _ = NSApplication.shared @@ -246,11 +247,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleDebugBlinkNotification), name: .codexbarDebugBlinkNow, object: nil) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.handleProviderConfigDidChange), - name: .codexbarProviderConfigDidChange, - object: nil) + if observeProviderConfigNotifications { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleProviderConfigDidChange), + name: .codexbarProviderConfigDidChange, + object: nil) + } } convenience init( @@ -259,7 +262,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin account: AccountInfo, updater: UpdaterProviding, preferencesSelection: PreferencesSelection, - statusBar: NSStatusBar = .system) + statusBar: NSStatusBar = .system, + observeProviderConfigNotifications: Bool = !SettingsStore.isRunningTests) { self.init( store: store, @@ -269,7 +273,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin preferencesSelection: preferencesSelection, managedCodexAccountCoordinator: ManagedCodexAccountCoordinator(), codexAccountPromotionCoordinator: nil, - statusBar: statusBar) + statusBar: statusBar, + observeProviderConfigNotifications: observeProviderConfigNotifications) } private func wireBindings() { From b945bd5b7a76a9d5b5269aee0593254555661e40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 03:12:19 +0100 Subject: [PATCH 0135/1239] test: isolate Claude keychain tests --- Sources/CodexBarCore/KeychainAccessGate.swift | 4 +++ .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 12 ++++++++ .../ClaudeOAuthKeychainReadStrategy.swift | 14 ++++++++++ ...eOAuthFetchStrategyAvailabilityTests.swift | 28 +++++++++++-------- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index f202e26a8d..5c76b5f23e 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -62,4 +62,8 @@ public enum KeychainAccessGate { try await operation() } } + + static var currentOverrideForTesting: Bool? { + self.taskOverrideValue ?? self.overrideValue + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 25168f90b7..35dfdc2744 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1702,10 +1702,22 @@ public enum ClaudeOAuthCredentialsStore { private static var keychainAccessAllowed: Bool { #if DEBUG if let override = self.taskKeychainAccessOverride { return !override } + if KeychainAccessGate.currentOverrideForTesting == true { return false } + if self.hasTaskKeychainTestingOverride { return true } #endif return !KeychainAccessGate.isDisabled } + #if DEBUG + private static var hasTaskKeychainTestingOverride: Bool { + self.taskClaudeKeychainOverrideStore != nil + || self.taskClaudeKeychainDataOverride != nil + || self.taskClaudeKeychainFingerprintOverride != nil + || self.taskSecurityCLIReadOverride != nil + || self.taskSecurityCLIReadAccountOverride != nil + } + #endif + private static var isPromptPolicyApplicable: Bool { ClaudeOAuthKeychainPromptPreference.isApplicable() } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift index fd90c5dcad..01556f94a0 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift @@ -19,10 +19,24 @@ public enum ClaudeOAuthKeychainReadStrategyPreference { if let raw = userDefaults.string(forKey: self.userDefaultsKey) { return ClaudeOAuthKeychainReadStrategy(rawValue: raw) ?? .securityFramework } + #if DEBUG + if self.isRunningUnderTests, + ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" + { + return .securityFramework + } + #endif return .securityCLIExperimental } #if DEBUG + private static var isRunningUnderTests: Bool { + let processName = ProcessInfo.processInfo.processName + return processName == "swiftpm-testing-helper" + || processName.hasSuffix("PackageTests") + || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + public static func withTaskOverrideForTesting( _ strategy: ClaudeOAuthKeychainReadStrategy?, operation: () throws -> T) rethrows -> T diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index a9f4994d35..82f4fb58cb 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -177,12 +177,14 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("credentials.json") - let available = await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - await ProviderRefreshContext.$current.withValue(.startup) { - await ProviderInteractionContext.$current.withValue(.background) { - await strategy.isAvailable(context) + let available = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await strategy.isAvailable(context) + } } } } @@ -240,12 +242,14 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("credentials.json") - let available = await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.nonZeroExit) { - await ProviderRefreshContext.$current.withValue(.startup) { - await ProviderInteractionContext.$current.withValue(.background) { - await strategy.isAvailable(context) + let available = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.nonZeroExit) { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await strategy.isAvailable(context) + } } } } From de06ecfc1a66dd58de545f2c8c1356fb66832363 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 03:37:44 +0100 Subject: [PATCH 0136/1239] test: prevent browser keychain prompts in tests --- .../BrowserCookieAccessGate.swift | 11 +++++++ Sources/CodexBarCore/KeychainAccessGate.swift | 22 ++++++++++---- ...OpenAIDashboardBrowserCookieImporter.swift | 6 ++-- .../AlibabaCodingPlanCookieImporter.swift | 2 +- .../Providers/Amp/AmpUsageFetcher.swift | 2 +- .../Augment/AugmentStatusProbe.swift | 2 +- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 2 +- .../Providers/Cursor/CursorStatusProbe.swift | 2 +- .../Factory/FactoryStatusProbe.swift | 4 +-- .../Providers/Kimi/KimiCookieImporter.swift | 2 +- .../MiniMax/MiniMaxCookieImporter.swift | 2 +- .../Providers/Ollama/OllamaUsageFetcher.swift | 2 +- .../OpenCode/OpenCodeCookieImporter.swift | 2 +- .../Perplexity/PerplexityCookieImporter.swift | 2 +- .../CodexBarTests/BrowserDetectionTests.swift | 30 ++++++++++++++++++- .../ClaudeOAuthKeychainAccessGateTests.swift | 11 +++++++ 16 files changed, 83 insertions(+), 21 deletions(-) diff --git a/Sources/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index 2efd75204a..9c7ea0e12e 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -111,6 +111,17 @@ public enum BrowserCookieAccessGate { UserDefaults.standard.set(raw, forKey: self.defaultsKey) } } + +extension BrowserCookieClient { + public func codexBarRecords( + matching query: BrowserCookieQuery, + in browser: Browser, + logger: ((String) -> Void)? = nil) throws -> [BrowserCookieStoreRecords] + { + guard BrowserCookieAccessGate.shouldAttempt(browser) else { return [] } + return try self.records(matching: query, in: browser, logger: logger) + } +} #else public enum BrowserCookieAccessGate { public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 5c76b5f23e..548a4add4f 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -12,14 +12,12 @@ public enum KeychainAccessGate { public nonisolated(unsafe) static var isDisabled: Bool { get { if let taskOverrideValue { return taskOverrideValue } - if let overrideValue { return overrideValue } #if DEBUG - if Self.isRunningUnderTests, - ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" - { + if Self.forcesDisabledUnderTests { return true } #endif + if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = UserDefaults(suiteName: Self.appGroupID), shared.bool(forKey: Self.flagKey) @@ -31,12 +29,17 @@ public enum KeychainAccessGate { set { overrideValue = newValue #if os(macOS) && canImport(SweetCookieKit) - BrowserCookieKeychainAccessGate.isDisabled = newValue + BrowserCookieKeychainAccessGate.isDisabled = self.isDisabled #endif } } #if DEBUG + private nonisolated(unsafe) static var forcesDisabledUnderTests: Bool { + self.isRunningUnderTests + && ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" + } + private nonisolated(unsafe) static var isRunningUnderTests: Bool { let processName = ProcessInfo.processInfo.processName return processName == "swiftpm-testing-helper" @@ -66,4 +69,13 @@ public enum KeychainAccessGate { static var currentOverrideForTesting: Bool? { self.taskOverrideValue ?? self.overrideValue } + + #if DEBUG + static func resetOverrideForTesting() { + self.overrideValue = nil + #if os(macOS) && canImport(SweetCookieKit) + BrowserCookieKeychainAccessGate.isDisabled = self.isDisabled + #endif + } + #endif } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 514c7f5f67..1a320f5717 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -236,7 +236,7 @@ public struct OpenAIDashboardBrowserCookieImporter { // Safari first: avoids touching Keychain ("Chrome Safe Storage") when Safari already matches. do { let query = BrowserCookieQuery(domains: Self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: .safari, logger: log) @@ -285,7 +285,7 @@ public struct OpenAIDashboardBrowserCookieImporter { // Chrome fallback: may trigger Keychain prompt. Only do this if Safari didn't match. do { let query = BrowserCookieQuery(domains: Self.cookieDomains) - let chromeSources = try Self.cookieClient.records( + let chromeSources = try Self.cookieClient.codexBarRecords( matching: query, in: .chrome) for source in chromeSources { @@ -328,7 +328,7 @@ public struct OpenAIDashboardBrowserCookieImporter { // Firefox fallback: no Keychain, but still only after Safari/Chrome. do { let query = BrowserCookieQuery(domains: Self.cookieDomains) - let firefoxSources = try Self.cookieClient.records( + let firefoxSources = try Self.cookieClient.codexBarRecords( matching: query, in: .firefox) for source in firefoxSources { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift index 56285223bc..ea354697a1 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift @@ -82,7 +82,7 @@ public enum AlibabaCodingPlanCookieImporter { do { log("Checking \(browserSource.displayName)") let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift index 43b1118c75..bd6c62f6e1 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift @@ -65,7 +65,7 @@ public enum AmpCookieImporter { for browserSource in installed { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift index dfb43d5ebf..60ceb0b157 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift @@ -67,7 +67,7 @@ public enum AugmentCookieImporter { for browserSource in augmentCookieImportOrder { do { let query = BrowserCookieQuery(domains: cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 06a9bc7b1f..c350d30c49 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -351,7 +351,7 @@ public enum ClaudeWebAPIFetcher { for browserSource in installedBrowsers { do { let query = BrowserCookieQuery(domains: cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index fee9af25bb..ffb362b422 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -108,7 +108,7 @@ public enum CursorCookieImporter { do { let query = BrowserCookieQuery(domains: Self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browser, logger: log) diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index f0766927a8..2f45886a2a 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -79,7 +79,7 @@ public enum FactoryCookieImporter { let log: (String) -> Void = { msg in logger?("[factory-cookie] \(msg)") } let cookieDomains = ["factory.ai", "app.factory.ai", "auth.factory.ai"] let query = BrowserCookieQuery(domains: cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) @@ -818,7 +818,7 @@ public struct FactoryStatusProbe: Sendable { for browserSource in sources { do { let query = BrowserCookieQuery(domains: ["workos.com"]) - let sources = try BrowserCookieClient().records( + let sources = try BrowserCookieClient().codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift index 3ede44a7c4..b3f6deef69 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCookieImporter.swift @@ -54,7 +54,7 @@ public enum KimiCookieImporter { { let query = BrowserCookieQuery(domains: self.cookieDomains) let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieImporter.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieImporter.swift index 756b350dfd..5841de1783 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieImporter.swift @@ -64,7 +64,7 @@ public enum MiniMaxCookieImporter { { let query = BrowserCookieQuery(domains: self.cookieDomains) let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 86bcb9ea5f..815e12b65f 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -199,7 +199,7 @@ public enum OllamaCookieImporter { for browserSource in browserSources { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: logger) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift index d91261fdbd..0532d8cae8 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift @@ -37,7 +37,7 @@ public enum OpenCodeCookieImporter { for browserSource in installedBrowsers { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift index 2b698bb8f8..1c0a7c911e 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift @@ -80,7 +80,7 @@ public enum PerplexityCookieImporter { { let query = BrowserCookieQuery(domains: self.cookieDomains) let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index c7dc5f0860..94cc9c36e7 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore #if os(macOS) import SweetCookieKit @@ -67,6 +67,34 @@ struct BrowserDetectionTests { #expect(detection.isCookieSourceAvailable(.chrome) == true) } + @Test + func `process filters chromium candidates despite false global keychain override`() throws { + guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return } + KeychainAccessGate.resetOverrideForTesting() + defer { KeychainAccessGate.resetOverrideForTesting() } + + KeychainAccessGate.isDisabled = false + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let profile = temp + .appendingPathComponent("Library") + .appendingPathComponent("Application Support") + .appendingPathComponent("Google") + .appendingPathComponent("Chrome") + .appendingPathComponent("Default") + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + let cookiesDir = profile.appendingPathComponent("Network") + try FileManager.default.createDirectory(at: cookiesDir, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: cookiesDir.appendingPathComponent("Cookies").path, contents: Data()) + + let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let browsers: [Browser] = [.chrome, .safari] + #expect(browsers.cookieImportCandidates(using: detection) == [.safari]) + } + @Test func `dia requires profile data`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift index 620ddee015..03e098e2c5 100644 --- a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift @@ -47,6 +47,17 @@ struct ClaudeOAuthKeychainAccessGateTests { } } + @Test + func `process keeps keychain access disabled despite false global override`() { + guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return } + KeychainAccessGate.resetOverrideForTesting() + defer { KeychainAccessGate.resetOverrideForTesting() } + + KeychainAccessGate.isDisabled = false + + #expect(KeychainAccessGate.isDisabled) + } + @Test func `clear denied allows immediate retry`() { KeychainAccessGate.withTaskOverrideForTesting(false) { From d36dc01d33b900fff2b3db093d85ddae8000eab1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 04:08:07 +0100 Subject: [PATCH 0137/1239] fix: prevent test keychain and subprocess hangs --- .../Host/Process/SubprocessRunner.swift | 80 +++++--- ...AlibabaCodingPlanCookieImporterTests.swift | 5 +- ...AuthDelegatedRefreshCoordinatorTests.swift | 175 +++++++++--------- 3 files changed, 150 insertions(+), 110 deletions(-) diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index a3dd15c49c..34c8bf38bf 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -51,24 +51,56 @@ public enum SubprocessRunner { } } - // MARK: - Helpers to move blocking calls off the cooperative thread pool + private final class ProcessTermination: @unchecked Sendable { + private let lock = NSLock() + private var status: Int32? + private var continuation: CheckedContinuation? - /// Runs `readDataToEndOfFile()` on a GCD thread so it does not block the Swift cooperative pool. - private static func readDataOffPool(_ fileHandle: FileHandle) async -> Data { - await withCheckedContinuation { continuation in - DispatchQueue.global().async { - let data = fileHandle.readDataToEndOfFile() - continuation.resume(returning: data) + func resolve(_ status: Int32) { + let continuation: CheckedContinuation? + self.lock.lock() + self.status = status + continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: status) + } + + func wait() async -> Int32 { + await withCheckedContinuation { continuation in + let status: Int32? + self.lock.lock() + status = self.status + if status == nil { + self.continuation = continuation + } + self.lock.unlock() + + if let status { + continuation.resume(returning: status) + } } } } - /// Runs `waitUntilExit()` on a GCD thread so it does not block the Swift cooperative pool. - private static func waitForExitOffPool(_ process: Process) async -> Int32 { + // MARK: - Helpers to move blocking calls off the cooperative thread pool + + /// Reads pipe data on a GCD thread so it does not block the Swift cooperative pool. + private static func readDataOffPool(_ fileHandle: FileHandle) async -> Data { await withCheckedContinuation { continuation in DispatchQueue.global().async { - process.waitUntilExit() - continuation.resume(returning: process.terminationStatus) + var output = Data() + while true { + do { + guard let data = try fileHandle.read(upToCount: 64 * 1024), data.isEmpty == false else { + break + } + output.append(data) + } catch { + break + } + } + continuation.resume(returning: output) } } } @@ -125,28 +157,34 @@ public enum SubprocessRunner { process.standardError = stderrPipe process.standardInput = nil - let stdoutTask = Task { - await self.readDataOffPool(stdoutPipe.fileHandleForReading) - } - let stderrTask = Task { - await self.readDataOffPool(stderrPipe.fileHandleForReading) + let termination = ProcessTermination() + process.terminationHandler = { process in + termination.resolve(process.terminationStatus) } do { try process.run() } catch { - stdoutTask.cancel() - stderrTask.cancel() + process.terminationHandler = nil stdoutPipe.fileHandleForReading.closeFile() + stdoutPipe.fileHandleForWriting.closeFile() stderrPipe.fileHandleForReading.closeFile() + stderrPipe.fileHandleForWriting.closeFile() throw SubprocessRunnerError.launchFailed(error.localizedDescription) } let pid = process.processIdentifier let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil + let stdoutTask = Task { + await self.readDataOffPool(stdoutPipe.fileHandleForReading) + } + let stderrTask = Task { + await self.readDataOffPool(stderrPipe.fileHandleForReading) + } + let exitCodeTask = Task { - await self.waitForExitOffPool(process) + await termination.wait() } let killedByTimeout = KillFlag() @@ -186,8 +224,6 @@ public enum SubprocessRunner { ]) stdoutTask.cancel() stderrTask.cancel() - stdoutPipe.fileHandleForReading.closeFile() - stderrPipe.fileHandleForReading.closeFile() throw SubprocessRunnerError.timedOut(label) } @@ -233,8 +269,6 @@ public enum SubprocessRunner { exitCodeTask.cancel() stdoutTask.cancel() stderrTask.cancel() - stdoutPipe.fileHandleForReading.closeFile() - stderrPipe.fileHandleForReading.closeFile() throw error } } diff --git a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift index 579dc02465..9bf9952ae2 100644 --- a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift +++ b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift @@ -46,7 +46,7 @@ struct AlibabaCodingPlanCookieImporterTests { } @Test - func `default cookie import candidates prefer chrome before safari`() throws { + func `default cookie import candidates skip keychain browsers during tests`() throws { BrowserCookieAccessGate.resetForTesting() let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -69,7 +69,8 @@ struct AlibabaCodingPlanCookieImporterTests { let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates(browserDetection: detection) - #expect(Array(candidates.prefix(2)) == [.chrome, .safari]) + #expect(candidates.first == .safari) + #expect(candidates.contains(.chrome) == false) } } diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index 1b4a0e63df..4c93321841 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -297,34 +297,37 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { createdAt: 1, persistentRefHash: "ref1")) let now = Date(timeIntervalSince1970: 50000) - let outcomes = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { - await self.withCoordinatorOverrides( - isolateState: false, - cliAvailable: true, - touchAuthPath: { _, _ in - counter.increment() - await gate.markStarted() - await gate.waitRelease() - box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 2, - createdAt: 2, - persistentRefHash: "ref2") - }, - keychainFingerprint: { box.fingerprint }, - operation: { - let first = Task { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: now, timeout: 2) - } - await gate.waitStarted() - let second = Task { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: now.addingTimeInterval(30), - timeout: 2) - } + let outcomes = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + await self.withCoordinatorOverrides( + isolateState: false, + cliAvailable: true, + touchAuthPath: { _, _ in + counter.increment() + await gate.markStarted() + await gate.waitRelease() + box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2") + try? await Task.sleep(nanoseconds: 50_000_000) + }, + keychainFingerprint: { box.fingerprint }, + operation: { + let first = Task { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: now, timeout: 2) + } + await gate.waitStarted() + let second = Task { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: now.addingTimeInterval(30), + timeout: 2) + } - await gate.release() - return await [first.value, second.value] - }) + await gate.release() + return await [first.value, second.value] + }) + } } #expect(outcomes.allSatisfy { $0 == .attemptedSucceeded }) @@ -381,69 +384,71 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { func `experimental strategy observes security CLI change after touch`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - final class DataBox: @unchecked Sendable { - private let lock = NSLock() - private var _data: Data? - init(data: Data?) { - self._data = data - } + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class DataBox: @unchecked Sendable { + private let lock = NSLock() + private var _data: Data? + init(data: Data?) { + self._data = data + } - func load() -> Data? { - self.lock.lock() - defer { self.lock.unlock() } - return self._data - } + func load() -> Data? { + self.lock.lock() + defer { self.lock.unlock() } + return self._data + } - func store(_ data: Data?) { - self.lock.lock() - self._data = data - self.lock.unlock() - } - } - final class CounterBox: @unchecked Sendable { - private let lock = NSLock() - private(set) var count: Int = 0 - func increment() { - self.lock.lock() - self.count += 1 - self.lock.unlock() + func store(_ data: Data?) { + self.lock.lock() + self._data = data + self.lock.unlock() + } } - } - let beforeData = self.makeCredentialsData( - accessToken: "security-token-before", - expiresAt: Date(timeIntervalSinceNow: -60)) - let afterData = self.makeCredentialsData( - accessToken: "security-token-after", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let dataBox = DataBox(data: beforeData) - let fingerprintCounter = CounterBox() - let outcome = await self.withCoordinatorOverrides( - cliAvailable: true, - touchAuthPath: { _, _ in - dataBox.store(afterData) - }, - keychainFingerprint: { - fingerprintCounter.increment() - return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 11, - createdAt: 11, - persistentRefHash: "framework-fingerprint") - }, - operation: { - await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .dynamic { _ in dataBox.load() }) - { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61000), - timeout: 0.1) + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() } - }) + } + let beforeData = self.makeCredentialsData( + accessToken: "security-token-before", + expiresAt: Date(timeIntervalSinceNow: -60)) + let afterData = self.makeCredentialsData( + accessToken: "security-token-after", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let dataBox = DataBox(data: beforeData) + let fingerprintCounter = CounterBox() + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in + dataBox.store(afterData) + }, + keychainFingerprint: { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 11, + createdAt: 11, + persistentRefHash: "framework-fingerprint") + }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in dataBox.load() }) + { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61000), + timeout: 0.1) + } + }) - #expect(outcome == .attemptedSucceeded) - #expect(fingerprintCounter.count < 1) + #expect(outcome == .attemptedSucceeded) + #expect(fingerprintCounter.count < 1) + } } } From 329f9bb88c5865db65e135d4fca91586a9eeaef8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 04:42:21 +0100 Subject: [PATCH 0138/1239] docs: update appcast for 0.20 --- appcast.xml | 109 +++++++++++++++++++--------------------------------- 1 file changed, 39 insertions(+), 70 deletions(-) diff --git a/appcast.xml b/appcast.xml index 8b06ec9777..4a55cde5f3 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,44 @@ CodexBar + + 0.20 + Wed, 08 Apr 2026 04:42:18 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 55 + 0.20 + 14.0 + CodexBar 0.20 +

Highlights

+
    +
  • Codex: switch between system accounts/profiles without manually logging out and back in. @ratulsarna
  • +
  • Add Perplexity provider support with recurring, bonus, and purchased-credit tracking, Pro/Max plan detection, browser-cookie auto-import, and manual-cookie fallback (#449). Thanks @BeelixGit!
  • +
  • Add OpenCode Go as a separate provider with 5-hour, weekly, and monthly web usage tracking, widget integration, and browser-cookie support.
  • +
  • Claude: fix token and cost inflation caused by cross-file double counting of subagent JSONL logs, fix streaming chunk deduplication, and add claude-sonnet-4-6 pricing. Thanks @enzonaute for the investigation!
  • +
  • Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman!
  • +
+

Providers & Usage

+
    +
  • Perplexity: add recurring, bonus, and purchased-credit tracking; plan detection for Pro/Max; browser-cookie auto-import; and manual-cookie fallback (#449). Thanks @BeelixGit!
  • +
  • OpenCode Go: add a dedicated provider, parse live authenticated workspace Go usage from the web app, keep monthly optional and honor workspace env overrides.
  • +
  • Codex: add workspace attribution for account labels and same-email multi-workspace accounts.
  • +
  • Codex: reconcile live-system and managed accounts by canonical identity, preserve account-scoped usage/history/dashboard state, allow OAuth CLI fallback, and tighten OpenAI web ownership gating so quota and credits only attach to the matching account. Thanks @monterrr and @Rag30 for the initial effort and ideas!
  • +
  • Codex: normalize weekly-only rate limits across OAuth and CLI/RPC so free-plan accounts render as Weekly instead of a fake Session, preserve unknown single-window payloads in the primary lane, hide the empty Session lane in widgets, and accept weekly-only Codex CLI /status/RPC data without failing. @ratulsarna
  • +
  • Codex: refactor the provider end to end into clearer components and better division of responsibilities.
  • +
  • OpenCode: preserve product separation between Zen and Go, improve null/unsupported usage handling, and harden cookie/domain behavior for authenticated web fetches.
  • +
  • Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman!
  • +
+

Menu & Settings

+
    +
  • Codex: add UI for switching the system-level Codex account and promoting a managed account into the live system slot.
  • +
  • Codex: hide display-only OpenAI web extras in widgets and fix buy-credits / credits-only presentation regressions.
  • +
  • Claude: enable “Avoid Keychain prompts” by default, remove the experimental label, and preserve user-action cooldown clearing plus startup bootstrap when Security.framework fallback is still needed.
  • +
  • Fix alignment of menu chart hover coordinates on macOS. Thanks @cuidong233!
  • +
+

View full changelog

+]]>
+ +
0.20.0-beta.1 Wed, 01 Apr 2026 00:36:32 +0900 @@ -70,75 +108,6 @@ ]]> - - 0.18.0 - Sun, 15 Mar 2026 22:16:41 -0700 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 52 - 0.18.0 - 14.0 - CodexBar 0.18.0 -

Highlights

-
    -
  • Add Kilo provider support with API/CLI source modes, widget integration, and pass/credit handling (#454). Built on work by @coreh.
  • -
  • Add Ollama provider, including token-account support in Settings and CLI (#380). Thanks @CryptoSageSnr!
  • -
  • Add OpenRouter provider for credit-based usage tracking (#396). Thanks @chountalas!
  • -
  • Add Codex historical pace with risk forecasting, backfill, and zero-usage-day handling (#482, supersedes #438). Thanks @tristanmanchester!
  • -
  • Add a merged-menu Overview tab with configurable providers and row-to-provider navigation (#416). @ratulsarna
  • -
  • Add an experimental option to suppress Claude Keychain prompts (#388).
  • -
  • Reduce CPU/energy regressions and JSONL scanner overhead in Codex/web usage paths (#402, #392). Thanks @bald-ai and @asonawalla!
  • -
-

Providers & Usage

-
    -
  • Codex: add historical pace risk forecasting and backfill, gate pace computation by display mode, and handle zero-usage days in historical data (#482, supersedes #438). Thanks @tristanmanchester!
  • -
  • Kilo: add provider support with source-mode fallback, clearer credential/login guidance, auto top-up activity labeling, zero-balance credit handling, and pass parsing/menu rendering (#454). Thanks @coreh!
  • -
  • Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr!
  • -
  • OpenRouter: add provider support with credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas!
  • -
  • Gemini: show separate Pro, Flash, and Flash Lite meters by splitting Gemini CLI quota buckets for gemini-2.5-flash and gemini-2.5-flash-lite (#496). Thanks @aladh
  • -
  • Codex: in percent display mode with "show remaining," show remaining credits in the menu bar when session or weekly usage is exhausted (#336). Thanks @teron131!
  • -
  • Claude: surface rate-limit errors from the CLI /usage probe with a user-friendly message, and harden "Failed to load usage data" matching against whitespace-collapsed output.
  • -
  • Claude: restore weekly/Sonnet reset parsing from whitespace-collapsed CLI /usage output so reset times and pace details still appear after CLI fallback.
  • -
  • Claude: fix extra-usage double conversion so OAuth/Web values stay on a single normalization path (#472, supersedes #463). Thanks @Priyans-hu!
  • -
  • Claude: remove root-directory mtime short-circuiting in cost scanning so new session logs inside existing ~/.claude/projects/* folders are discovered reliably (#462, fixes #411). Thanks @Priyans-hu!
  • -
  • Copilot: harden free-plan quota parsing and fallback behavior by treating underdetermined values as unknown, preserving missing metadata as nil (#432, supersedes #393). Thanks @emanuelst!
  • -
  • OpenCode: treat explicit null subscription responses as missing usage data, skip POST fallback, and return a clearer workspace-specific error (#412).
  • -
  • OpenCode: surface clearer HTTP errors. Thanks @SalimBinYousuf1!
  • -
  • Codex: preserve exact GPT-5 model IDs in local cost history, add GPT-5.4 pricing, and label zero-cost gpt-5.3-codex-spark sessions as "Research Preview" in cost breakdowns (#511). Thanks @iam-brain!
  • -
  • Augment: prevent refresh stalls when auggie account status hangs by replacing unbounded CLI waits with timed subprocess execution and fallback handling (#481). Thanks @bryant24hao!
  • -
  • Update Kiro parsing for kiro-cli 1.24+ / Q Developer formats and non-managed plan handling (#288). Thanks @kilhyeonjun!
  • -
  • Kimi: in automatic metric mode, prioritize the 5-hour rate-limit window for menu bar and merged highest-usage calculations (#390). Thanks @ajaxjiang96!
  • -
  • Browser cookie import: match Gecko *.default* profile directories case-insensitively so Firefox/Zen cookie detection works with uppercase .Default directories (#422). Thanks @bald-ai!
  • -
  • MiniMax: make both Settings "Open Coding Plan" actions region-aware so China mainland selection opens platform.minimaxi.com instead of the global domain (#426, fixes #378). Thanks @bald-ai!
  • -
  • Menu: rebuild the merged provider switcher when “Show usage as used” changes so switcher progress updates immediately (#306). Thanks @Flohhhhh!
  • -
  • Warp: update API key setup guidance.
  • -
  • Claude: update the "not installed" help link to the current Claude Code documentation URL (#431). Thanks @skebby11!
  • -
  • Fix Claude setup message package name (#376). Thanks @daegwang!
  • -
-

Menu & Settings

-
    -
  • Merged menu: keep Merge Icons, the switcher, and Overview tied to user-enabled providers even when some providers are temporarily unavailable, while defaulting menu content and icon state to an available provider when possible (#525). Thanks @Astro-Han!
  • -
  • Merged menu: add an Overview switcher tab that shows up to three provider usage rows in provider order (#416).
  • -
  • Settings: add "Overview tab providers" controls to choose/deselect Overview providers, with persisted selection reconciliation as enabled providers change (#416).
  • -
  • Menu: hide contextual provider actions while Overview is selected and rebuild switcher state when overview availability changes (#416).
  • -
-

Claude OAuth & Keychain

-
    -
  • Add an experimental Claude OAuth Security-CLI reader path and option in settings.
  • -
  • Apply stored prompt mode and fallback policy to silent/noninteractive keychain probes.
  • -
  • Add cooldown for background OAuth keychain retries.
  • -
  • Disable experimental toggle when keychain access is disabled.
  • -
  • Use a claude-code/ User-Agent for OAuth usage requests instead of a generic identifier.
  • -
-

Performance & Reliability

-
    -
  • Codex/OpenAI web: reduce CPU and energy overhead by shortening failed CLI probe windows, capping web retry timeouts, and using adaptive idle blink scheduling (#402). Thanks @bald-ai!
  • -
  • Cost usage scanner: optimize JSONL chunk parsing to avoid buffer-front removal overhead on large logs (#392). Thanks @asonawalla!
  • -
  • TTY runner: fence shutdown registration to avoid launch/shutdown races, isolate process groups before shutdown rejection, and ensure lingering CLI descendants are cleaned up on app termination (#429). Thanks @uraimo!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 @@ -180,4 +149,4 @@
-
+ \ No newline at end of file From 375680488b75de882a526cc73c76a3adb7ed0336 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 05:59:14 +0100 Subject: [PATCH 0139/1239] chore: start 0.21 development --- CHANGELOG.md | 8 ++++++++ version.env | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aedbfeac7b..57dae9ff39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.21 — Unreleased + +### Highlights + +### Providers & Usage + +### Menu & Settings + ## 0.20 — 2026-04-07 ### Highlights diff --git a/version.env b/version.env index 3ed30494fa..b28d35c74a 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.20 -BUILD_NUMBER=55 +MARKETING_VERSION=0.21 +BUILD_NUMBER=56 From 67e937dcb0d57021f248a6a5da5386b110a1bf51 Mon Sep 17 00:00:00 2001 From: Takumi Mori <51111242+takumi3488@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:25:53 +0900 Subject: [PATCH 0140/1239] feat(zai): display 5-hour token quota as tertiary row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The z.ai API can return two TOKENS_LIMIT entries — one for the 5-hour window (unit:3/number:5) and one for the weekly window (unit:6/number:1). Previously the second entry silently overwrote the first, discarding the 5-hour quota entirely. Changes: - Add ZaiLimitUnit.weeks (rawValue 6) with windowMinutes = n×7×24×60 - Add ZaiUsageSnapshot.sessionTokenLimit for the shorter-window entry - Rewrite parseUsageSnapshot to collect all TOKENS_LIMIT entries and sort by windowMinutes: shorter → sessionTokenLimit (tertiary), longer → tokenLimit (primary), preserving the existing display unchanged for APIs that return only one TOKENS_LIMIT - Map sessionTokenLimit to UsageSnapshot.tertiary in toUsageSnapshot() - Enable supportsOpus + opusLabel "5-hour" in ZaiProviderDescriptor so MenuCardView/MenuDescriptor/CLIRenderer render the new tertiary row - Wire zaiSessionDetail text into the tertiary card metric - Add ZaiThreeLimitTests covering 3-entry parsing, unit:6 enum, and backward-compatible 2-entry fallback --- Sources/CodexBar/MenuCardView.swift | 4 + .../Providers/Zai/ZaiProviderDescriptor.swift | 4 +- .../Providers/Zai/ZaiUsageStats.swift | 39 ++++- Tests/CodexBarTests/ZaiProviderTests.swift | 134 ++++++++++++++++++ 4 files changed, 175 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 14703fae91..d211a9d964 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -943,6 +943,7 @@ extension UsageMenuCardView.Model { let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) + let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit) let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) if input.provider == .codex, let codexProjection = input.codexProjection { metrics.append(contentsOf: Self.codexRateMetrics( @@ -984,6 +985,9 @@ extension UsageMenuCardView.Model { { tertiaryDetailText = detail } + if input.provider == .zai, let detail = zaiSessionDetail { + tertiaryDetailText = detail + } // Perplexity purchased credits don't reset; show balance without "Resets" prefix. let opusResetText: String? = input.provider == .perplexity ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index 430066a109..d644451c6c 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -12,8 +12,8 @@ public enum ZaiProviderDescriptor { displayName: "z.ai", sessionLabel: "Tokens", weeklyLabel: "MCP", - opusLabel: nil, - supportsOpus: false, + opusLabel: "5-hour", + supportsOpus: true, supportsCredits: false, creditsHint: "", toggleTitle: "Show z.ai usage", diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 1592a61819..09ebd21c1e 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -15,6 +15,7 @@ public enum ZaiLimitUnit: Int, Sendable { case days = 1 case hours = 3 case minutes = 5 + case weeks = 6 } /// A single limit entry from the z.ai API @@ -69,6 +70,8 @@ extension ZaiLimitEntry { return self.number * 60 case .days: return self.number * 24 * 60 + case .weeks: + return self.number * 7 * 24 * 60 case .unknown: return nil } @@ -80,6 +83,7 @@ extension ZaiLimitEntry { case .minutes: "minute" case .hours: "hour" case .days: "day" + case .weeks: "week" case .unknown: nil } guard let unitLabel else { return nil } @@ -129,12 +133,21 @@ public struct ZaiUsageDetail: Sendable, Codable { /// Complete z.ai usage response public struct ZaiUsageSnapshot: Sendable { public let tokenLimit: ZaiLimitEntry? + /// Shorter-window TOKENS_LIMIT (e.g. 5-hour), present only when the API returns two TOKENS_LIMIT entries. + public let sessionTokenLimit: ZaiLimitEntry? public let timeLimit: ZaiLimitEntry? public let planName: String? public let updatedAt: Date - public init(tokenLimit: ZaiLimitEntry?, timeLimit: ZaiLimitEntry?, planName: String?, updatedAt: Date) { + public init( + tokenLimit: ZaiLimitEntry?, + sessionTokenLimit: ZaiLimitEntry? = nil, + timeLimit: ZaiLimitEntry?, + planName: String?, + updatedAt: Date) + { self.tokenLimit = tokenLimit + self.sessionTokenLimit = sessionTokenLimit self.timeLimit = timeLimit self.planName = planName self.updatedAt = updatedAt @@ -150,6 +163,7 @@ extension ZaiUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { let primaryLimit = self.tokenLimit ?? self.timeLimit let secondaryLimit = (self.tokenLimit != nil && self.timeLimit != nil) ? self.timeLimit : nil + let tertiaryLimit = self.sessionTokenLimit let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow( usedPercent: 0, @@ -157,6 +171,7 @@ extension ZaiUsageSnapshot { resetsAt: nil, resetDescription: nil) let secondary = secondaryLimit.map { Self.rateWindow(for: $0) } + let tertiary = tertiaryLimit.map { Self.rateWindow(for: $0) } let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -168,7 +183,7 @@ extension ZaiUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, + tertiary: tertiary, providerCost: nil, zaiUsage: self, updatedAt: self.updatedAt, @@ -364,22 +379,38 @@ public struct ZaiUsageFetcher: Sendable { throw ZaiUsageError.parseFailed("Missing data") } - var tokenLimit: ZaiLimitEntry? + var tokenLimits: [ZaiLimitEntry] = [] var timeLimit: ZaiLimitEntry? for limit in responseData.limits { if let entry = limit.toLimitEntry() { switch entry.type { case .tokensLimit: - tokenLimit = entry + tokenLimits.append(entry) case .timeLimit: timeLimit = entry } } } + // When two TOKENS_LIMIT entries are present, the shorter-window one (5-hour) becomes + // sessionTokenLimit (tertiary) and the longer-window one (weekly) becomes tokenLimit (primary). + let tokenLimit: ZaiLimitEntry? + let sessionTokenLimit: ZaiLimitEntry? + if tokenLimits.count >= 2 { + let sorted = tokenLimits.sorted { + ($0.windowMinutes ?? Int.max) < ($1.windowMinutes ?? Int.max) + } + sessionTokenLimit = sorted.first // shorter window → 5-hour + tokenLimit = sorted.last // longer window → weekly + } else { + tokenLimit = tokenLimits.first + sessionTokenLimit = nil + } + return ZaiUsageSnapshot( tokenLimit: tokenLimit, + sessionTokenLimit: sessionTokenLimit, timeLimit: timeLimit, planName: responseData.planName, updatedAt: Date()) diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index a112e281ee..4f92775254 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -67,7 +67,9 @@ struct ZaiUsageSnapshotTests { #expect(usage.primary?.resetDescription == "5 hours window") #expect(usage.secondary?.usedPercent == 20) #expect(usage.secondary?.resetDescription == "30 days window") + #expect(usage.tertiary == nil) #expect(usage.zaiUsage?.tokenLimit?.usage == 100) + #expect(usage.zaiUsage?.sessionTokenLimit == nil) } @Test @@ -320,6 +322,138 @@ struct ZaiUsageParsingTests { } } +struct ZaiThreeLimitTests { + @Test + func `parses three limit entries into session weekly and mcp slots`() throws { + let json = """ + { + "code": 200, + "msg": "操作成功", + "data": { + "limits": [ + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 25, + "nextResetTime": 1775020168897 + }, + { + "type": "TOKENS_LIMIT", + "unit": 6, + "number": 1, + "percentage": 9, + "nextResetTime": 1775588029998 + }, + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 1000, + "currentValue": 224, + "remaining": 776, + "percentage": 22, + "nextResetTime": 1777575229998, + "usageDetails": [ + { "modelCode": "search-prime", "usage": 210 }, + { "modelCode": "web-reader", "usage": 14 } + ] + } + ], + "level": "pro" + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + // Weekly token limit (unit:6=weeks, longer window) → tokenLimit (primary) + #expect(snapshot.tokenLimit?.unit == .weeks) + #expect(snapshot.tokenLimit?.number == 1) + #expect(snapshot.tokenLimit?.percentage == 9.0) + #expect(snapshot.tokenLimit?.windowMinutes == 10080) + + // 5-hour token limit (unit:3=hours, number:5 → 300 min) → sessionTokenLimit (tertiary) + #expect(snapshot.sessionTokenLimit?.unit == .hours) + #expect(snapshot.sessionTokenLimit?.number == 5) + #expect(snapshot.sessionTokenLimit?.percentage == 25.0) + #expect(snapshot.sessionTokenLimit?.windowMinutes == 300) + + // MCP time limit → timeLimit (secondary) + #expect(snapshot.timeLimit?.usage == 1000) + #expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime") + + // UsageSnapshot slot mapping + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 9.0) + #expect(usage.primary?.windowMinutes == 10080) + #expect(usage.secondary != nil) // MCP + #expect(usage.tertiary?.usedPercent == 25.0) + #expect(usage.tertiary?.windowMinutes == 300) + } + + @Test + func `unit 6 maps to weeks with correct window minutes`() { + let entry = ZaiLimitEntry( + type: .tokensLimit, + unit: .weeks, + number: 1, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: 9, + usageDetails: [], + nextResetTime: nil) + #expect(entry.windowMinutes == 10080) + #expect(entry.windowDescription == "1 week") + #expect(entry.windowLabel == "1 week window") + } + + @Test + func `two limit entries remain backward compatible`() throws { + let json = """ + { + "code": 200, + "msg": "Operation successful", + "data": { + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 100, + "currentValue": 50, + "remaining": 50, + "percentage": 50, + "usageDetails": [] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 34, + "nextResetTime": 1768507567547 + } + ] + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.tokenLimit != nil) + #expect(snapshot.sessionTokenLimit == nil) + #expect(snapshot.timeLimit != nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary != nil) + #expect(usage.secondary != nil) + #expect(usage.tertiary == nil) + } +} + struct ZaiAPIRegionTests { @Test func `defaults to global endpoint`() { From 1121370e7dbfaf5e3711157e7432a71497ddf632 Mon Sep 17 00:00:00 2001 From: Takumi Mori <51111242+takumi3488@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:38:35 +0900 Subject: [PATCH 0141/1239] simplify: remove tertiaryLimit alias and redundant inline comments --- .../CodexBarCore/Providers/Zai/ZaiUsageStats.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 09ebd21c1e..8cca1f4625 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -163,15 +163,13 @@ extension ZaiUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { let primaryLimit = self.tokenLimit ?? self.timeLimit let secondaryLimit = (self.tokenLimit != nil && self.timeLimit != nil) ? self.timeLimit : nil - let tertiaryLimit = self.sessionTokenLimit - let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow( usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = secondaryLimit.map { Self.rateWindow(for: $0) } - let tertiary = tertiaryLimit.map { Self.rateWindow(for: $0) } + let tertiary = self.sessionTokenLimit.map { Self.rateWindow(for: $0) } let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -393,16 +391,15 @@ public struct ZaiUsageFetcher: Sendable { } } - // When two TOKENS_LIMIT entries are present, the shorter-window one (5-hour) becomes - // sessionTokenLimit (tertiary) and the longer-window one (weekly) becomes tokenLimit (primary). + // Multiple TOKENS_LIMIT entries: shortest window → sessionTokenLimit (tertiary), longest → tokenLimit (primary). let tokenLimit: ZaiLimitEntry? let sessionTokenLimit: ZaiLimitEntry? if tokenLimits.count >= 2 { let sorted = tokenLimits.sorted { ($0.windowMinutes ?? Int.max) < ($1.windowMinutes ?? Int.max) } - sessionTokenLimit = sorted.first // shorter window → 5-hour - tokenLimit = sorted.last // longer window → weekly + sessionTokenLimit = sorted.first + tokenLimit = sorted.last } else { tokenLimit = tokenLimits.first sessionTokenLimit = nil From 3fa8cd9979bd39d6c91e07f25894a47b2f2ec0b0 Mon Sep 17 00:00:00 2001 From: Takumi Mori <51111242+takumi3488@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:17:49 +0900 Subject: [PATCH 0142/1239] test(zai): add menu card, characterization, and CLI tests for 5-hour tertiary row --- Tests/CodexBarTests/CLISnapshotTests.swift | 23 ++++++ ...dexPresentationCharacterizationTests.swift | 50 +++++++++++++ Tests/CodexBarTests/ZaiMenuCardTests.swift | 70 +++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 Tests/CodexBarTests/ZaiMenuCardTests.swift diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index d5218d1bcd..b3ce7ae523 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -425,4 +425,27 @@ struct CLISnapshotTests { #expect(!output.contains("\u{001B}[")) #expect(output.contains("Status: Operational – Operational")) } + + @Test + func `renders 5-hour tertiary row for zai`() { + let snap = UsageSnapshot( + primary: .init(usedPercent: 9, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: .init(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: .init(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CLIRenderer.renderText( + provider: .zai, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "z.ai 0.0.0 (zai)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("5-hour:")) + #expect(output.contains("Tokens:")) + #expect(output.contains("MCP:")) + } } diff --git a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift index 81fd776030..047a15246e 100644 --- a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift @@ -346,6 +346,56 @@ struct CodexPresentationCharacterizationTests { #expect(store.codexCookieCacheScopeForOpenAIWeb() == nil) } + @Test + func `zai menu descriptor includes Tokens MCP and 5-hour rows`() { + let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-zai-three-quota") + settings.statusChecksEnabled = false + + let fetcher = UsageFetcher(environment: [:]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 50, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "pro")), + provider: .zai) + + let descriptor = MenuDescriptor.build( + provider: .zai, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: false) + + let lines = self.textLines(from: descriptor) + #expect(lines.contains(where: { $0.hasPrefix("Tokens:") })) + #expect(lines.contains(where: { $0.hasPrefix("MCP:") })) + #expect(lines.contains(where: { $0.hasPrefix("5-hour:") })) + } + private func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/ZaiMenuCardTests.swift b/Tests/CodexBarTests/ZaiMenuCardTests.swift new file mode 100644 index 0000000000..4433fdf9f5 --- /dev/null +++ b/Tests/CodexBarTests/ZaiMenuCardTests.swift @@ -0,0 +1,70 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ZaiMenuCardTests { + @Test + func `zai metrics titles are Tokens MCP and 5-hour when session token limit present`() throws { + let now = Date() + let zai = ZaiUsageSnapshot( + tokenLimit: ZaiLimitEntry( + type: .tokensLimit, + unit: .weeks, + number: 1, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: 9, + usageDetails: [], + nextResetTime: nil), + sessionTokenLimit: ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: 1000, + currentValue: 750, + remaining: 250, + percentage: 25, + usageDetails: [], + nextResetTime: nil), + timeLimit: ZaiLimitEntry( + type: .timeLimit, + unit: .minutes, + number: 1, + usage: 100, + currentValue: 50, + remaining: 50, + percentage: 50, + usageDetails: [], + nextResetTime: nil), + planName: "pro", + updatedAt: now) + let snapshot = zai.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.zai]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .zai, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Tokens", "MCP", "5-hour"]) + let tertiary = try #require(model.metrics.first(where: { $0.title == "5-hour" })) + #expect(tertiary.detailText == "750 / 1K (250 remaining)") + } +} From 3a75ec261249835c97384057c4578689b8d40fcd Mon Sep 17 00:00:00 2001 From: Takumi Mori <51111242+takumi3488@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:29:29 +0900 Subject: [PATCH 0143/1239] fix: lint --- Sources/CodexBar/MenuCardView.swift | 1 - Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift | 3 ++- Tests/CodexBarTests/ZaiProviderTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index d211a9d964..ffb3788867 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1165,7 +1165,6 @@ extension UsageMenuCardView.Model { paceOnTop: paceDetail?.paceOnTop ?? true) } } - private static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left return [ diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 8cca1f4625..1936c9f7e8 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -391,7 +391,8 @@ public struct ZaiUsageFetcher: Sendable { } } - // Multiple TOKENS_LIMIT entries: shortest window → sessionTokenLimit (tertiary), longest → tokenLimit (primary). + // Multiple TOKENS_LIMIT entries: shortest window → sessionTokenLimit (tertiary), + // longest → tokenLimit (primary). let tokenLimit: ZaiLimitEntry? let sessionTokenLimit: ZaiLimitEntry? if tokenLimits.count >= 2 { diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index 4f92775254..ce23d2351a 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -388,7 +388,7 @@ struct ZaiThreeLimitTests { let usage = snapshot.toUsageSnapshot() #expect(usage.primary?.usedPercent == 9.0) #expect(usage.primary?.windowMinutes == 10080) - #expect(usage.secondary != nil) // MCP + #expect(usage.secondary != nil) // MCP #expect(usage.tertiary?.usedPercent == 25.0) #expect(usage.tertiary?.windowMinutes == 300) } From dd54e340e1012ab4ff76517f7d4f5ff05ba8b42a Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 8 Apr 2026 15:09:19 +0530 Subject: [PATCH 0144/1239] Fix z.ai menu bar 5-hour lane selection --- .../MenuBarMetricWindowResolver.swift | 163 ++++++++++++------ Sources/CodexBar/MenuCardView.swift | 1 + .../CodexBar/PreferencesProvidersPane.swift | 1 - .../SettingsStore+MenuPreferences.swift | 9 +- .../MenuBarMetricWindowResolverTests.swift | 22 +++ .../ProvidersPaneCoverageTests.swift | 35 ++++ .../SettingsStoreAdditionalTests.swift | 12 +- .../SettingsStoreCoverageTests.swift | 2 +- .../UsageStoreHighestUsageTests.swift | 40 +++++ 9 files changed, 221 insertions(+), 64 deletions(-) create mode 100644 Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index c2e2d10b3d..9857f39a11 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -2,6 +2,12 @@ import CodexBarCore import Foundation enum MenuBarMetricWindowResolver { + private enum Lane { + case primary + case secondary + case tertiary + } + static func rateWindow( preference: MenuBarMetricPreference, provider: UsageProvider, @@ -12,68 +18,117 @@ enum MenuBarMetricWindowResolver { guard let snapshot else { return nil } switch preference { case .tertiary: - if provider == .perplexity { - return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary - } - guard provider == .cursor else { - if provider == .antigravity { - return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary - } - return snapshot.primary ?? snapshot.secondary - } - return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary + return Self.window(in: snapshot, following: Self.tertiaryOrder(for: provider)) case .primary: - if provider == .perplexity { - return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary - } - if provider == .antigravity { - return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary - } - return snapshot.primary ?? snapshot.secondary + return Self.window(in: snapshot, following: Self.primaryOrder(for: provider)) case .secondary: - if provider == .perplexity { - return snapshot.secondary ?? snapshot.tertiary ?? snapshot.primary - } - if provider == .antigravity { - return snapshot.secondary ?? snapshot.primary ?? snapshot.tertiary - } - return snapshot.secondary ?? snapshot.primary + return Self.window(in: snapshot, following: Self.secondaryOrder(for: provider)) case .average: - guard supportsAverage, - let primary = snapshot.primary, - let secondary = snapshot.secondary - else { - if provider == .antigravity { - return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary - } - return snapshot.primary ?? snapshot.secondary - } - let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 - return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + return Self.averageWindow(provider: provider, snapshot: snapshot, supportsAverage: supportsAverage) case .automatic: + return Self.automaticWindow(provider: provider, snapshot: snapshot) + } + } + + private static func tertiaryOrder(for provider: UsageProvider) -> [Lane] { + if provider == .zai { + return [.tertiary, .primary, .secondary] + } + if provider == .perplexity || provider == .cursor || provider == .antigravity { + return [.tertiary, .secondary, .primary] + } + return [.primary, .secondary] + } + + private static func primaryOrder(for provider: UsageProvider) -> [Lane] { + if provider == .zai { + return [.primary, .tertiary, .secondary] + } + if provider == .perplexity || provider == .antigravity { + return [.primary, .secondary, .tertiary] + } + return [.primary, .secondary] + } + + private static func secondaryOrder(for provider: UsageProvider) -> [Lane] { + if provider == .zai || provider == .antigravity { + return [.secondary, .primary, .tertiary] + } + if provider == .perplexity { + return [.secondary, .tertiary, .primary] + } + return [.secondary, .primary] + } + + private static func averageWindow( + provider: UsageProvider, + snapshot: UsageSnapshot, + supportsAverage: Bool) + -> RateWindow? + { + guard supportsAverage, + let primary = snapshot.primary, + let secondary = snapshot.secondary + else { if provider == .antigravity { - return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary - } - if provider == .perplexity { - return snapshot.automaticPerplexityWindow() - } - if provider == .factory || provider == .kimi { - return snapshot.secondary ?? snapshot.primary - } - if provider == .copilot, - let primary = snapshot.primary, - let secondary = snapshot.secondary - { - return primary.usedPercent >= secondary.usedPercent ? primary : secondary - } - if provider == .cursor { - return Self.mostConstrainedWindow( - primary: snapshot.primary, - secondary: snapshot.secondary, - tertiary: snapshot.tertiary) + return self.window(in: snapshot, following: [.primary, .secondary, .tertiary]) } return snapshot.primary ?? snapshot.secondary } + + let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 + return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + } + + private static func automaticWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + if provider == .antigravity { + return self.window(in: snapshot, following: [.primary, .secondary, .tertiary]) + } + if provider == .perplexity { + return snapshot.automaticPerplexityWindow() + } + if provider == .zai { + return self.mostConstrainedWindow( + primary: snapshot.primary, + secondary: snapshot.tertiary, + tertiary: nil) ?? snapshot.secondary + } + if provider == .factory || provider == .kimi { + return snapshot.secondary ?? snapshot.primary + } + if provider == .copilot, + let primary = snapshot.primary, + let secondary = snapshot.secondary + { + return primary.usedPercent >= secondary.usedPercent ? primary : secondary + } + if provider == .cursor { + return Self.mostConstrainedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: snapshot.tertiary) + } + return snapshot.primary ?? snapshot.secondary + } + + private static func window(in snapshot: UsageSnapshot, following lanes: [Lane]) -> RateWindow? { + for lane in lanes { + if let window = self.window(in: snapshot, lane: lane) { + return window + } + } + return nil + } + + private static func window(in snapshot: UsageSnapshot, lane: Lane) -> RateWindow? { + switch lane { + case .primary: + snapshot.primary + case .secondary: + snapshot.secondary + case .tertiary: + snapshot.tertiary + } } private static func mostConstrainedWindow( diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index ffb3788867..d211a9d964 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1165,6 +1165,7 @@ extension UsageMenuCardView.Model { paceOnTop: paceDetail?.paceOnTop ?? true) } } + private static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left return [ diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 327f515645..c95f404310 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -440,7 +440,6 @@ struct ProvidersPane: View { } func menuBarMetricPicker(for provider: UsageProvider) -> ProviderSettingsPickerDescriptor? { - if provider == .zai { return nil } let options: [ProviderSettingsPickerOption] if provider == .openrouter { options = [ diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 34601636a6..fa62c073f7 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -3,7 +3,6 @@ import Foundation extension SettingsStore { func menuBarMetricPreference(for provider: UsageProvider) -> MenuBarMetricPreference { - if provider == .zai { return .primary } if provider == .openrouter { let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? "" let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic @@ -26,10 +25,6 @@ extension SettingsStore { } func setMenuBarMetricPreference(_ preference: MenuBarMetricPreference, for provider: UsageProvider) { - if provider == .zai { - self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.primary.rawValue - return - } if provider == .openrouter { switch preference { case .automatic, .primary: @@ -51,11 +46,11 @@ extension SettingsStore { } func menuBarMetricSupportsTertiary(for provider: UsageProvider) -> Bool { - provider == .cursor || provider == .perplexity + provider == .cursor || provider == .perplexity || provider == .zai } func menuBarMetricSupportsTertiary(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool { - if provider == .cursor { + if provider == .cursor || provider == .zai { return snapshot?.tertiary != nil } return self.menuBarMetricSupportsTertiary(for: provider) diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift new file mode 100644 index 0000000000..e18d869afe --- /dev/null +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -0,0 +1,22 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct MenuBarMetricWindowResolverTests { + @Test + func `automatic metric uses zai 5-hour token lane when it is most constrained`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 92, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .zai, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 92) + } +} diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 2722d41d94..ee7e4e2298 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -62,6 +62,41 @@ struct ProvidersPaneCoverageTests { #expect(tertiaryOption?.title == "Tertiary (API)") } + @Test + func `zai menu bar metric picker omits tertiary lane when snapshot has no 5-hour metric`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-no-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .zai) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids == [ + MenuBarMetricPreference.automatic.rawValue, + MenuBarMetricPreference.primary.rawValue, + MenuBarMetricPreference.secondary.rawValue, + ]) + } + + @Test + func `zai menu bar metric picker includes tertiary 5-hour lane when snapshot has it`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .zai) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .zai) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (5-hour)") + } + @Test func `gemini menu bar metric picker omits tertiary lane`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-gemini-no-tertiary-picker") diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 3de5099f4e..7ff18809ca 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -9,8 +9,18 @@ struct SettingsStoreAdditionalTests { func `menu bar metric preference handles zai and average`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-metric") + #expect(settings.menuBarMetricPreference(for: .zai) == .automatic) + settings.setMenuBarMetricPreference(.average, for: .zai) - #expect(settings.menuBarMetricPreference(for: .zai) == .primary) + #expect(settings.menuBarMetricPreference(for: .zai) == .automatic) + + settings.setMenuBarMetricPreference(.secondary, for: .zai) + #expect(settings.menuBarMetricPreference(for: .zai) == .secondary) + + settings.setMenuBarMetricPreference(.tertiary, for: .zai) + #expect(settings.menuBarMetricPreference(for: .zai) == .tertiary) + #expect(settings.menuBarMetricPreference(for: .zai, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsTertiary(for: .zai, snapshot: nil) == false) settings.setMenuBarMetricPreference(.average, for: .codex) #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index e870d31247..0b18ad89a5 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -47,7 +47,7 @@ struct SettingsStoreCoverageTests { #expect(settings.menuBarMetricSupportsAverage(for: .gemini)) settings.setMenuBarMetricPreference(.secondary, for: .zai) - #expect(settings.menuBarMetricPreference(for: .zai) == .primary) + #expect(settings.menuBarMetricPreference(for: .zai) == .secondary) settings.menuBarDisplayMode = .pace #expect(settings.menuBarDisplayMode == .pace) diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 217e28da30..49a353234c 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -156,6 +156,46 @@ struct UsageStoreHighestUsageTests { #expect(highest?.usedPercent == 85) } + @Test + func `automatic metric uses zai 5-hour token lane when ranking highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-zai-automatic-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .zai) + settings.addTokenAccount(provider: .zai, label: "Primary", token: "zai-token") + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let zaiMeta = registry.metadata[.zai] { + settings.setProviderEnabled(provider: .zai, metadata: zaiMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let zaiSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 15, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 90, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(zaiSnapshot, provider: .zai) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .zai) + #expect(highest?.usedPercent == 90) + } + @Test func `automatic metric keeps copilot most constrained ranking`() { let settings = SettingsStore( From f350da52cc0f0d10a5fec256d4a916ee6ecab8dd Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 8 Apr 2026 15:20:05 +0530 Subject: [PATCH 0145/1239] Fix resolver test import --- Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index e18d869afe..e72581e064 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Foundation import Testing @testable import CodexBar From 9bb4174908d7f1950cff8cc4d5900d628223239d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 8 Apr 2026 15:52:51 +0530 Subject: [PATCH 0146/1239] Drain trailing PTY output after process exit --- .../Host/PTY/TTYCommandRunner.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 8870d48db6..4361ac71f3 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -487,6 +487,16 @@ public struct TTYCommandRunner { return appended } + func drainRemainingOutput(until drainDeadline: Date) { + while Date() < drainDeadline { + let newData = readChunk() + if newData.isEmpty { + usleep(20000) + continue + } + } + } + func firstLink(in data: Data) -> String? { guard let s = String(data: data, encoding: .utf8) else { return nil } let pattern = #"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+"# @@ -621,6 +631,13 @@ public struct TTYCommandRunner { usleep(50000) } } + } else if !proc.isRunning { + // PTY-backed scripts can exit before their final echo becomes readable on the parent side. + // Give the kernel a brief non-blocking drain window so we don't lose the last line of output. + let drainFor = max(0, min(0.2, deadline.timeIntervalSinceNow)) + if drainFor > 0 { + drainRemainingOutput(until: Date().addingTimeInterval(drainFor)) + } } let text = String(data: buffer, encoding: .utf8) ?? "" From 0f7d07176de83bb729bf056c3b247e22771e7341 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 8 Apr 2026 16:48:17 +0530 Subject: [PATCH 0147/1239] Harden PTY post-exit drain classification --- .../Host/PTY/TTYCommandRunner.swift | 121 ++++++++++++++---- .../CodexBarTests/TTYCommandRunnerTests.swift | 106 +++++++++++++++ 2 files changed, 199 insertions(+), 28 deletions(-) diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 4361ac71f3..d55bff859f 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -233,6 +233,12 @@ public struct TTYCommandRunner { } } + enum DrainReadResult { + case data(Data) + case wouldBlock + case closed + } + static func lowercasedASCII(_ data: Data) -> Data { guard !data.isEmpty else { return data } var out = Data(count: data.count) @@ -250,6 +256,43 @@ public struct TTYCommandRunner { return out } + static func drainRemainingOutput( + until drainDeadline: Date, + readChunk: () -> DrainReadResult, + processChunk: (Data) -> Void, + sleep: (UInt32) -> Void = { usleep($0) }) + { + while Date() < drainDeadline { + switch readChunk() { + case let .data(newData): + processChunk(newData) + case .wouldBlock: + sleep(20000) + case .closed: + return + } + } + } + + static func drainReadResult(for data: Data, terminalRead: Int, errno err: Int32) -> DrainReadResult { + if !data.isEmpty { return .data(data) } + + if terminalRead == 0 { + return .closed + } + + if terminalRead < 0 { + if err == EAGAIN || err == EWOULDBLOCK || err == EINTR { + return .wouldBlock + } + if err == EIO { + return .closed + } + } + + return .closed + } + static func locateBundledHelper(_ name: String) -> String? { let fm = FileManager.default @@ -471,10 +514,13 @@ public struct TTYCommandRunner { let isCodexStatus = isCodex && trimmed == "/status" var buffer = Data() - func readChunk() -> Data { + func readChunkResult() -> (data: Data, terminalRead: Int, errno: Int32) { var appended = Data() + var terminalRead = 0 + var terminalErrno: Int32 = 0 while true { var tmp = [UInt8](repeating: 0, count: 8192) + errno = 0 let n = read(primaryFD, &tmp, tmp.count) if n > 0 { let slice = tmp.prefix(n) @@ -482,19 +528,20 @@ public struct TTYCommandRunner { appended.append(contentsOf: slice) continue } + terminalRead = Int(n) + terminalErrno = errno break } - return appended + return (appended, terminalRead, terminalErrno) } - func drainRemainingOutput(until drainDeadline: Date) { - while Date() < drainDeadline { - let newData = readChunk() - if newData.isEmpty { - usleep(20000) - continue - } - } + func readChunk() -> Data { + readChunkResult().data + } + + func readDrainChunk() -> DrainReadResult { + let result = readChunkResult() + return Self.drainReadResult(for: result.data, terminalRead: result.terminalRead, errno: result.errno) } func firstLink(in data: Data) -> String? { @@ -546,27 +593,26 @@ public struct TTYCommandRunner { var recentText = "" var lastOutputAt = Date() - while Date() < deadline { - let newData = readChunk() - if !newData.isEmpty { - lastOutputAt = Date() - if let chunkText = String(bytes: newData, encoding: .utf8) { - recentText += chunkText - if recentText.count > 8192 { - recentText.removeFirst(recentText.count - 8192) - } + func processNonCodexChunk(_ newData: Data, allowSends: Bool, allowStop: Bool) -> Bool { + guard !newData.isEmpty else { return false } + + lastOutputAt = Date() + if let chunkText = String(bytes: newData, encoding: .utf8) { + recentText += chunkText + if recentText.count > 8192 { + recentText.removeFirst(recentText.count - 8192) } } + let scanData = scanBuffer.append(newData) if Date() >= nextCursorCheckAt, - !scanData.isEmpty, scanData.range(of: cursorQuery) != nil { try? send("\u{1b}[1;1R") nextCursorCheckAt = Date().addingTimeInterval(1.0) } - if !sendNeedles.isEmpty { + if allowSends, !sendNeedles.isEmpty { let recentTextCollapsed = recentText.replacingOccurrences(of: "\r", with: "") for item in sendNeedles where !triggeredSends.contains(item.needle) { let matched = scanData.range(of: item.needle) != nil || @@ -584,16 +630,31 @@ public struct TTYCommandRunner { } if urlNeedles.contains(where: { scanData.range(of: $0) != nil }) { - urlSeen = true - if urlSeen { + if !urlSeen { + urlSeen = true onURLDetected?() } - if options.stopOnURL { - stoppedEarly = true - break + if allowStop, options.stopOnURL { + return true } } - if !stopNeedles.isEmpty, stopNeedles.contains(where: { scanData.range(of: $0) != nil }) { + + if allowStop, !stopNeedles.isEmpty, stopNeedles.contains(where: { scanData.range(of: $0) != nil }) { + return true + } + + return false + } + + while Date() < deadline { + let readResult = readDrainChunk() + let newData = switch readResult { + case let .data(data): + data + case .wouldBlock, .closed: + Data() + } + if processNonCodexChunk(newData, allowSends: true, allowStop: true) { stoppedEarly = true break } @@ -610,6 +671,7 @@ public struct TTYCommandRunner { lastEnter = Date() } + if case .closed = readResult, !proc.isRunning { break } if !proc.isRunning { break } usleep(60000) } @@ -636,7 +698,10 @@ public struct TTYCommandRunner { // Give the kernel a brief non-blocking drain window so we don't lose the last line of output. let drainFor = max(0, min(0.2, deadline.timeIntervalSinceNow)) if drainFor > 0 { - drainRemainingOutput(until: Date().addingTimeInterval(drainFor)) + Self.drainRemainingOutput( + until: Date().addingTimeInterval(drainFor), + readChunk: readDrainChunk, + processChunk: { _ = processNonCodexChunk($0, allowSends: false, allowStop: false) }) } } diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index fe4cfca51d..78c6419ea0 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -4,6 +4,23 @@ import Testing @Suite(.serialized) struct TTYCommandRunnerEnvTests { + private final class CallbackCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + + func value() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.count + } + } + @Test func `shutdown fence drains tracked TTY processes`() { TTYCommandRunner._test_resetTrackedProcesses() @@ -159,6 +176,95 @@ struct TTYCommandRunnerEnvTests { #expect(result.text.contains("accepted")) } + @Test + func `post-exit drain processes trailing chunk through callback path`() { + let callbackCounter = CallbackCounter() + var reads: [TTYCommandRunner.DrainReadResult] = [ + .wouldBlock, + .wouldBlock, + .data(Data("https://example.com/auth".utf8)), + .closed, + ] + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + if reads.isEmpty { return .closed } + return reads.removeFirst() + }, + processChunk: { data in + if data.range(of: Data("https://".utf8)) != nil { + callbackCounter.increment() + } + }, + sleep: { _ in }) + + #expect(callbackCounter.value() == 1) + } + + @Test + func `post-exit drain keeps harvesting after late success marker`() { + var readCount = 0 + var processedChunks: [String] = [] + var reads: [TTYCommandRunner.DrainReadResult] = [ + .data(Data("accepted".utf8)), + .wouldBlock, + .data(Data(" trailing".utf8)), + .closed, + ] + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + readCount += 1 + if reads.isEmpty { return .closed } + return reads.removeFirst() + }, + processChunk: { data in + processedChunks.append(String(bytes: data, encoding: .utf8) ?? "") + }, + sleep: { _ in }) + + #expect(readCount == 4) + #expect(processedChunks == ["accepted", " trailing"]) + } + + @Test + func `post-exit drain stops once the PTY reports closure`() { + var readCount = 0 + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + readCount += 1 + return .closed + }, + processChunk: { _ in }, + sleep: { _ in }) + + #expect(readCount == 1) + } + + @Test + func `interrupted drain reads are treated as retryable`() { + let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: -1, errno: EINTR) + if case .wouldBlock = result { + #expect(Bool(true)) + } else { + Issue.record("Expected interrupted read to remain retryable during drain") + } + } + + @Test + func `EOF beats stale would-block errno during drain classification`() { + let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: 0, errno: EAGAIN) + if case .closed = result { + #expect(Bool(true)) + } else { + Issue.record("Expected EOF reads to stop draining even if errno still holds EAGAIN") + } + } + @Test func `stops when output is idle`() throws { let fm = FileManager.default From a04986b8b4a683408096085ec7161d75a1af5614 Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Wed, 8 Apr 2026 17:40:34 +0530 Subject: [PATCH 0148/1239] Fix Cursor fetch crash path (#663) * Fix Cursor fetch crash path * Fix Linux Cursor probe build --- .../Providers/Cursor/CursorStatusProbe.swift | 56 +++++- .../CursorStatusProbeTests.swift | 185 ++++++++++++++++++ 2 files changed, 232 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index ffb362b422..b94a351159 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import SweetCookieKit #if os(macOS) @@ -610,15 +613,18 @@ public struct CursorStatusProbe: Sendable { public let baseURL: URL public var timeout: TimeInterval = 15.0 private let browserDetection: BrowserDetection + private let urlSession: URLSession public init( baseURL: URL = URL(string: "https://cursor.com")!, timeout: TimeInterval = 15.0, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + urlSession: URLSession = .shared) { self.baseURL = baseURL self.timeout = timeout self.browserDetection = browserDetection + self.urlSession = urlSession } /// Fetch Cursor usage with manual cookie header (for debugging). @@ -807,11 +813,41 @@ public struct CursorStatusProbe: Sendable { } private func fetchWithCookieHeader(_ cookieHeader: String) async throws -> CursorStatusSnapshot { - async let usageSummaryTask = self.fetchUsageSummary(cookieHeader: cookieHeader) - async let userInfoTask = self.fetchUserInfo(cookieHeader: cookieHeader) + enum FetchPart: Sendable { + case usageSummary((CursorUsageSummary, String)) + case userInfo(Result) + } + + var usageSummaryResult: (CursorUsageSummary, String)? + var userInfo: CursorUserInfo? - let (usageSummary, rawJSON) = try await usageSummaryTask - let userInfo = try? await userInfoTask + try await withThrowingTaskGroup(of: FetchPart.self) { group in + group.addTask { + try await .usageSummary(self.fetchUsageSummary(cookieHeader: cookieHeader)) + } + group.addTask { + do { + return try await .userInfo(.success(self.fetchUserInfo(cookieHeader: cookieHeader))) + } catch { + return .userInfo(.failure(error)) + } + } + + while let result = try await group.next() { + switch result { + case let .usageSummary(value): + usageSummaryResult = value + case let .userInfo(value): + userInfo = try? value.get() + } + } + } + + guard let usageSummaryResult else { + throw CursorStatusProbeError.networkError("Cursor usage summary fetch did not complete") + } + + let (usageSummary, rawJSON) = usageSummaryResult // Fetch legacy request usage only if user has a sub ID. // Uses try? to avoid breaking the flow for users where this endpoint fails or returns unexpected data. @@ -847,7 +883,7 @@ public struct CursorStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await self.urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw CursorStatusProbeError.networkError("Invalid response") @@ -880,7 +916,7 @@ public struct CursorStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await self.urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw CursorStatusProbeError.networkError("Failed to fetch user info") @@ -901,7 +937,7 @@ public struct CursorStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await self.urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw CursorStatusProbeError.networkError("Failed to fetch request usage") @@ -1025,11 +1061,13 @@ public struct CursorStatusProbe: Sendable { public init( baseURL: URL = URL(string: "https://cursor.com")!, timeout: TimeInterval = 15.0, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + urlSession: URLSession = .shared) { _ = baseURL _ = timeout _ = browserDetection + _ = urlSession } public func fetch(logger: ((String) -> Void)? = nil) async throws -> CursorStatusSnapshot { diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index aaaac47e04..2b02bbcbb6 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct CursorStatusProbeTests { // MARK: - Usage Summary Parsing @@ -872,3 +873,187 @@ struct CursorStatusProbeTests { return CursorCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: sourceLabel) } } + +private func makeCursorStatusProbeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [CursorStatusProbeStubURLProtocol.self] + return URLSession(configuration: config) +} + +private func makeCursorStatusProbeResponse( + url: URL, + body: String, + statusCode: Int, + contentType: String = "application/json") -> (HTTPURLResponse, Data) +{ + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": contentType])! + return (response, Data(body.utf8)) +} + +extension CursorStatusProbeTests { + @Test + func `fetch ignores user info failure when usage summary succeeds`() async throws { + defer { + CursorStatusProbeStubURLProtocol.reset() + } + CursorStatusProbeStubURLProtocol.reset() + + CursorStatusProbeStubURLProtocol.setHandler { request in + let requestURL = try #require(request.url) + + switch requestURL.path { + case "/api/usage-summary": + return makeCursorStatusProbeResponse( + url: requestURL, + body: """ + { + "membershipType": "pro", + "individualUsage": { + "plan": { + "used": 1500, + "limit": 5000, + "totalPercentUsed": 30.0 + } + } + } + """, + statusCode: 200) + case "/api/auth/me": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"error":"nope"}"#, + statusCode: 500) + default: + throw URLError(.badURL) + } + } + + let baseURL = try #require(URL(string: "https://cursor.test")) + let snapshot = try await CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + urlSession: makeCursorStatusProbeSession()).fetchWithManualCookies("auth=test") + + #expect(snapshot.planPercentUsed == 30.0) + #expect(snapshot.accountEmail == nil) + #expect(CursorStatusProbeStubURLProtocol.requestCount == 2) + } + + @Test + func `fetch fails cleanly when usage summary fails`() async { + defer { + CursorStatusProbeStubURLProtocol.reset() + } + CursorStatusProbeStubURLProtocol.reset() + + CursorStatusProbeStubURLProtocol.setHandler { request in + let requestURL = try #require(request.url) + + switch requestURL.path { + case "/api/usage-summary": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"error":"denied"}"#, + statusCode: 500) + case "/api/auth/me": + return makeCursorStatusProbeResponse( + url: requestURL, + body: """ + { + "email": "user@example.com", + "email_verified": true, + "name": "Test User", + "sub": "auth0|12345" + } + """, + statusCode: 200) + default: + throw URLError(.badURL) + } + } + + do { + let baseURL = try #require(URL(string: "https://cursor.test")) + _ = try await CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + urlSession: makeCursorStatusProbeSession()).fetchWithManualCookies("auth=test") + Issue.record("Expected usage summary failure to be surfaced") + } catch let error as CursorStatusProbeError { + guard case let .networkError(message) = error else { + Issue.record("Expected networkError, got: \(error)") + return + } + #expect(message == "HTTP 500") + #expect(CursorStatusProbeStubURLProtocol.requestPaths.contains("/api/usage-summary")) + } catch { + Issue.record("Expected CursorStatusProbeError, got: \(error)") + } + } +} + +final class CursorStatusProbeStubURLProtocol: URLProtocol { + private struct State { + var requests: [URLRequest] = [] + var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + } + + private static let lock = NSLock() + private nonisolated(unsafe) static var state = State() + + static func setHandler(_ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) { + self.lock.lock() + self.state.handler = handler + self.lock.unlock() + } + + static func reset() { + self.lock.lock() + self.state = State() + self.lock.unlock() + } + + static var requestCount: Int { + lock.lock() + defer { Self.lock.unlock() } + return state.requests.count + } + + static var requestPaths: [String] { + lock.lock() + defer { Self.lock.unlock() } + return state.requests.compactMap { $0.url?.path } + } + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + Self.lock.lock() + Self.state.requests.append(self.request) + handler = Self.state.handler + Self.lock.unlock() + + do { + let handler = try #require(handler) + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} From 140acd1d701d219adfcedf5e04acb01fb3909caf Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 8 Apr 2026 18:10:08 +0530 Subject: [PATCH 0149/1239] Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57dae9ff39..2ba6a10703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ ## 0.21 — Unreleased ### Highlights +- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. +- Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! ### Providers & Usage +- z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. +- Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). ### Menu & Settings +- z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662). ## 0.20 — 2026-04-07 From 20784e00eaed9d9d5b18c36cd39b24b712019d99 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 9 Apr 2026 13:56:17 +0530 Subject: [PATCH 0150/1239] Fix OpenCode workspace auth fallback handling --- CHANGELOG.md | 1 + .../OpenCode/OpenCodeUsageFetcher.swift | 7 +++- .../OpenCodeGo/OpenCodeGoUsageFetcher.swift | 6 ++- .../OpenCodeGoUsageFetcherErrorTests.swift | 41 +++++++++++++++++++ .../OpenCodeUsageFetcherErrorTests.swift | 41 +++++++++++++++++++ 5 files changed, 94 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba6a10703..b854574720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Providers & Usage - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). +- OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. ### Menu & Settings - z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662). diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index 7ddf31466a..0946a4c4f7 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -441,7 +441,12 @@ public struct OpenCodeUsageFetcher: Sendable { private static func looksSignedOut(text: String) -> Bool { let lower = text.lowercased() - if lower.contains("login") || lower.contains("sign in") || lower.contains("auth/authorize") { + if lower.contains("login") || + lower.contains("sign in") || + lower.contains("auth/authorize") || + lower.contains("not associated with an account") || + lower.contains("actor of type \"public\"") + { return true } return false diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index 4183eb8538..fbee8eec82 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -676,7 +676,11 @@ public struct OpenCodeGoUsageFetcher: Sendable { private static func looksSignedOut(text: String) -> Bool { let lower = text.lowercased() - return lower.contains("login") || lower.contains("sign in") || lower.contains("auth/authorize") + return lower.contains("login") || + lower.contains("sign in") || + lower.contains("auth/authorize") || + lower.contains("not associated with an account") || + lower.contains("actor of type \"public\"") } private static func extractServerErrorMessage(from text: String) -> String? { diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift index 2fe935cbdd..73876e68f0 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -100,6 +100,47 @@ struct OpenCodeGoUsageFetcherErrorTests { #expect(methods == ["GET", "POST", "GET"]) } + @Test + func `workspace get public actor error is treated as invalid credentials without post retry`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + let body = [ + #";0x00000263;((self.$R=self.$R||{})["server-fn:test"]=[],"#, + #"($R=>$R[0]=Object.assign(new Error("actor of type \"public\" is not associated with an account"),"#, + #"{stack:"Error: actor of type \"public\" is not associated with an account"}))"#, + #"($R["server-fn:test"]))"#, + ].joined() + return Self.makeResponse( + url: url, + body: body, + statusCode: 200, + contentType: "text/javascript") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()) + Issue.record("Expected OpenCodeGoUsageError.invalidCredentials") + } catch let error as OpenCodeGoUsageError { + switch error { + case .invalidCredentials: + break + default: + Issue.record("Expected invalidCredentials, got: \(error)") + } + } + + #expect(methods == ["GET"]) + } + @Test func `go page missing usage fields returns parse failed without post retry`() async throws { defer { diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift index 073b72f98a..c934d35205 100644 --- a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift @@ -150,6 +150,47 @@ struct OpenCodeUsageFetcherErrorTests { #expect(methods == ["GET"]) } + @Test + func `workspace get public actor error is treated as invalid credentials without post retry`() async throws { + defer { + OpenCodeStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + let body = [ + #";0x00000263;((self.$R=self.$R||{})["server-fn:test"]=[],"#, + #"($R=>$R[0]=Object.assign(new Error("actor of type \"public\" is not associated with an account"),"#, + #"{stack:"Error: actor of type \"public\" is not associated with an account"}))"#, + #"($R["server-fn:test"]))"#, + ].joined() + return Self.makeResponse( + url: url, + body: body, + statusCode: 200, + contentType: "text/javascript") + } + + do { + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()) + Issue.record("Expected OpenCodeUsageError.invalidCredentials") + } catch let error as OpenCodeUsageError { + switch error { + case .invalidCredentials: + break + default: + Issue.record("Expected invalidCredentials, got: \(error)") + } + } + + #expect(methods == ["GET"]) + } + @Test func `subscription get missing fields falls back to post`() async throws { defer { From 6665a27ab6cda262b339e8d39d7abe8c51709419 Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Thu, 9 Apr 2026 15:02:14 +0530 Subject: [PATCH 0151/1239] Fix Claude CLI well-known path fallback precedence (#675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: detect Claude CLI at well-known install paths (cmux.app, ~/.claude/bin) When CodexBar is launched from Finder (not a terminal), it may not inherit the user's shell PATH. The existing resolution chain tries login shell capture, environment PATH, interactive shell lookup, and alias resolution—but all of these can fail when: 1. The Claude CLI is installed via the macOS Terminal installer to /Applications/cmux.app/Contents/Resources/bin/claude 2. The PATH entry was added to the shell profile but the login shell capture times out or doesn't pick it up This adds a well-known-paths step to BinaryLocator.resolveBinary() that checks common Claude CLI installation locations as a fallback before the minimal system-path lookup: - /Applications/cmux.app/Contents/Resources/bin/claude (Terminal installer) - ~/.claude/bin/claude (manual/script install) - /usr/local/bin/claude (Homebrew Intel) - /opt/homebrew/bin/claude (Homebrew Apple Silicon) The well-known paths are only checked after all dynamic resolution methods fail, so they never override a user's preferred installation. Includes tests verifying: - cmux.app path is found when shell lookups fail - ~/.claude/bin path is found when shell lookups fail - Shell PATH is still preferred over well-known paths Fixes #599 * Preserve Claude CLI fallback precedence * Update CHANGELOG.md --------- Co-authored-by: Claw Explorer --- CHANGELOG.md | 2 + Sources/CodexBarCore/PathEnvironment.swift | 22 +++++- Tests/CodexBarTests/PathBuilderTests.swift | 87 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b854574720..6892abb42b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ ### Highlights - z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. - Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! +- Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps still prefer user-managed and native Homebrew binaries when multiple installs exist. ### Providers & Usage - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). +- Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps prefer `~/.claude/bin/claude`, then Homebrew, before the bundled `cmux.app` binary when shell-based resolution is unavailable. - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. ### Menu & Settings diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index ece93eda2b..e845f1a862 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -52,10 +52,22 @@ public enum BinaryLocator { loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, + wellKnownPaths: self.claudeWellKnownPaths(home: home), fileManager: fileManager, home: home) } + /// Well-known installation paths for the Claude CLI binary. + /// Covers the macOS Terminal installer (cmux.app), ~/.claude/bin, and Homebrew. + static func claudeWellKnownPaths(home: String) -> [String] { + [ + "\(home)/.claude/bin/claude", + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + "/Applications/cmux.app/Contents/Resources/bin/claude", + ] + } + public static func resolveCodexBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, @@ -124,6 +136,7 @@ public enum BinaryLocator { loginPATH: [String]?, commandV: (String, String?, TimeInterval, FileManager) -> String?, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String?, + wellKnownPaths: [String] = [], fileManager: FileManager, home: String) -> String? { @@ -164,7 +177,14 @@ public enum BinaryLocator { return aliasHit } - // 5) Minimal fallback + // 5) Well-known installation paths (e.g. cmux.app bundle, ~/.claude/bin) + // macOS apps launched from Finder may not inherit the user's shell PATH, + // so check common install locations that the shell-based lookups above may miss. + for candidate in wellKnownPaths where fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + + // 6) Minimal fallback let fallback = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] if let pathHit = self.find(name, in: fallback, fileManager: fileManager) { return pathHit diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index d40bc2345e..d39baa0aba 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -210,6 +210,93 @@ struct PathBuilderTests { #expect(resolved == aliasPath) } + @Test + func `resolves claude from well-known cmux path when shell lookups fail`() { + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [cmuxPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == cmuxPath) + } + + @Test + func `resolves claude from well-known home dir path`() { + let homePath = "/Users/test/.claude/bin/claude" + let fm = MockFileManager(executables: [homePath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == homePath) + } + + @Test + func `prefers user managed well-known path over cmux path`() { + let homePath = "/Users/test/.claude/bin/claude" + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [homePath, cmuxPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == homePath) + } + + @Test + func `prefers homebrew arm path over usr local fallback`() { + let fm = MockFileManager(executables: [ + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + ]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == "/opt/homebrew/bin/claude") + } + + @Test + func `prefers shell PATH over well-known paths`() { + let shellPath = "/custom/bin/claude" + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [shellPath, cmuxPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in shellPath } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + fileManager: fm, + home: "/Users/test") + #expect(resolved == shellPath) + } + @Test func `skips alias when command V resolves`() { let path = "/shell/bin/claude" From 2d070c2af65d8f146e07d10e713a642d5161a352 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 9 Apr 2026 18:03:50 +0530 Subject: [PATCH 0152/1239] Improve OpenAI dashboard session retry timing --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 19 ++++- ...OpenAIDashboardBrowserCookieImporter.swift | 56 +++++++++++++- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 75 ++++++++++++++++++- .../OpenAIDashboardNavigationDelegate.swift | 15 ++++ .../CodexManagedOpenAIWebRefreshTests.swift | 6 ++ ...IDashboardBrowserCookieImporterTests.swift | 15 +++- ...enAIDashboardFetcherCreditsWaitTests.swift | 71 ++++++++++++++++++ ...enAIDashboardNavigationDelegateTests.swift | 42 +++++++++++ 8 files changed, 287 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 6e4541701c..65fb3ce975 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -15,6 +15,15 @@ extension UsageStore { private static let openAIWebRefreshMultiplier: TimeInterval = 5 private static let openAIWebPrimaryFetchTimeout: TimeInterval = 15 private static let openAIWebRetryFetchTimeout: TimeInterval = 8 + private static let openAIWebPostImportFetchTimeout: TimeInterval = 25 + + static func openAIWebDashboardFetchTimeout(didImportCookies: Bool) -> TimeInterval { + didImportCookies ? self.openAIWebPostImportFetchTimeout : self.openAIWebPrimaryFetchTimeout + } + + static func openAIWebRetryDashboardFetchTimeout(afterCookieImport: Bool) -> TimeInterval { + afterCookieImport ? self.openAIWebPostImportFetchTimeout : self.openAIWebRetryFetchTimeout + } private func openAIWebRefreshIntervalSeconds() -> TimeInterval { let base = max(self.settings.refreshFrequency.seconds ?? 0, 120) @@ -387,12 +396,14 @@ extension UsageStore { // Strategy: // - Try the existing per-email WebKit cookie store first (fast; avoids Keychain prompts). // - On login-required or account mismatch, import cookies from the configured browser order and retry once. + var didImportCookiesForRefresh = false if self.openAIWebAccountDidChange, let targetEmail = context.targetEmail, !targetEmail.isEmpty { // On account switches, proactively re-import cookies so we don't show stale data from the previous // user. let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: targetEmail, force: true) + didImportCookiesForRefresh = true latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( importedEmail: imported, @@ -413,7 +424,7 @@ extension UsageStore { var dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: log, - timeout: Self.openAIWebPrimaryFetchTimeout) + timeout: Self.openAIWebDashboardFetchTimeout(didImportCookies: didImportCookiesForRefresh)) if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) { if let imported = await self.importOpenAIDashboardCookiesIfNeeded( @@ -426,7 +437,7 @@ extension UsageStore { dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: log, - timeout: Self.openAIWebRetryFetchTimeout) + timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) } await self.applyOpenAIDashboard( @@ -487,7 +498,7 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, - timeout: Self.openAIWebRetryFetchTimeout) + timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) await self.applyOpenAIDashboard( dash, targetEmail: effectiveEmail, @@ -546,7 +557,7 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, - timeout: Self.openAIWebRetryFetchTimeout) + timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) await self.applyOpenAIDashboard( dash, targetEmail: effectiveEmail, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 1a320f5717..2b18a5b8a4 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -71,6 +71,11 @@ public struct OpenAIDashboardBrowserCookieImporter { private let browserDetection: BrowserDetection + nonisolated static func shouldTrustVerifiedSession(afterPersistFailure error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut + } + private struct ImportDiagnostics { var mismatches: [FoundAccount] = [] var foundAnyCookies: Bool = false @@ -213,9 +218,17 @@ public struct OpenAIDashboardBrowserCookieImporter { log: log) { case let .match(_, signedInEmail): - return try await self.persist(candidate: candidate, targetEmail: signedInEmail, logger: log) + return try await self.persistVerifiedCandidate( + candidate: candidate, + targetEmail: signedInEmail, + verifiedSignedInEmail: signedInEmail, + logger: log) case let .loggedIn(_, signedInEmail): - return try await self.persist(candidate: candidate, targetEmail: signedInEmail, logger: log) + return try await self.persistVerifiedCandidate( + candidate: candidate, + targetEmail: signedInEmail, + verifiedSignedInEmail: signedInEmail, + logger: log) case let .mismatch(_, signedInEmail): throw ImportError.noMatchingAccount(found: [FoundAccount(sourceLabel: "Manual", email: signedInEmail)]) case .unknown: @@ -405,7 +418,12 @@ public struct OpenAIDashboardBrowserCookieImporter { case let .match(candidate, signedInEmail): log("Selected \(candidate.label) (matches Codex: \(signedInEmail))") guard let targetEmail = context.targetEmail else { return nil } - if let result = try? await self.persist(candidate: candidate, targetEmail: targetEmail, logger: log) { + if let result = try? await self.persistVerifiedCandidate( + candidate: candidate, + targetEmail: targetEmail, + verifiedSignedInEmail: signedInEmail, + logger: log) + { self.cacheCookies(candidate: candidate, scope: context.cacheScope) return result } @@ -419,7 +437,12 @@ public struct OpenAIDashboardBrowserCookieImporter { return nil case let .loggedIn(candidate, signedInEmail): log("Selected \(candidate.label) (signed in: \(signedInEmail))") - if let result = try? await self.persist(candidate: candidate, targetEmail: signedInEmail, logger: log) { + if let result = try? await self.persistVerifiedCandidate( + candidate: candidate, + targetEmail: signedInEmail, + verifiedSignedInEmail: signedInEmail, + logger: log) + { self.cacheCookies(candidate: candidate, scope: context.cacheScope) return result } @@ -588,6 +611,31 @@ public struct OpenAIDashboardBrowserCookieImporter { return nil } + private func persistVerifiedCandidate( + candidate: Candidate, + targetEmail: String, + verifiedSignedInEmail: String, + logger: @escaping (String) -> Void) async throws -> ImportResult + { + do { + return try await self.persist(candidate: candidate, targetEmail: targetEmail, logger: logger) + } catch { + guard Self.shouldTrustVerifiedSession(afterPersistFailure: error) else { + throw error + } + + let signedInEmail = verifiedSignedInEmail.trimmingCharacters(in: .whitespacesAndNewlines) + logger( + "Persistent validation timed out after session verification; " + + "keeping \(candidate.label) cookies for \(signedInEmail).") + return ImportResult( + sourceLabel: candidate.label, + cookieCount: candidate.cookies.count, + signedInEmail: signedInEmail, + matchesCodexEmail: signedInEmail.lowercased() == targetEmail.lowercased()) + } + } + private func persist( candidate: Candidate, targetEmail: String, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index f9dfe030d7..febf398299 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -130,7 +130,7 @@ public struct OpenAIDashboardFetcher { } // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. - if let href = scrape.href, !href.contains("/codex/settings/usage") { + if let href = scrape.href, !Self.isUsageRoute(href) { _ = webView.load(URLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue @@ -274,6 +274,35 @@ public struct OpenAIDashboardFetcher { return false } + struct ProbeReadinessContext { + let now: Date + let usageRouteSeenAt: Date? + let dashboardSignalSeenAt: Date? + let signedInEmail: String? + let hasDashboardSignal: Bool + } + + nonisolated static func shouldWaitForProbeReadiness(_ context: ProbeReadinessContext) -> Bool { + if let signedInEmail = context.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines), + !signedInEmail.isEmpty + { + return false + } + + if context.hasDashboardSignal { + if let dashboardSignalSeenAt = context.dashboardSignalSeenAt { + return context.now.timeIntervalSince(dashboardSignalSeenAt) < 2.0 + } + return true + } + + if let usageRouteSeenAt = context.usageRouteSeenAt { + return context.now.timeIntervalSince(usageRouteSeenAt) < 2.0 + } + + return false + } + public func clearSessionData(accountEmail: String?) async { let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) @@ -296,6 +325,8 @@ public struct OpenAIDashboardFetcher { var lastBody: String? var lastHref: String? + var usageRouteSeenAt: Date? + var dashboardSignalSeenAt: Date? while Date() < deadline { let scrape = try await self.scrape(webView: webView) @@ -307,7 +338,9 @@ public struct OpenAIDashboardFetcher { continue } - if let href = scrape.href, !href.contains("/codex/settings/usage") { + if let href = scrape.href, !Self.isUsageRoute(href) { + usageRouteSeenAt = nil + dashboardSignalSeenAt = nil _ = webView.load(URLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue @@ -318,12 +351,41 @@ public struct OpenAIDashboardFetcher { throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") } + let normalizedEmail = scrape.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let bodyText = scrape.bodyText ?? "" + let rateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) + let hasDashboardSignal = normalizedEmail?.isEmpty == false || + !scrape.rows.isEmpty || + !scrape.usageBreakdown.isEmpty || + scrape.creditsHeaderPresent || + OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText) != nil || + OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) != nil || + rateLimits.primary != nil || + rateLimits.secondary != nil + + if usageRouteSeenAt == nil { + usageRouteSeenAt = Date() + } + if hasDashboardSignal, dashboardSignalSeenAt == nil { + dashboardSignalSeenAt = Date() + } + if Self.shouldWaitForProbeReadiness(.init( + now: Date(), + usageRouteSeenAt: usageRouteSeenAt, + dashboardSignalSeenAt: dashboardSignalSeenAt, + signedInEmail: normalizedEmail, + hasDashboardSignal: hasDashboardSignal)) + { + try? await Task.sleep(for: .milliseconds(400)) + continue + } + return ProbeResult( href: scrape.href, loginRequired: scrape.loginRequired, workspacePicker: scrape.workspacePicker, cloudflareInterstitial: scrape.cloudflareInterstitial, - signedInEmail: scrape.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines), + signedInEmail: normalizedEmail, bodyText: scrape.bodyText) } @@ -460,6 +522,13 @@ public struct OpenAIDashboardFetcher { max(0, deadline.timeIntervalSince(now)) } + nonisolated static func isUsageRoute(_ href: String?) -> Bool { + guard let href, !href.isEmpty else { return false } + let path = (URL(string: href)?.path ?? href) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return path.hasSuffix("codex/settings/usage") || path.hasSuffix("codex/cloud/settings/usage") + } + private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) { let stamp = Int(Date().timeIntervalSince1970) let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift index 50581caa6d..06ef8f5e6e 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift @@ -9,7 +9,9 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { private let completion: (Result) -> Void private var hasCompleted: Bool = false private var timeoutTask: Task? + private var postCommitTask: Task? static var associationKey: UInt8 = 0 + nonisolated static let postCommitSuccessDelay: TimeInterval = 0.75 init(completion: @escaping (Result) -> Void) { self.completion = completion @@ -29,6 +31,17 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { self.completeOnce(.success(())) } + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + guard !self.hasCompleted else { return } + self.postCommitTask?.cancel() + self.postCommitTask = Task { @MainActor [weak self] in + guard let self else { return } + let nanoseconds = UInt64(Self.postCommitSuccessDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + self.completeOnce(.success(())) + } + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { if Self.shouldIgnoreNavigationError(error) { return } self.completeOnce(.failure(error)) @@ -57,6 +70,8 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { self.hasCompleted = true self.timeoutTask?.cancel() self.timeoutTask = nil + self.postCommitTask?.cancel() + self.postCommitTask = nil self.completion(result) } } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 0703aec3f2..7bc9bf6b24 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -222,6 +222,12 @@ struct CodexManagedOpenAIWebRefreshTests { #expect(store.lastOpenAIDashboardError == ManagedDashboardTestError.networkTimeout.localizedDescription) } + @Test + func `post import retry timeout exceeds normal retry timeout`() { + #expect(UsageStore.openAIWebRetryDashboardFetchTimeout(afterCookieImport: false) == 8) + #expect(UsageStore.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true) == 25) + } + private func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift index a23c8deb70..357d49f91d 100644 --- a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift @@ -1,5 +1,6 @@ -import CodexBarCore +import Foundation import Testing +@testable import CodexBarCore struct OpenAIDashboardBrowserCookieImporterTests { @Test @@ -13,4 +14,16 @@ struct OpenAIDashboardBrowserCookieImporterTests { #expect(msg.contains("Safari=a@example.com")) #expect(msg.contains("Chrome=b@example.com")) } + + @Test + func `timed out persistent validation keeps verified session`() { + #expect(OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( + afterPersistFailure: URLError(.timedOut))) + } + + @Test + func `non-timeout persistent validation failures are not trusted`() { + #expect(!OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( + afterPersistFailure: OpenAIDashboardBrowserCookieImporter.ImportError.dashboardStillRequiresLogin)) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index 6ee2c4bfb7..85713405ae 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -72,6 +72,54 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(shouldWait == false) } + @Test + func `probe waits briefly after reaching usage route without email or dashboard signals`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-1.0), + dashboardSignalSeenAt: nil, + signedInEmail: nil, + hasDashboardSignal: false)) + #expect(shouldWait == true) + } + + @Test + func `probe waits briefly for email after dashboard signals appear`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-3.0), + dashboardSignalSeenAt: now.addingTimeInterval(-1.0), + signedInEmail: nil, + hasDashboardSignal: true)) + #expect(shouldWait == true) + } + + @Test + func `probe stops waiting once signed in email is available`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-0.2), + dashboardSignalSeenAt: now.addingTimeInterval(-0.2), + signedInEmail: "user@example.com", + hasDashboardSignal: true)) + #expect(shouldWait == false) + } + + @Test + func `probe grace restarts after route reload resets readiness timestamps`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now, + dashboardSignalSeenAt: nil, + signedInEmail: nil, + hasDashboardSignal: false)) + #expect(shouldWait == true) + } + @Test func `sanitized timeout preserves positive caller deadline`() { #expect(OpenAIDashboardFetcher.sanitizedTimeout(60) == 60) @@ -108,4 +156,27 @@ struct OpenAIDashboardFetcherCreditsWaitTests { now: deadline.addingTimeInterval(3)) #expect(remaining == 0) } + + @Test + func `usage route matcher accepts legacy settings route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage")) + } + + @Test + func `usage route matcher accepts cloud settings route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage")) + } + + @Test + func `usage route matcher accepts trailing slash variants`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage/")) + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage/")) + } + + @Test + func `usage route matcher rejects unrelated routes`() { + #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/")) + #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex")) + #expect(!OpenAIDashboardFetcher.isUsageRoute(nil)) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 64b1991bb7..22e1938f53 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -41,6 +41,48 @@ struct OpenAIDashboardNavigationDelegateTests { } } + @MainActor + @Test + func `commit completes navigation successfully after grace period`() async { + let webView = WKWebView() + var result: Result? + let delegate = NavigationDelegate { result = $0 } + + delegate.webView(webView, didCommit: nil) + #expect(result == nil) + + try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + + switch result { + case .success?: + #expect(Bool(true)) + default: + #expect(Bool(false)) + } + } + + @MainActor + @Test + func `post commit failure wins before delayed success`() async { + let webView = WKWebView() + var result: Result? + let delegate = NavigationDelegate { result = $0 } + + delegate.webView(webView, didCommit: nil) + let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + delegate.webView(webView, didFail: nil, withError: timeout) + + try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + + switch result { + case let .failure(error as NSError)?: + #expect(error.domain == NSURLErrorDomain) + #expect(error.code == NSURLErrorTimedOut) + default: + #expect(Bool(false)) + } + } + @MainActor @Test func `cancelled provisional failure is ignored until real failure`() { From 21d2eedd77cb3cb64a851127f6dfce7bf3b21011 Mon Sep 17 00:00:00 2001 From: Andrzej Chmielewski Date: Thu, 9 Apr 2026 17:32:22 +0200 Subject: [PATCH 0153/1239] Fix menu bar icon not appearing due to RenderBox Metal shader crash On some macOS 26 (Tahoe) machines, the RenderBox framework fails to compile Metal shaders for certain SwiftUI visual effects, triggering a non-fatal precondition failure in RB::FunctionLibrary::FunctionLibrary(). This corrupts the rendering state and prevents the NSStatusItem from appearing in the menu bar. Two changes fix the issue: 1. Replace .regularMaterial with a solid color background in the provider sidebar. SwiftUI Materials require RenderBox to compile blur/vibrancy Metal shaders which can fail on certain GPU configurations. 2. Remove .drawingGroup() from the highlighted progress bar path. This modifier forces Metal-backed offscreen rendering which triggers the same shader compilation failure. The RenderBox crash manifests as: RB::FunctionLibrary::FunctionLibrary(RB::Device&, RB::CustomShader::Library const&) -> RB::CustomShader::Function::function(...) -> RB::CustomEffect::render(...) Fixes #668 --- Sources/CodexBar/PreferencesProviderSidebarView.swift | 2 +- Sources/CodexBar/UsageProgressBar.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index ee34cb3e7c..32a1b5b471 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -35,7 +35,7 @@ struct ProviderSidebarListView: View { .scrollContentBackground(.hidden) .background( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) - .fill(.regularMaterial)) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.8))) .overlay( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1)) diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index 28b467b867..d2bc2d8446 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -61,7 +61,6 @@ struct UsageProgressBar: View { if self.isHighlighted { bar .compositingGroup() - .drawingGroup() } else if needsPunchCompositing { bar .compositingGroup() From 97cceac3733fa80bec02d7c2565095fe3e6a75f3 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 9 Apr 2026 22:27:40 +0530 Subject: [PATCH 0154/1239] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6892abb42b..279d0ec180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Highlights - z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. - Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! +- Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps still prefer user-managed and native Homebrew binaries when multiple installs exist. ### Providers & Usage @@ -14,6 +15,7 @@ - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. ### Menu & Settings +- Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm! - z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662). ## 0.20 — 2026-04-07 From 0199e1ca9a00c3eba5f32552c34611228806d1c1 Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Fri, 10 Apr 2026 22:13:46 +0530 Subject: [PATCH 0155/1239] Add an option to reduce battery drain from OpenAI web extras (#684) * Reduce battery drain from OpenAI web extras * Make OpenAI web refresh discoverable * Separate OpenAI web extras from battery saver * Respect battery saver on stale dashboard refresh * Restore scoped Codex pane refresh * Fix Codex OpenAI web refresh defaults * Preserve legacy Codex web access inference * Skip WebKit cache tests on CI * Fix Codex dashboard refresh test defaults * Fix managed OpenAI web test defaults --------- Co-authored-by: cbrane --- Sources/CodexBar/MenuDescriptor.swift | 1 + .../CodexBar/PreferencesProvidersPane.swift | 22 +- .../Codex/CodexProviderImplementation.swift | 21 +- Sources/CodexBar/SettingsStore+Defaults.swift | 11 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 28 ++- Sources/CodexBar/SettingsStoreState.swift | 1 + .../UsageStore+BackgroundRefresh.swift | 24 +++ Sources/CodexBar/UsageStore+Logging.swift | 1 + Sources/CodexBar/UsageStore+OpenAIWeb.swift | 103 ++++++++-- Sources/CodexBar/UsageStore.swift | 40 +++- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 4 + .../OpenAIDashboardWebViewCache.swift | 40 +++- ...AuthDelegatedRefreshCoordinatorTests.swift | 192 +++++++++--------- .../CodexAccountScopedRefreshTests.swift | 2 + .../CodexManagedOpenAIWebRefreshTests.swift | 5 +- .../CodexManagedOpenAIWebTestSupport.swift | 2 + .../OpenAIDashboardWebViewCacheTests.swift | 45 +++- .../OpenAIWebRefreshGateTests.swift | 113 +++++++++++ .../ProviderSettingsDescriptorTests.swift | 51 +++++ Tests/CodexBarTests/SettingsStoreTests.swift | 103 +++++++++- Tests/CodexBarTests/StatusMenuTests.swift | 57 +++++- .../UsageStoreCoverageTests.swift | 72 ++++++- docs/codex.md | 7 +- docs/providers.md | 3 +- ...eb-extras-default-off-codexbar-20260307.md | 67 ++++++ 26 files changed, 871 insertions(+), 145 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+BackgroundRefresh.swift create mode 100644 Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift create mode 100644 docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 75cf7e29d4..68c81a10ad 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -400,6 +400,7 @@ struct MenuDescriptor { entries.append(.action("Update ready, restart now?", .installUpdate)) } entries.append(contentsOf: [ + .action("Refresh", .refresh), .action("Settings...", .settings), .action("About CodexBar", .about), .action("Quit", .quit), diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index c95f404310..9e8a636405 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -66,15 +66,7 @@ struct ProvidersPane: View { isErrorExpanded: self.expandedBinding(for: provider), onCopyError: { text in self.copyToPasteboard(text) }, onRefresh: { - Task { @MainActor in - await ProviderInteractionContext.$current.withValue(.userInitiated) { - if provider == .codex { - await self.store.refreshCodexAccountScopedState(allowDisabled: true) - } else { - await self.store.refreshProvider(provider, allowDisabled: true) - } - } - } + self.triggerRefresh(for: provider) }, showsSupplementarySettingsContent: self.codexAccountsSectionState(for: provider) != nil, supplementarySettingsContent: { @@ -156,6 +148,18 @@ struct ProvidersPane: View { self.selectedProvider = self.providers.first } + private func triggerRefresh(for provider: UsageProvider) { + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + if provider == .codex { + await self.store.refreshCodexAccountScopedState(allowDisabled: true) + } else { + await self.store.refreshProvider(provider, allowDisabled: true) + } + } + } + } + func binding(for provider: UsageProvider) -> Binding { let meta = self.store.metadata(for: provider) return Binding( diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 2f306aa55b..ad1a0f2fcc 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -69,6 +69,7 @@ struct CodexProviderImplementation: ProviderImplementation { for: .codex) } }) + let batterySaverBinding = context.boolBinding(\.openAIWebBatterySaverEnabled) return [ ProviderSettingsToggleDescriptor( @@ -85,7 +86,10 @@ struct CodexProviderImplementation: ProviderImplementation { ProviderSettingsToggleDescriptor( id: "codex-openai-web-extras", title: "OpenAI web extras", - subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com.", + subtitle: [ + "Optional.", + "Turn this on to show code review, usage breakdown, and credits history via chatgpt.com.", + ].joined(separator: " "), binding: extrasBinding, statusText: nil, actions: [], @@ -93,6 +97,21 @@ struct CodexProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "codex-openai-web-battery-saver", + title: "Battery Saver", + subtitle: [ + "Recommended.", + "Limits background chatgpt.com refreshes to reduce battery and network usage.", + "Dashboard extras may stay stale until you refresh them manually.", + ].joined(separator: " "), + binding: batterySaverBinding, + statusText: nil, + actions: [], + isVisible: { context.settings.openAIWebAccessEnabled }, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ] } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 477529804f..84aebdd9f3 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -268,6 +268,17 @@ extension SettingsStore { } } + var openAIWebBatterySaverEnabled: Bool { + get { self.defaultsState.openAIWebBatterySaverEnabled } + set { + self.defaultsState.openAIWebBatterySaverEnabled = newValue + self.userDefaults.set(newValue, forKey: "openAIWebBatterySaverEnabled") + CodexBarLog.logger(LogCategories.settings).info( + "OpenAI web battery saver updated", + metadata: ["enabled": newValue ? "1" : "0"]) + } + } + var jetbrainsIDEBasePath: String { get { self.defaultsState.jetbrainsIDEBasePath } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 9c3a5095a1..5ac7f16f60 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -27,6 +27,7 @@ extension SettingsStore { _ = self.claudeWebExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage _ = self.openAIWebAccessEnabled + _ = self.openAIWebBatterySaverEnabled _ = self.codexUsageDataSource _ = self.codexActiveSource _ = self.claudeUsageDataSource diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index c3a59ba7aa..68ba707aa8 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -124,6 +124,8 @@ final class SettingsStore { copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(), tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { + let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil + let hadExistingConfig = (try? configStore.load()) != nil let legacyStores = CodexBarConfigMigrator.LegacyStores( zaiTokenStore: zaiTokenStore, syntheticTokenStore: syntheticTokenStore, @@ -159,7 +161,13 @@ final class SettingsStore { self.ensureAlibabaProviderAutoEnabledIfNeeded() self.applyTokenCostDefaultIfNeeded() if self.claudeUsageDataSource != .cli { self.claudeWebExtrasEnabled = false } - self.openAIWebAccessEnabled = self.codexCookieSource.isEnabled + if hasStoredOpenAIWebAccessPreference { + self.openAIWebAccessEnabled = self.defaultsState.openAIWebAccessEnabled + } else { + self.openAIWebAccessEnabled = Self.inferredInitialOpenAIWebAccessEnabled( + config: config, + hadExistingConfig: hadExistingConfig) + } if Self.shouldBridgeSharedDefaults(for: userDefaults) { Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") } @@ -168,6 +176,16 @@ final class SettingsStore { } extension SettingsStore { + private static func inferredInitialOpenAIWebAccessEnabled( + config: CodexBarConfig, + hadExistingConfig: Bool) -> Bool + { + guard let codex = config.providerConfig(for: .codex) else { return false } + if let cookieSource = codex.cookieSource { return cookieSource.isEnabled } + if codex.sanitizedCookieHeader != nil { return true } + return hadExistingConfig + } + private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState { let refreshDefault = userDefaults.string(forKey: "refreshFrequency") .flatMap(RefreshFrequency.init(rawValue:)) @@ -230,8 +248,11 @@ extension SettingsStore { let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true if creditsExtrasDefault == nil { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") } let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool - let openAIWebAccessEnabled = openAIWebAccessDefault ?? true - if openAIWebAccessDefault == nil { userDefaults.set(true, forKey: "openAIWebAccessEnabled") } + let openAIWebAccessEnabled = openAIWebAccessDefault ?? false + if openAIWebAccessDefault == nil { userDefaults.set(false, forKey: "openAIWebAccessEnabled") } + let openAIWebBatterySaverDefault = userDefaults.object(forKey: "openAIWebBatterySaverEnabled") as? Bool + let openAIWebBatterySaverEnabled = openAIWebBatterySaverDefault ?? false + if openAIWebBatterySaverDefault == nil { userDefaults.set(false, forKey: "openAIWebBatterySaverEnabled") } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true @@ -269,6 +290,7 @@ extension SettingsStore { claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, + openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406df..69e6760328 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -27,6 +27,7 @@ struct SettingsDefaultsState { var claudeWebExtrasEnabledRaw: Bool var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool + var openAIWebBatterySaverEnabled: Bool var jetbrainsIDEBasePath: String var mergeIcons: Bool var switcherShowsIcons: Bool diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift new file mode 100644 index 0000000000..cfe602df38 --- /dev/null +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -0,0 +1,24 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + func clearDisabledProviderState(enabledProviders: Set) { + for provider in UsageProvider.allCases where !enabledProviders.contains(provider) { + self.refreshingProviders.remove(provider) + self.snapshots.removeValue(forKey: provider) + self.errors[provider] = nil + self.lastSourceLabels.removeValue(forKey: provider) + self.lastFetchAttempts.removeValue(forKey: provider) + self.accountSnapshots.removeValue(forKey: provider) + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.failureGates[provider]?.reset() + self.tokenFailureGates[provider]?.reset() + self.statuses.removeValue(forKey: provider) + self.lastKnownSessionRemaining.removeValue(forKey: provider) + self.lastKnownSessionWindowSource.removeValue(forKey: provider) + self.lastTokenFetchAt.removeValue(forKey: provider) + } + } +} diff --git a/Sources/CodexBar/UsageStore+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift index 06dbc92c9a..d5c9830d06 100644 --- a/Sources/CodexBar/UsageStore+Logging.swift +++ b/Sources/CodexBar/UsageStore+Logging.swift @@ -18,6 +18,7 @@ extension UsageStore { "ampCookieSource": self.settings.ampCookieSource.rawValue, "ollamaCookieSource": self.settings.ollamaCookieSource.rawValue, "openAIWebAccess": self.settings.openAIWebAccessEnabled ? "1" : "0", + "openAIWebBatterySaver": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", "claudeWebExtras": self.settings.claudeWebExtrasEnabled ? "1" : "0", "kiloExtras": self.settings.kiloExtrasEnabled ? "1" : "0", ] diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 65fb3ce975..08a15e4a6b 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -1,6 +1,22 @@ import CodexBarCore import Foundation +struct OpenAIWebRefreshGateContext { + let force: Bool + let accountDidChange: Bool + let lastError: String? + let lastSnapshotAt: Date? + let lastAttemptAt: Date? + let now: Date + let refreshInterval: TimeInterval +} + +struct OpenAIWebRefreshPolicyContext { + let accessEnabled: Bool + let batterySaverEnabled: Bool + let force: Bool +} + // MARK: - OpenAI web lifecycle extension UsageStore { @@ -31,15 +47,28 @@ extension UsageStore { } func requestOpenAIDashboardRefreshIfStale(reason: String) { - guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { return } + guard self.isEnabled(.codex), + self.settings.openAIWebAccessEnabled, + self.settings.codexCookieSource.isEnabled + else { return } let now = Date() let refreshInterval = self.openAIWebRefreshIntervalSeconds() let lastUpdatedAt = self.openAIDashboard?.updatedAt ?? self.lastOpenAIDashboardSnapshot?.updatedAt if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval { return } let stamp = now.formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)") + let forceRefresh = Self.forceOpenAIWebRefreshForStaleRequest( + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) + self.openAIWebLogger.debug( + "OpenAI web stale refresh gate", + metadata: [ + "reason": reason, + "force": forceRefresh ? "1" : "0", + "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", + "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", + ]) let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() - Task { await self.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) } + Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } func applyOpenAIDashboard( @@ -95,6 +124,7 @@ extension UsageStore { return } + OpenAIDashboardFetcher.evictAllCachedWebViews() await MainActor.run { if let cached = self.lastOpenAIDashboardSnapshot { self.openAIDashboard = cached @@ -132,6 +162,7 @@ extension UsageStore { return } + OpenAIDashboardFetcher.evictAllCachedWebViews() await MainActor.run { self.lastOpenAIDashboardError = [ "OpenAI web access requires a signed-in chatgpt.com session.", @@ -311,10 +342,11 @@ extension UsageStore { bypassCoalescing: Bool = false, allowCodexUsageBackfill: Bool = true) async { - guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { - self.resetOpenAIWebState() - return - } + self.syncOpenAIWebState() + guard self.isEnabled(.codex), + self.settings.openAIWebAccessEnabled, + self.settings.codexCookieSource.isEnabled + else { return } if self.openAIWebManagedTargetStoreIsUnreadable() { await self.failClosedRefreshForUnreadableManagedCodexStore() return @@ -341,14 +373,18 @@ extension UsageStore { let now = Date() let minInterval = self.openAIWebRefreshIntervalSeconds() - if !force, - !self.openAIWebAccountDidChange, - self.lastOpenAIDashboardError == nil, - let snapshot = self.lastOpenAIDashboardSnapshot, - now.timeIntervalSince(snapshot.updatedAt) < minInterval - { + let refreshGate = OpenAIWebRefreshGateContext( + force: force, + accountDidChange: self.openAIWebAccountDidChange, + lastError: self.lastOpenAIDashboardError, + lastSnapshotAt: self.lastOpenAIDashboardSnapshot?.updatedAt, + lastAttemptAt: self.lastOpenAIDashboardAttemptAt, + now: now, + refreshInterval: minInterval) + if Self.shouldSkipOpenAIWebRefresh(refreshGate) { return } + self.lastOpenAIDashboardAttemptAt = now let taskToken = UUID() let context = OpenAIDashboardRefreshContext( @@ -610,6 +646,7 @@ extension UsageStore { self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardError = nil + self.lastOpenAIDashboardAttemptAt = nil self.openAIDashboardRequiresLogin = true self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…" self.lastOpenAIDashboardCookieImportAttemptAt = nil @@ -994,12 +1031,14 @@ extension UsageStore { func resetOpenAIWebState() { self.invalidateOpenAIDashboardRefreshTask() + OpenAIDashboardFetcher.evictAllCachedWebViews() self.openAIDashboard = nil self.openAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardError = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardTargetEmail = nil + self.lastOpenAIDashboardAttemptAt = nil self.openAIDashboardRequiresLogin = false self.openAIDashboardCookieImportStatus = nil self.openAIDashboardCookieImportDebugLog = nil @@ -1079,6 +1118,46 @@ extension UsageStore { // MARK: - OpenAI web error messaging extension UsageStore { + nonisolated static func shouldRunOpenAIWebRefresh(_ context: OpenAIWebRefreshPolicyContext) -> Bool { + guard context.accessEnabled else { return false } + return context.force || !context.batterySaverEnabled + } + + nonisolated static func forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: Bool) -> Bool { + !batterySaverEnabled + } + + nonisolated static func shouldSkipOpenAIWebRefresh(_ context: OpenAIWebRefreshGateContext) -> Bool { + if context.force || context.accountDidChange { return false } + if let lastAttemptAt = context.lastAttemptAt, + context.now.timeIntervalSince(lastAttemptAt) < context.refreshInterval + { + return true + } + if context.lastError == nil, + let lastSnapshotAt = context.lastSnapshotAt, + context.now.timeIntervalSince(lastSnapshotAt) < context.refreshInterval + { + return true + } + return false + } + + func syncOpenAIWebState() { + guard self.isEnabled(.codex), + self.settings.openAIWebAccessEnabled, + self.settings.codexCookieSource.isEnabled + else { + self.resetOpenAIWebState() + return + } + + let targetEmail = self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: true, + allowLastKnownLiveFallback: true) + self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) + } + func openAIDashboardFriendlyError( body: String, targetEmail: String?, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 42acabb03b..0ecbc1820d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -137,6 +137,7 @@ final class UsageStore { @ObservationIgnored var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored var lastOpenAIDashboardAttachmentAuthorized: Bool = false @ObservationIgnored var lastOpenAIDashboardTargetEmail: String? + @ObservationIgnored var lastOpenAIDashboardAttemptAt: Date? @ObservationIgnored var lastOpenAIDashboardCookieImportAttemptAt: Date? @ObservationIgnored var lastOpenAIDashboardCookieImportEmail: String? @ObservationIgnored var lastCodexAccountScopedRefreshGuard: CodexAccountScopedRefreshGuard? @@ -435,6 +436,8 @@ final class UsageStore { guard !self.isRefreshing else { return } self.prepareRefreshState() let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup + let enabledProviders = self.enabledProvidersForDisplay() + let enabledProviderSet = Set(enabledProviders) let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { @@ -444,8 +447,10 @@ final class UsageStore { self.hasCompletedInitialRefresh = true } + self.clearDisabledProviderState(enabledProviders: enabledProviderSet) + await withTaskGroup(of: Void.self) { group in - for provider in UsageProvider.allCases { + for provider in enabledProviders { group.addTask { await self.refreshProvider(provider) } group.addTask { await self.refreshStatus(provider) } } @@ -457,12 +462,32 @@ final class UsageStore { // 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. - let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() - await self.refreshOpenAIDashboardIfNeeded( - force: forceTokenUsage, - expectedGuard: codexDashboardGuard) + self.syncOpenAIWebState() + let refreshPolicy = OpenAIWebRefreshPolicyContext( + accessEnabled: self.isEnabled(.codex) && + self.settings.openAIWebAccessEnabled && + self.settings.codexCookieSource.isEnabled, + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, + force: forceTokenUsage) + let shouldRefreshOpenAIWeb = Self.shouldRunOpenAIWebRefresh(refreshPolicy) + self.openAIWebLogger.debug( + "OpenAI web refresh gate", + metadata: [ + "allowed": shouldRefreshOpenAIWeb ? "1" : "0", + "accessEnabled": refreshPolicy.accessEnabled ? "1" : "0", + "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", + "force": refreshPolicy.force ? "1" : "0", + "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", + "phase": refreshPhase == .startup ? "startup" : "regular", + ]) + if shouldRefreshOpenAIWeb { + let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() + await self.refreshOpenAIDashboardIfNeeded( + force: forceTokenUsage, + expectedGuard: codexDashboardGuard) + } - if self.openAIDashboardRequiresLogin { + if forceTokenUsage, self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } @@ -523,6 +548,7 @@ final class UsageStore { return } + let providers = self.enabledProvidersForDisplay() self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in guard let self else { return } defer { @@ -530,7 +556,7 @@ final class UsageStore { self?.tokenRefreshSequenceTask = nil } } - for provider in UsageProvider.allCases { + for provider in providers { if Task.isCancelled { break } await self.refreshTokenUsage(provider, force: force) } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index febf398299..64ca88b8de 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -309,6 +309,10 @@ public struct OpenAIDashboardFetcher { await OpenAIDashboardWebsiteDataStore.clearStore(forAccountEmail: accountEmail) } + public static func evictAllCachedWebViews() { + OpenAIDashboardWebViewCache.shared.evictAll() + } + public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index fa2d2dcab2..e7c9291e1a 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -29,7 +29,10 @@ final class OpenAIDashboardWebViewCache { } private var entries: [ObjectIdentifier: Entry] = [:] - private let idleTimeout: TimeInterval = 10 * 60 + /// Keep the WebView alive only long enough for immediate retries/menu reopens. + /// Long-lived hidden ChatGPT tabs still consume noticeable energy on some setups. + private let idleTimeout: TimeInterval = 60 + private let blankURL = URL(string: "about:blank")! // MARK: - Testing support @@ -50,6 +53,10 @@ final class OpenAIDashboardWebViewCache { self.prune(now: now) } + var idleTimeoutForTesting: TimeInterval { + self.idleTimeout + } + /// Seed a cached entry without navigating a real page (for test stability). @discardableResult func cacheEntryForTesting( @@ -173,10 +180,7 @@ final class OpenAIDashboardWebViewCache { guard let self, let entry else { return } entry.isBusy = false entry.lastUsedAt = Date() - // Hide instead of close - keep WebView cached for reuse. - // This avoids re-downloading the ChatGPT SPA bundle on every refresh, - // saving significant network bandwidth. See GitHub issues #269, #251. - entry.host.hide() + self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) self.prune(now: Date()) }) } @@ -213,10 +217,7 @@ final class OpenAIDashboardWebViewCache { guard let self, let entry else { return } entry.isBusy = false entry.lastUsedAt = Date() - // Hide instead of close - keep WebView cached for reuse. - // This avoids re-downloading the ChatGPT SPA bundle on every refresh, - // saving significant network bandwidth. See GitHub issues #269, #251. - entry.host.hide() + self.prepareCachedWebViewForIdle(webView, host: entry.host) self.prune(now: Date()) }) } @@ -228,6 +229,27 @@ final class OpenAIDashboardWebViewCache { entry.host.close() } + func evictAll() { + let existing = self.entries + self.entries.removeAll() + for (_, entry) in existing { + entry.host.close() + } + if !existing.isEmpty { + Self.log.debug("OpenAI webview evicted all") + } + } + + private func prepareCachedWebViewForIdle(_ webView: WKWebView, host: OffscreenWebViewHost) { + // Detach the heavyweight ChatGPT SPA as soon as a scrape completes. Keeping the WebView object around + // still helps with immediate reuse, but letting chatgpt.com remain the active document is too expensive. + webView.stopLoading() + webView.navigationDelegate = nil + webView.codexNavigationDelegate = nil + _ = webView.load(URLRequest(url: self.blankURL)) + host.hide() + } + private func prune(now: Date) { let expired = self.entries.filter { _, entry in !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index 4c93321841..d2d139a9a9 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -338,45 +338,47 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { func `experimental strategy does not use security framework fingerprint observation`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - final class CounterBox: @unchecked Sendable { - private let lock = NSLock() - private(set) var count: Int = 0 - func increment() { - self.lock.lock() - self.count += 1 - self.lock.unlock() - } - } - let fingerprintCounter = CounterBox() - let securityData = self.makeCredentialsData( - accessToken: "security-token-a", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let outcome = await self.withCoordinatorOverrides( - cliAvailable: true, - touchAuthPath: { _, _ in }, - keychainFingerprint: { - fingerprintCounter.increment() - return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "framework-fingerprint") - }, - operation: { - await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 60000), - timeout: 0.1) + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() } - }) + } + let fingerprintCounter = CounterBox() + let securityData = self.makeCredentialsData( + accessToken: "security-token-a", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in }, + keychainFingerprint: { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "framework-fingerprint") + }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 60000), + timeout: 0.1) + } + }) - guard case .attemptedFailed = outcome else { - Issue.record("Expected .attemptedFailed outcome") - return + guard case .attemptedFailed = outcome else { + Issue.record("Expected .attemptedFailed outcome") + return + } + #expect(fingerprintCounter.count < 1) } - #expect(fingerprintCounter.count < 1) } } @@ -416,6 +418,7 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { self.lock.unlock() } } + let fingerprintCounter = CounterBox() let beforeData = self.makeCredentialsData( accessToken: "security-token-before", expiresAt: Date(timeIntervalSinceNow: -60)) @@ -423,7 +426,6 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { accessToken: "security-token-after", expiresAt: Date(timeIntervalSinceNow: 3600)) let dataBox = DataBox(data: beforeData) - let fingerprintCounter = CounterBox() let outcome = await self.withCoordinatorOverrides( cliAvailable: true, touchAuthPath: { _, _ in @@ -456,69 +458,71 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { func `experimental strategy missing baseline does not auto succeed when later read succeeds`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - final class DataBox: @unchecked Sendable { - private let lock = NSLock() - private var _data: Data? - init(data: Data?) { - self._data = data - } + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class DataBox: @unchecked Sendable { + private let lock = NSLock() + private var _data: Data? + init(data: Data?) { + self._data = data + } - func load() -> Data? { - self.lock.lock() - defer { self.lock.unlock() } - return self._data - } + func load() -> Data? { + self.lock.lock() + defer { self.lock.unlock() } + return self._data + } - func store(_ data: Data?) { - self.lock.lock() - self._data = data - self.lock.unlock() - } - } - final class CounterBox: @unchecked Sendable { - private let lock = NSLock() - private(set) var count: Int = 0 - func increment() { - self.lock.lock() - self.count += 1 - self.lock.unlock() + func store(_ data: Data?) { + self.lock.lock() + self._data = data + self.lock.unlock() + } } - } - let afterData = self.makeCredentialsData( - accessToken: "security-token-after-baseline-miss", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let dataBox = DataBox(data: nil) - let fingerprintCounter = CounterBox() - let outcome = await self.withCoordinatorOverrides( - cliAvailable: true, - touchAuthPath: { _, _ in - dataBox.store(afterData) - }, - keychainFingerprint: { - fingerprintCounter.increment() - return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 21, - createdAt: 21, - persistentRefHash: "framework-fingerprint") - }, - operation: { - await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .dynamic { _ in dataBox.load() }) - { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61500), - timeout: 0.1) + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() } - }) + } + let fingerprintCounter = CounterBox() + let afterData = self.makeCredentialsData( + accessToken: "security-token-after-baseline-miss", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let dataBox = DataBox(data: nil) + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in + dataBox.store(afterData) + }, + keychainFingerprint: { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 21, + createdAt: 21, + persistentRefHash: "framework-fingerprint") + }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in dataBox.load() }) + { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61500), + timeout: 0.1) + } + }) - guard case .attemptedFailed = outcome else { - Issue.record("Expected .attemptedFailed outcome when baseline is unavailable") - return + guard case .attemptedFailed = outcome else { + Issue.record("Expected .attemptedFailed outcome when baseline is unavailable") + return + } + #expect(fingerprintCounter.count < 1) } - #expect(fingerprintCounter.count < 1) } } diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 4f3d61f9fc..6261162b33 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -565,6 +565,8 @@ struct CodexAccountScopedRefreshTests { func `default dashboard refresh path discards stale completion after account switch`() async { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-dashboard-guard") settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto settings._test_liveSystemCodexAccount = self.liveAccount(email: "alpha@example.com") let store = self.makeUsageStore(settings: settings) diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 7bc9bf6b24..cb8ccb90de 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -232,11 +232,14 @@ struct CodexManagedOpenAIWebRefreshTests { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + return settings } } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift index 3e5c3a0703..c8c6c103d1 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift @@ -95,6 +95,8 @@ extension CodexManagedOpenAIWebTests { settings._test_managedCodexAccountStoreURL = nil settings._test_liveSystemCodexAccount = nil settings._test_codexReconciliationEnvironment = nil + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto return settings } diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index 43439a598f..84d500fb2d 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -12,10 +12,16 @@ import WebKit @MainActor @Suite(.serialized) struct OpenAIDashboardWebViewCacheTests { + private func shouldSkipOnCI() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" + } + // MARK: - Data Store Identity Tests @Test func `WKWebsiteDataStore should return same instance for same email`() { + if self.shouldSkipOnCI() { return } OpenAIDashboardWebsiteDataStore.clearCacheForTesting() let store1 = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "test@example.com") @@ -36,6 +42,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `WebView should be cached after release, not destroyed`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -69,6 +76,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Different data stores should have separate cached WebViews`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() let store2 = WKWebsiteDataStore.nonPersistent() @@ -100,14 +108,15 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `WebView should be pruned after idle timeout`() { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() cache.cacheEntryForTesting(websiteDataStore: store) #expect(cache.hasCachedEntry(for: store), "Should be cached immediately after release") - // Simulate time passing beyond idle timeout (10 minutes + buffer) - let futureTime = Date().addingTimeInterval(11 * 60) + // Simulate time passing beyond the configured idle timeout. + let futureTime = Date().addingTimeInterval(cache.idleTimeoutForTesting + 5) cache.pruneForTesting(now: futureTime) #expect(!cache.hasCachedEntry(for: store), "Should be pruned after idle timeout") @@ -116,12 +125,13 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Recently used WebView should not be pruned`() { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() cache.cacheEntryForTesting(websiteDataStore: store) - // Simulate time passing within idle timeout (5 minutes) - let nearFutureTime = Date().addingTimeInterval(5 * 60) + // Simulate time passing comfortably within the configured idle timeout. + let nearFutureTime = Date().addingTimeInterval(max(1, cache.idleTimeoutForTesting / 2)) cache.pruneForTesting(now: nearFutureTime) #expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout") @@ -132,6 +142,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Evict should remove specific WebView from cache`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() let store2 = WKWebsiteDataStore.nonPersistent() @@ -157,6 +168,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Evicted WebView should not be reused on next acquire`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -176,10 +188,33 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test("Evict all should remove every cached WebView") + func evictAllRemovesAllEntries() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store1 = WKWebsiteDataStore.nonPersistent() + let store2 = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let lease1 = try await cache.acquire(websiteDataStore: store1, usageURL: url, logger: nil) + lease1.release() + let lease2 = try await cache.acquire(websiteDataStore: store2, usageURL: url, logger: nil) + lease2.release() + + #expect(cache.entryCount == 2, "Should have two cached entries") + + cache.evictAll() + + #expect(cache.entryCount == 0, "Evict all should remove every cached entry") + #expect(!cache.hasCachedEntry(for: store1), "First store should be evicted") + #expect(!cache.hasCachedEntry(for: store2), "Second store should be evicted") + } + // MARK: - Busy WebView Tests @Test func `Busy WebView should create temporary WebView for concurrent access`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -215,6 +250,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Multiple sequential fetches should reuse same WebView (network optimization)`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -249,6 +285,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView`() async throws { + if self.shouldSkipOnCI() { return } OpenAIDashboardWebsiteDataStore.clearCacheForTesting() let cache = OpenAIDashboardWebViewCache() let url = try #require(URL(string: "about:blank")) diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift new file mode 100644 index 0000000000..67c7282cc4 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import CodexBar + +struct OpenAIWebRefreshGateTests { + @Test("Battery saver keeps background OpenAI web refreshes off") + func batterySaverDisablesBackgroundRefresh() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: false)) + + #expect(shouldRun == false) + } + + @Test("Disabling battery saver restores normal OpenAI web refreshes") + func disabledBatterySaverAllowsBackgroundRefresh() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false)) + + #expect(shouldRun == true) + } + + @Test("Manual refresh still forces OpenAI web refreshes with battery saver enabled") + func manualRefreshBypassesBatterySaver() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: true)) + + #expect(shouldRun == true) + } + + @Test("Battery saver stale-submenu refresh respects the cooldown") + func batterySaverStaleRefreshDoesNotForce() { + let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: true) + + #expect(shouldForce == false) + } + + @Test("Normal stale-submenu refresh still forces when battery saver is off") + func nonBatterySaverStaleRefreshForces() { + let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: false) + + #expect(shouldForce == true) + } + + @Test("Recent successful dashboard refresh stays throttled") + func recentSuccessSkipsRefresh() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-60), + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test("Recent failed dashboard refresh also stays throttled") + func recentFailureSkipsRefresh() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: false, + lastError: "login required", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test("Force refresh bypasses throttle after failures") + func forceRefreshBypassesCooldown() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: true, + accountDidChange: false, + lastError: "login required", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } + + @Test("Account switches bypass the prior-attempt cooldown") + func accountChangeBypassesCooldown() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: true, + lastError: "mismatch", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 924da2a05f..42db664f54 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -124,6 +124,57 @@ struct ProviderSettingsDescriptorTests { #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) } + @Test + func codexExposesOpenAIWebExtrasToggleAsDefaultOffOptIn() throws { + let suite = "ProviderSettingsDescriptorTests-codex-openai-toggle" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let context = ProviderSettingsContext( + provider: .codex, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let toggles = CodexProviderImplementation().settingsToggles(context: context) + let extrasToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-extras" })) + #expect(extrasToggle.binding.wrappedValue == false) + #expect(extrasToggle.subtitle.contains("Optional.")) + #expect(extrasToggle.subtitle.contains("Turn this on")) + + let batterySaverToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-battery-saver" })) + #expect(batterySaverToggle.binding.wrappedValue == false) + #expect(batterySaverToggle.subtitle.contains("Recommended.")) + #expect(batterySaverToggle.isVisible?() == false) + + settings.openAIWebAccessEnabled = true + #expect(batterySaverToggle.isVisible?() == true) + } + @Test func `claude exposes usage and cookie pickers`() throws { let suite = "ProviderSettingsDescriptorTests-claude" diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 08fd2ecfef..a2f6742717 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -649,13 +649,63 @@ struct SettingsStoreTests { } @Test - func `defaults open AI web access to enabled`() throws { + func defaultsOpenAIWebAccessToDisabled() throws { let suite = "SettingsStoreTests-openai-web" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defaults.set(false, forKey: "debugDisableKeychainAccess") let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == false) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .off) + } + + @Test + func infersOpenAIWebAccessEnabledForLegacyConfiguredCodexCookies() throws { + let suite = "SettingsStoreTests-openai-web-legacy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex, cookieSource: .auto), + ])) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == true) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .auto) + } + + @Test + func infersOpenAIWebAccessEnabledForLegacyCodexConfigWithImplicitAutoCookies() throws { + let suite = "SettingsStoreTests-openai-web-legacy-implicit-auto" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex), + ])) + let store = SettingsStore( userDefaults: defaults, configStore: configStore, @@ -664,9 +714,60 @@ struct SettingsStoreTests { #expect(store.openAIWebAccessEnabled == true) #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) #expect(store.codexCookieSource == .auto) } + @Test + func disablingOpenAIWebAccessTurnsCodexCookieSourceOff() throws { + let suite = "SettingsStoreTests-openai-web-toggle" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.codexCookieSource = .auto + #expect(store.codexCookieSource == .auto) + + store.openAIWebAccessEnabled = false + #expect(store.codexCookieSource == .off) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + + store.openAIWebAccessEnabled = true + #expect(store.codexCookieSource == .auto) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + } + + @Test + func openAIWebBatterySaverPersistsSeparatelyFromExtrasAvailability() throws { + let suite = "SettingsStoreTests-openai-web-battery-saver" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebBatterySaverEnabled == false) + + store.openAIWebBatterySaverEnabled = false + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + + store.openAIWebAccessEnabled = true + #expect(store.openAIWebBatterySaverEnabled == false) + } + @Test func `menu observation token updates on defaults change`() async throws { let suite = "SettingsStoreTests-observation-defaults" diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index eca3810fc3..b0b676848a 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -194,6 +194,7 @@ struct StatusMenuTests { settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -517,11 +518,15 @@ struct StatusMenuTests { #expect(!titles.contains("Switch Account...")) #expect(!titles.contains("Usage Dashboard")) #expect(!titles.contains("Status Page")) + #expect(titles.contains("Refresh")) #expect(titles.contains("Settings...")) #expect(titles.contains("About CodexBar")) #expect(titles.contains("Quit")) } +} +@MainActor +extension StatusMenuTests { @Test func `status blurb uses wrapped view-backed menu item`() { self.disableMenuCardsForTesting() @@ -648,7 +653,56 @@ struct StatusMenuTests { } @Test - func `shows open AI web submenus when history exists`() throws { + func hidesOpenAIWebSubmenusWhenOpenAIWebExtrasDisabled() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let titles = Set(menu.items.map(\.title)) + #expect(!titles.contains("Credits history")) + #expect(!titles.contains("Usage breakdown")) + } + + @Test + func showsOpenAIWebSubmenusWhenHistoryExists() throws { self.disableMenuCardsForTesting() let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusMenuTests-history"), @@ -658,6 +712,7 @@ struct StatusMenuTests { settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 67b0a323a6..49dd6e681c 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -134,7 +134,77 @@ struct UsageStoreCoverageTests { } @Test - func `status indicators and failure gate`() { + func backgroundRefreshOnlyTracksEnabledProviders() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-refresh") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let staleSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(staleSnapshot, provider: .claude) + store._setErrorForTesting("stale", provider: .claude) + store.statuses[.claude] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProviders() == [.codex]) + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProvidersForDisplay())) + + #expect(store.snapshot(for: .claude) == nil) + #expect(store.errors[.claude] == nil) + #expect(store.statuses[.claude] == nil) + } + + @Test + func cleanupPreservesEnabledButUnavailableProviderState() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-preserve-unavailable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let staleSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(staleSnapshot, provider: .synthetic) + store._setErrorForTesting("stale", provider: .synthetic) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProvidersForDisplay())) + + #expect(store.snapshot(for: .synthetic) != nil) + #expect(store.errors[.synthetic] == "stale") + #expect(store.statuses[.synthetic]?.indicator == .major) + } + + @Test + func statusIndicatorsAndFailureGate() { #expect(!ProviderStatusIndicator.none.hasIssue) #expect(ProviderStatusIndicator.maintenance.hasIssue) #expect(ProviderStatusIndicator.unknown.label == "Status unknown") diff --git a/docs/codex.md b/docs/codex.md index 0396d6e41e..7c42a32981 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -32,7 +32,12 @@ Usage source picker: - Refreshes access tokens when `last_refresh` is older than 8 days. - Calls `GET https://chatgpt.com/backend-api/wham/usage` (default) with `Authorization: Bearer `. -### OpenAI web dashboard (optional) +### OpenAI web dashboard (optional, off by default) +- Enable it in Preferences -> Providers -> Codex -> OpenAI web extras. +- It exists for dashboard-only extras such as code review remaining, usage breakdown, and credits history. +- It is intentionally opt-in because it loads `chatgpt.com` in a hidden WebView and can materially increase battery or network usage. +- OpenAI web battery saver is a separate toggle. When enabled, routine background/settings-driven refreshes are reduced, but explicit manual refreshes still run. +- OpenAI web battery saver currently defaults to off. - Preferences → Providers → Codex → OpenAI cookies (Automatic or Manual). - URL: `https://chatgpt.com/codex/settings/usage`. - Uses an off-screen `WKWebView` with a per-account `WKWebsiteDataStore`. diff --git a/docs/providers.md b/docs/providers.md index 63f3aeaa0b..e6f0516bc4 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -41,7 +41,8 @@ until the session is invalid, to avoid repeated Keychain prompts. | OpenRouter | API token (config, overrides env) → credits API (`api`). | ## Codex -- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. +- Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. +- Battery saver toggle (currently off by default): reduces routine OpenAI web refreshes but still allows explicit manual refreshes. - CLI RPC default: `codex ... app-server` JSON-RPC (`account/read`, `account/rateLimits/read`). - CLI PTY fallback: `/status` scrape. - Local cost usage: scans `~/.codex/sessions/**/*.jsonl` (last 30 days). diff --git a/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md new file mode 100644 index 0000000000..8636edb3f8 --- /dev/null +++ b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md @@ -0,0 +1,67 @@ +--- +module: CodexBar +date: 2026-03-07 +problem_type: performance_issue +component: tooling +symptoms: + - "Hidden chatgpt.com web content could spike to extremely high Energy Impact values in Activity Monitor" + - "CodexBar battery usage stayed abnormally high even when the app appeared idle" + - "Users did not realize optional OpenAI web extras were enabled by default" +root_cause: wrong_api +resolution_type: config_change +severity: high +tags: [codexbar, battery-drain, openai-web, webview, chatgpt, defaults] +--- + +# Troubleshooting: Default OpenAI Web Extras Off + +## Problem +CodexBar exposed optional OpenAI dashboard extras through a hidden `chatgpt.com` WebView, but the feature was enabled by default. That created a mismatch between user expectations for a lightweight menu bar app and the real cost of running a hidden single-page web app in the background. + +## Environment +- Module: CodexBar +- Affected component: Codex OpenAI web extras +- Date: 2026-03-07 + +## Symptoms +- Activity Monitor showed extreme energy usage attributed to `https://chatgpt.com` under the CodexBar process tree. +- Users observed battery drain that was out of proportion to the visible work the app was doing. +- The optional setting existed, but it was easy to miss, so affected users often did not know they could disable it. + +## What Didn't Work + +**Attempted solution 1:** Throttle failed OpenAI dashboard refresh attempts and evict cached WebViews more aggressively. +- **Why it failed:** This reduced the runaway failure loop, but it did not change the product default. Users could still pay the cost of a hidden ChatGPT dashboard without explicitly opting into it. + +**Attempted solution 2:** Keep the feature enabled by default and rely on a visible opt-out toggle. +- **Why it failed:** The battery and network cost was too high for a background utility. An opt-out-only design still left many users exposed to behavior they did not expect or understand. + +## Solution +Change OpenAI web extras to be off by default for new installs while preserving existing explicit configurations. + +**Code changes** +- `SettingsStore` now defaults `openAIWebAccessEnabled` to `false` when no prior preference exists. +- `SettingsStore` now defaults `openAIWebBatterySaverEnabled` to `false`; users can still opt into reduced routine OpenAI web refreshes separately. +- Existing users with an explicit Codex cookie configuration are inferred as enabled so upgrades do not silently break working setups. +- The Codex settings copy now describes the feature as optional and warns about battery and network cost. +- Documentation now labels the OpenAI web dashboard path as optional and off by default. + +## Why This Works +The root problem was not that the app had a toggle. The root problem was that an optional feature with heavyweight implementation details was enabled by default. + +The OpenAI web extras path uses a hidden `WKWebView` against `chatgpt.com` to gather dashboard-only data. That mechanism is fundamentally more expensive than the main Codex data paths, which already provide the normal information users expect from the app: session usage, weekly usage, reset timers, account identity, plan label, and normal credits remaining. + +Making the feature opt-in aligns the default behavior with the actual technical cost: +1. The normal Codex card continues to work without the hidden ChatGPT dashboard. +2. Users only incur the WebView cost if they deliberately choose the extra dashboard data. +3. Existing users with a configured Codex web setup keep their behavior on upgrade instead of being silently broken. + +## Prevention +- Do not default-enable optional features that load heavyweight hidden web content in a background utility. +- If a feature depends on a hidden SPA or WebView, require explicit user opt-in unless it is essential to core functionality. +- Prefer direct API or cookie-backed HTTP requests over hidden browser automation for background data collection. +- Surface the operational cost of optional features in the settings copy, not only in debug notes or issue threads. + +## Related Issues +- See also: [perf-energy-issue-139-simulation-report-2026-02-19.md](../../perf-energy-issue-139-simulation-report-2026-02-19.md) +- See also: [perf-energy-issue-139-main-fix-validation-2026-02-19.md](../../perf-energy-issue-139-main-fix-validation-2026-02-19.md) From 5e6b3eb76c001755498aa6292119dd6d08d3c886 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 10 Apr 2026 22:21:54 +0530 Subject: [PATCH 0156/1239] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 279d0ec180..f26b0c1da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,19 @@ - Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! - Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps still prefer user-managed and native Homebrew binaries when multiple installs exist. +- Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! ### Providers & Usage - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps prefer `~/.claude/bin/claude`, then Homebrew, before the bundled `cmux.app` binary when shell-based resolution is unavailable. - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. +- Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. ### Menu & Settings - Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm! - z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662). +- Codex: add an OpenAI web battery-saver toggle, keep manual refresh available when battery saver is on, and hide OpenAI web submenus when web extras are disabled. ## 0.20 — 2026-04-07 From 49aa91d3cb748d462199aaed9ce90934fa9e5777 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 10 Apr 2026 22:32:44 +0530 Subject: [PATCH 0157/1239] Update battery saver subtitle copy --- .../CodexBar/Providers/Codex/CodexProviderImplementation.swift | 1 - Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index ad1a0f2fcc..9a39c3af11 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -101,7 +101,6 @@ struct CodexProviderImplementation: ProviderImplementation { id: "codex-openai-web-battery-saver", title: "Battery Saver", subtitle: [ - "Recommended.", "Limits background chatgpt.com refreshes to reduce battery and network usage.", "Dashboard extras may stay stale until you refresh them manually.", ].joined(separator: " "), diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 42db664f54..99f8a9837f 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -168,7 +168,6 @@ struct ProviderSettingsDescriptorTests { let batterySaverToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-battery-saver" })) #expect(batterySaverToggle.binding.wrappedValue == false) - #expect(batterySaverToggle.subtitle.contains("Recommended.")) #expect(batterySaverToggle.isVisible?() == false) settings.openAIWebAccessEnabled = true From f8b9fd2280e4caf414505b8415f27f2ecbc1bba6 Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Sat, 11 Apr 2026 02:35:41 +0530 Subject: [PATCH 0158/1239] Add flag to compile and run script to clear ad-hoc keychain caches (#688) * Clear adhoc keychain caches * Make adhoc keychain reset optional --------- Co-authored-by: Kevin Lee --- Scripts/compile_and_run.sh | 32 +++++++++++++++++++++++--------- docs/DEVELOPMENT_SETUP.md | 10 ++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index ce6992d453..865f2a7973 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -16,10 +16,19 @@ RUN_TESTS=0 DEBUG_LLDB=0 RELEASE_ARCHES="" SIGNING_MODE="${CODEXBAR_SIGNING:-}" +CLEAR_ADHOC_KEYCHAIN=0 log() { printf '%s\n' "$*"; } fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +delete_keychain_service_items() { + local service="$1" + security delete-generic-password -s "${service}" >/dev/null 2>&1 || true + while security delete-generic-password -s "${service}" >/dev/null 2>&1; do + : + done +} + has_signing_identity() { local identity="${1:-}" if [[ -z "${identity}" ]]; then @@ -152,10 +161,11 @@ for arg in "$@"; do --wait|-w) WAIT_FOR_LOCK=1 ;; --test|-t) RUN_TESTS=1 ;; --debug-lldb) DEBUG_LLDB=1 ;; + --clear-adhoc-keychain) CLEAR_ADHOC_KEYCHAIN=1 ;; --release-universal) RELEASE_ARCHES="arm64 x86_64" ;; --release-arches=*) RELEASE_ARCHES="${arg#*=}" ;; --help|-h) - log "Usage: $(basename "$0") [--wait] [--test] [--debug-lldb] [--release-universal] [--release-arches=\"arm64 x86_64\"]" + log "Usage: $(basename "$0") [--wait] [--test] [--debug-lldb] [--clear-adhoc-keychain] [--release-universal] [--release-arches=\"arm64 x86_64\"]" exit 0 ;; *) @@ -164,6 +174,9 @@ for arg in "$@"; do done resolve_signing_mode +if [[ "${CLEAR_ADHOC_KEYCHAIN}" == "1" && "${SIGNING_MODE}" != "adhoc" ]]; then + fail "--clear-adhoc-keychain is only supported when using adhoc signing." +fi if [[ "${SIGNING_MODE}" == "adhoc" ]]; then log "==> Signing: adhoc (set APP_IDENTITY or install a dev cert to avoid keychain prompts)" else @@ -177,15 +190,16 @@ log "==> Killing existing CodexBar instances" kill_all_codexbar kill_claude_probes -# 2.5) Delete keychain entries to avoid permission prompts with adhoc signing +# 2.5) Optionally delete keychain entries to avoid permission prompts with adhoc signing # (adhoc signature changes on every build, making old keychain entries inaccessible) -if [[ "${SIGNING_MODE:-adhoc}" == "adhoc" ]]; then - log "==> Clearing keychain entries (adhoc signing)" - security delete-generic-password -s "com.steipete.CodexBar" 2>/dev/null || true - # Clear all keychain items for the app to avoid multiple prompts - while security delete-generic-password -s "com.steipete.CodexBar" 2>/dev/null; do - : - done +if [[ "${SIGNING_MODE:-adhoc}" == "adhoc" && "${CLEAR_ADHOC_KEYCHAIN}" == "1" ]]; then + log "==> Clearing CodexBar keychain entries (adhoc signing)" + # Clear both the legacy keychain store and the current cache service when developers explicitly want a clean reset + # of CodexBar-owned keychain state for ad-hoc builds. + delete_keychain_service_items "com.steipete.CodexBar" + delete_keychain_service_items "com.steipete.codexbar.cache" +elif [[ "${SIGNING_MODE:-adhoc}" == "adhoc" ]]; then + log "==> Preserving CodexBar keychain entries (pass --clear-adhoc-keychain to reset adhoc keychain state)" fi # 3) Package (release build happens inside package_app.sh). diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index a32098e4ea..48343efe95 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -15,6 +15,9 @@ When developing CodexBar, you may see frequent keychain permission prompts like: > **CodexBar wants to access key "Claude Code-credentials" in your keychain.** This happens because each rebuild creates a new code signature, and macOS treats it as a "different" app. +That can affect both CodexBar-owned entries (`com.steipete.CodexBar`, `com.steipete.codexbar.cache`) and +third-party items such as `Claude Code-credentials`, so an ad-hoc-signed rebuild can keep re-triggering +password/keychain approval dialogs even after you previously chose **Always Allow**. ### Quick Fix (Temporary) @@ -101,6 +104,13 @@ This script: 5. Launches `CodexBar.app` 6. Verifies it stays running +When the script falls back to ad-hoc signing, it preserves CodexBar-owned keychain state by default. +That means you may still see keychain prompts for existing CodexBar cache entries, but allowing those prompts keeps the +cached browser/OAuth state available across normal rebuilds. +If you want a clean reset of CodexBar-owned keychain state for an ad-hoc build, run +`./Scripts/compile_and_run.sh --clear-adhoc-keychain` before relaunching. +Third-party keychain items still need stable signing if you want macOS to remember **Always Allow** across rebuilds. + ### Quick Build (No Tests) ```bash From ce2ca032d61f02717fc4d0e1ec26a9d9b2bcc9e8 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 11 Apr 2026 02:45:56 +0530 Subject: [PATCH 0159/1239] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f26b0c1da8..14b0926099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ - z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662). - Codex: add an OpenAI web battery-saver toggle, keep manual refresh available when battery saver is on, and hide OpenAI web submenus when web extras are disabled. +### Development & Tooling +- Build script: make CodexBar-owned ad-hoc keychain cleanup opt-in with `--clear-adhoc-keychain`, and extend the explicit reset path to clear both `com.steipete.CodexBar` and `com.steipete.codexbar.cache`. Thanks @magnaprog! + ## 0.20 — 2026-04-07 ### Highlights From 69a715f6d29c1200a0127174a487acbc4779f443 Mon Sep 17 00:00:00 2001 From: Anirudh Venkatachalam <50367124+anirudhvee@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:20:38 -0700 Subject: [PATCH 0160/1239] fix: handle Antigravity localhost TLS challenges Fixes #692 - handle Antigravity localhost TLS auth challenges explicitly when probing the local language server - scope the trust override to localhost only - keep localhost requests cancellable - update the unreleased changelog entry for the fix Co-authored-by: Anirudh Venkatachalam <50367124+anirudhvee@users.noreply.github.com> --- CHANGELOG.md | 2 + .../Antigravity/AntigravityStatusProbe.swift | 107 +++++++++++++++--- .../AntigravityStatusProbeTests.swift | 32 +++++- 3 files changed, 123 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b0926099..bfff543e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Highlights - z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. - Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! +- Antigravity: accept localhost TLS challenges when probing the local language server so usage/account info loads again instead of reporting `no working API port found` (#693, fixes #692). Thanks @anirudhvee! - Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps still prefer user-managed and native Homebrew binaries when multiple installs exist. - Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! @@ -12,6 +13,7 @@ ### Providers & Usage - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). +- Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps prefer `~/.claude/bin/claude`, then Homebrew, before the bundled `cmux.app` binary when shell-based resolution is unavailable. - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. - Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 6ad656a3df..aaab9a483f 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -630,10 +630,13 @@ public struct AntigravityStatusProbe: Sendable { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = context.timeout config.timeoutIntervalForResource = context.timeout - let session = URLSession(configuration: config, delegate: InsecureSessionDelegate(), delegateQueue: nil) + config.waitsForConnectivity = false + + let delegate = LocalhostSessionDelegate() + let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) defer { session.invalidateAndCancel() } - let (data, response) = try await session.data(for: request) + let (data, response) = try await delegate.data(for: request, session: session) guard let http = response as? HTTPURLResponse else { throw AntigravityStatusProbeError.apiError("Invalid response") } @@ -645,38 +648,108 @@ public struct AntigravityStatusProbe: Sendable { } } -private final class InsecureSessionDelegate: NSObject {} +enum LocalhostTrustPolicy { + static func shouldAcceptServerTrust( + host: String, + authenticationMethod: String, + hasServerTrust: Bool) -> Bool + { + guard authenticationMethod == NSURLAuthenticationMethodServerTrust else { return false } + let normalizedHost = host.lowercased() + guard normalizedHost == "127.0.0.1" || normalizedHost == "localhost" else { return false } + return hasServerTrust + } +} -extension InsecureSessionDelegate: URLSessionTaskDelegate {} +private final class LocalhostSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { + func data(for request: URLRequest, session: URLSession) async throws -> (Data, URLResponse) { + let state = LocalhostSessionTaskState() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let task = session.dataTask(with: request) { data, response, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let data, let response else { + continuation.resume(throwing: AntigravityStatusProbeError.apiError("Invalid response")) + return + } + continuation.resume(returning: (data, response)) + } + state.setTask(task) + task.resume() + } + } onCancel: { + state.cancel() + } + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + { + self.handle(challenge, completionHandler: completionHandler) + } -extension InsecureSessionDelegate { func urlSession( _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + self.handle(challenge, completionHandler: completionHandler) } - private func challengeResult(_ challenge: URLAuthenticationChallenge) -> ( - disposition: URLSession.AuthChallengeDisposition, - credential: URLCredential?) + private func handle( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { #if canImport(FoundationNetworking) - return (.performDefaultHandling, nil) + completionHandler(.performDefaultHandling, nil) #else - if let trust = challenge.protectionSpace.serverTrust { - return (.useCredential, URLCredential(trust: trust)) + let protectionSpace = challenge.protectionSpace + let trust = protectionSpace.serverTrust + guard LocalhostTrustPolicy.shouldAcceptServerTrust( + host: protectionSpace.host, + authenticationMethod: protectionSpace.authenticationMethod, + hasServerTrust: trust != nil), + let trust + else { + completionHandler(.performDefaultHandling, nil) + return } - return (.performDefaultHandling, nil) + completionHandler(.useCredential, URLCredential(trust: trust)) #endif } } +private final class LocalhostSessionTaskState: @unchecked Sendable { + private let lock = NSLock() + private var task: URLSessionDataTask? + private var isCancelled = false + + func setTask(_ task: URLSessionDataTask) { + self.lock.lock() + self.task = task + let shouldCancel = self.isCancelled + self.lock.unlock() + + if shouldCancel { + task.cancel() + } + } + + func cancel() { + self.lock.lock() + self.isCancelled = true + let task = self.task + self.lock.unlock() + task?.cancel() + } +} + private struct UserStatusResponse: Decodable { let code: CodeValue? let message: String? diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index 8946f630db..33c1bb7e57 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -1,8 +1,38 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore struct AntigravityStatusProbeTests { + @Test + func `localhost trust policy only accepts local server trust challenges`() { + #expect( + LocalhostTrustPolicy.shouldAcceptServerTrust( + host: "127.0.0.1", + authenticationMethod: NSURLAuthenticationMethodServerTrust, + hasServerTrust: true)) + #expect( + LocalhostTrustPolicy.shouldAcceptServerTrust( + host: "LOCALHOST", + authenticationMethod: NSURLAuthenticationMethodServerTrust, + hasServerTrust: true)) + + #expect( + !LocalhostTrustPolicy.shouldAcceptServerTrust( + host: "cursor.com", + authenticationMethod: NSURLAuthenticationMethodServerTrust, + hasServerTrust: true)) + #expect( + !LocalhostTrustPolicy.shouldAcceptServerTrust( + host: "127.0.0.1", + authenticationMethod: NSURLAuthenticationMethodHTTPBasic, + hasServerTrust: true)) + #expect( + !LocalhostTrustPolicy.shouldAcceptServerTrust( + host: "127.0.0.1", + authenticationMethod: NSURLAuthenticationMethodServerTrust, + hasServerTrust: false)) + } + @Test func `parses user status response`() throws { let json = """ From 620a86c76b1a9c8ce56dae21f1a8b0c6a1196d00 Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Sun, 12 Apr 2026 13:51:19 +0530 Subject: [PATCH 0161/1239] feat: support Edge browser cookie import for Codex provider (#694) * feat: support Edge and other Chromium browsers for Codex web cookie import Replace separate tryChrome/tryFirefox methods with a generic tryBrowser that handles any non-Safari browser. Previously the trySource switch only matched .safari/.chrome/.firefox, silently ignoring Edge, Brave, Arc and all other browsers detected by SweetCookieKit. Closes #372 * Preserve Codex browser fallback order * Fix Antigravity Linux CLI build --------- Co-authored-by: Yuhan Lei --- ...OpenAIDashboardBrowserCookieImporter.swift | 77 +++++-------------- .../Antigravity/AntigravityStatusProbe.swift | 35 +++++---- .../Codex/CodexProviderDescriptor.swift | 3 +- .../CodexBarCore/Providers/Providers.swift | 11 +++ .../BrowserCookieOrderLabelTests.swift | 6 ++ 5 files changed, 59 insertions(+), 73 deletions(-) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 2b18a5b8a4..200193f62a 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -290,63 +290,26 @@ public struct OpenAIDashboardBrowserCookieImporter { } } - private func tryChrome( + /// Generic cookie loader for any non-Safari browser (Chrome, Edge, Firefox, Brave, Arc, etc.). + /// SweetCookieKit handles engine-specific decryption internally. + private func tryBrowser( + _ browser: Browser, context: ImportContext, log: @escaping (String) -> Void, diagnostics: inout ImportDiagnostics) async -> ImportResult? { - // Chrome fallback: may trigger Keychain prompt. Only do this if Safari didn't match. do { let query = BrowserCookieQuery(domains: Self.cookieDomains) - let chromeSources = try Self.cookieClient.codexBarRecords( + let sources = try Self.cookieClient.codexBarRecords( matching: query, - in: .chrome) - for source in chromeSources { - let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - if cookies.isEmpty { - log("\(source.label) produced 0 HTTPCookies.") - continue - } - diagnostics.foundAnyCookies = true - log("Loaded \(cookies.count) cookies from \(source.label) (\(self.cookieSummary(cookies)))") - let candidate = Candidate(label: source.label, cookies: cookies) - if let match = await self.applyCandidate( - candidate, - context: context, - log: log, - diagnostics: &diagnostics) - { - return match - } - } - return nil - } catch let error as BrowserCookieError { - BrowserCookieAccessGate.recordIfNeeded(error) - if let hint = error.accessDeniedHint { - diagnostics.accessDeniedHints.append(hint) + in: browser) + guard !sources.isEmpty else { + log("\(browser.displayName) contained 0 matching records.") + return nil } - log("Chrome cookie load failed: \(error.localizedDescription)") - return nil - } catch { - log("Chrome cookie load failed: \(error.localizedDescription)") - return nil - } - } - - private func tryFirefox( - context: ImportContext, - log: @escaping (String) -> Void, - diagnostics: inout ImportDiagnostics) async -> ImportResult? - { - // Firefox fallback: no Keychain, but still only after Safari/Chrome. - do { - let query = BrowserCookieQuery(domains: Self.cookieDomains) - let firefoxSources = try Self.cookieClient.codexBarRecords( - matching: query, - in: .firefox) - for source in firefoxSources { + for source in sources { let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - if cookies.isEmpty { + guard !cookies.isEmpty else { log("\(source.label) produced 0 HTTPCookies.") continue } @@ -368,10 +331,10 @@ public struct OpenAIDashboardBrowserCookieImporter { if let hint = error.accessDeniedHint { diagnostics.accessDeniedHints.append(hint) } - log("Firefox cookie load failed: \(error.localizedDescription)") + log("\(browser.displayName) cookie load failed: \(error.localizedDescription)") return nil } catch { - log("Firefox cookie load failed: \(error.localizedDescription)") + log("\(browser.displayName) cookie load failed: \(error.localizedDescription)") return nil } } @@ -388,18 +351,14 @@ public struct OpenAIDashboardBrowserCookieImporter { context: context, log: log, diagnostics: &diagnostics) - case .chrome: - await self.tryChrome( - context: context, - log: log, - diagnostics: &diagnostics) - case .firefox: - await self.tryFirefox( + default: + // All non-Safari browsers (Chrome, Edge, Firefox, Brave, Arc, etc.) + // share the same cookie loading path via SweetCookieKit. + await self.tryBrowser( + source, context: context, log: log, diagnostics: &diagnostics) - default: - nil } } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index aaab9a483f..cec06f3669 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -630,7 +630,9 @@ public struct AntigravityStatusProbe: Sendable { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = context.timeout config.timeoutIntervalForResource = context.timeout + #if !os(Linux) config.waitsForConnectivity = false + #endif let delegate = LocalhostSessionDelegate() let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) @@ -654,14 +656,16 @@ enum LocalhostTrustPolicy { authenticationMethod: String, hasServerTrust: Bool) -> Bool { + #if !os(Linux) guard authenticationMethod == NSURLAuthenticationMethodServerTrust else { return false } + #endif let normalizedHost = host.lowercased() guard normalizedHost == "127.0.0.1" || normalizedHost == "localhost" else { return false } return hasServerTrust } } -private final class LocalhostSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { +private final class LocalhostSessionDelegate: NSObject { func data(for request: URLRequest, session: URLSession) async throws -> (Data, URLResponse) { let state = LocalhostSessionTaskState() return try await withTaskCancellationHandler { @@ -684,30 +688,36 @@ private final class LocalhostSessionDelegate: NSObject, URLSessionDelegate, URLS state.cancel() } } +} +extension LocalhostSessionDelegate: URLSessionDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - self.handle(challenge, completionHandler: completionHandler) + let result = self.challengeResult(challenge) + completionHandler(result.disposition, result.credential) } +} +extension LocalhostSessionDelegate: URLSessionTaskDelegate { func urlSession( _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - self.handle(challenge, completionHandler: completionHandler) + let result = self.challengeResult(challenge) + completionHandler(result.disposition, result.credential) } - private func handle( - _ challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + private func challengeResult(_ challenge: URLAuthenticationChallenge) -> ( + disposition: URLSession.AuthChallengeDisposition, + credential: URLCredential?) { - #if canImport(FoundationNetworking) - completionHandler(.performDefaultHandling, nil) + #if os(Linux) + return (.performDefaultHandling, nil) #else let protectionSpace = challenge.protectionSpace let trust = protectionSpace.serverTrust @@ -717,10 +727,9 @@ private final class LocalhostSessionDelegate: NSObject, URLSessionDelegate, URLS hasServerTrust: trust != nil), let trust else { - completionHandler(.performDefaultHandling, nil) - return + return (.performDefaultHandling, nil) } - completionHandler(.useCredential, URLCredential(trust: trust)) + return (.useCredential, URLCredential(trust: trust)) #endif } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 3d6c4a95bd..a715c1bd2f 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -21,7 +21,8 @@ public enum CodexProviderDescriptor { defaultEnabled: true, isPrimaryProvider: true, usesAccountFallback: true, - browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + browserCookieOrder: ProviderBrowserCookieDefaults.codexCookieImportOrder + ?? ProviderBrowserCookieDefaults.defaultImportOrder, dashboardURL: "https://chatgpt.com/codex/settings/usage", statusPageURL: "https://status.openai.com/"), branding: ProviderBranding( diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index f573978f3d..eb9f08e929 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -151,4 +151,15 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// Preserve the legacy Codex prompt behavior: prefer Safari/Chrome/Firefox before + /// probing additional Chromium variants that may trigger Safe Storage prompts. + public static var codexCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + let preferredPrefix: [Browser] = [.safari, .chrome, .firefox] + return preferredPrefix + Browser.defaultImportOrder.filter { !preferredPrefix.contains($0) } + #else + nil + #endif + } } diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index 09b222f256..e21c6233bd 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -4,6 +4,12 @@ import Testing struct BrowserCookieOrderStatusStringTests { #if os(macOS) + @Test + func `codex cookie import order keeps firefox ahead of extra chromium browsers`() { + let order = ProviderDefaults.metadata[.codex]?.browserCookieOrder ?? Browser.defaultImportOrder + #expect(Array(order.prefix(3)) == [.safari, .chrome, .firefox]) + } + @Test func `cursor no session includes browser login hint`() { let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder From b1de19de2d2c2e36986778fc6e3706f4be8645e9 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 12 Apr 2026 12:55:25 +0300 Subject: [PATCH 0162/1239] docs(triage): add issue labeling guide --- README.md | 1 + docs/ISSUE_LABELING.md | 199 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 docs/ISSUE_LABELING.md diff --git a/README.md b/README.md index 3f685d6fc8..78d34d3e0c 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it re ## Docs - Providers overview: [docs/providers.md](docs/providers.md) - Provider authoring: [docs/provider.md](docs/provider.md) +- Issue labeling guide: [docs/ISSUE_LABELING.md](docs/ISSUE_LABELING.md) - UI & icon notes: [docs/ui.md](docs/ui.md) - CLI reference: [docs/cli.md](docs/cli.md) - Architecture: [docs/architecture.md](docs/architecture.md) diff --git a/docs/ISSUE_LABELING.md b/docs/ISSUE_LABELING.md new file mode 100644 index 0000000000..8e0ba964a6 --- /dev/null +++ b/docs/ISSUE_LABELING.md @@ -0,0 +1,199 @@ +--- +summary: "Issue labeling policy for triage, prioritization, and backlog hygiene." +read_when: + - Triageing GitHub issues + - Adding or updating issue labels + - Organizing the backlog +--- + +# Issue labeling guide + +This repo uses labels to make the issue tracker easier to scan by: + +- **type** — what kind of issue is this? +- **priority** — how urgent is it? +- **area** — what subsystem is affected? +- **provider** — which provider/service is involved? +- **workflow state** — what kind of follow-up is needed? + +The goal is not to perfectly label everything. The goal is to make open issues easy to sort into: + +- what is broken now, +- what needs maintainer attention, +- what is accepted backlog, +- and what belongs to a specific provider or subsystem. + +## Labeling rules + +For most open issues, aim to apply: + +- **1 type label** +- **1 priority label** +- **1 workflow label** +- **1 area label** +- **0–1 provider labels** + +That means most issues should end up with **3–5 labels max**. + +## Type labels + +Use the existing GitHub-style labels: + +- `bug` — broken behavior, crash, mismatch, false negative, bad parsing, auth failure +- `enhancement` — feature request, UX improvement, support for a new workflow +- `documentation` — docs, onboarding, missing setup guidance +- `question` — only for issues that are primarily asking for clarification or support + +Avoid using `question` as a generic fallback when the issue is actually a bug or feature request. + +## Priority labels + +- `priority:high` — crashes, install failures, auth/account breakage, provider unusable, severe resource issues +- `priority:medium` — real issue or good feature request, but not urgent +- `priority:low` — minor polish, optional UX improvements, long-tail backlog + +## Workflow labels + +- `needs-triage` — new issue that has not been categorized yet +- `needs-repro` — needs logs, screenshots, exact steps, or a current repro +- `needs-design` — valid request, but needs a product/UX decision before implementation +- `blocked-upstream` — likely caused or limited by upstream provider behavior +- `accepted` — intentionally kept open as part of the backlog/roadmap + +## Area labels + +- `area:auth-keychain` — keychain prompts, login state, token refresh, account switching +- `area:install-distribution` — Homebrew, packaging, launch/install failures, binary detection +- `area:usage-accuracy` — usage %, reset windows, plan parsing, cost/token math +- `area:performance` — CPU, battery, memory, background sessions/process churn +- `area:ui-ux` — menu bar behavior, settings, copy, visual layout, interaction polish +- `area:widget` — widget registration, app groups, widget gallery visibility +- `area:docs-onboarding` — setup docs, onboarding docs, missing instructions +- `area:notifications` — threshold alerts, prompt waiting, quota notifications +- `area:export-integration` — Prometheus, HTTP server mode, external integrations +- `area:accounts` — multiple accounts, account discovery, account switching UX + +## Provider labels + +Only apply one when a provider is clearly the main subject: + +- `provider:claude` +- `provider:codex` +- `provider:cursor` +- `provider:copilot` +- `provider:gemini` +- `provider:alibaba` +- `provider:factory` +- `provider:antigravity` +- `provider:opencode` +- `provider:zai` +- `provider:openrouter` + +Not every issue needs a provider label. + +## Close-time labels + +These are mostly useful when resolving issues, not as backlog-organizing labels: + +- `duplicate` +- `invalid` +- `wontfix` +- `stale` + +## Recommended minimum viable label set + +If starting from a sparse tracker, add these first: + +### Priority +- `priority:high` +- `priority:medium` +- `priority:low` + +### Workflow +- `needs-triage` +- `needs-repro` +- `needs-design` +- `accepted` + +### Area +- `area:auth-keychain` +- `area:install-distribution` +- `area:usage-accuracy` +- `area:performance` +- `area:ui-ux` +- `area:widget` +- `area:docs-onboarding` + +### Provider +- `provider:claude` +- `provider:codex` +- `provider:cursor` +- `provider:copilot` + +This smaller set already gives most of the value. + +## Examples + +### Example 1 — severe Claude keychain issue +Issue: repeated Claude keychain prompts, user can’t keep the app running normally. + +Suggested labels: +- `bug` +- `priority:high` +- `area:auth-keychain` +- `provider:claude` + +### Example 2 — roadmap feature +Issue: multiple account support. + +Suggested labels: +- `enhancement` +- `priority:high` +- `area:accounts` +- `needs-design` + +### Example 3 — needs better repro +Issue: generic usage mismatch with unclear screenshots and no exact values. + +Suggested labels: +- `bug` +- `priority:medium` +- `area:usage-accuracy` +- `needs-repro` + +### Example 4 — accepted backlog UI request +Issue: show burn rate / pacing indicators. + +Suggested labels: +- `enhancement` +- `priority:medium` +- `area:usage-accuracy` +- `accepted` + +## Suggested rollout + +1. **Create the new labels** +2. **Backfill the top-priority open issues first** + - all `priority:high` bugs + - major roadmap items + - maintainer-triage issues +3. **Apply labels to new issues at intake** +4. **Backfill older backlog issues gradually** + +## Practical guidance + +- Prefer **fewer, clearer labels** over many vague labels. +- Do not label everything `question`. +- Do not use both `needs-repro` and `accepted` on the same issue unless there is a strong reason. +- If an issue is provider-specific, add the provider label early. +- If an issue is obviously real and intended to stay open, add `accepted` so it doesn’t look abandoned. + +## Current workflow-specific labels + +These already exist and should stay scoped to their current purpose: + +- `upstream-sync` +- `needs-review` +- `changes requested` + +They should not replace the general issue triage labels above. From fd19445056a18e6296f3cfce4eda76ff997526b6 Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Sun, 12 Apr 2026 17:07:27 +0530 Subject: [PATCH 0163/1239] Fix Codex cost scanner overcounting and cross-day undercounting (#698) * Fix Codex cost usage session replay * Harden Codex cost scanner refresh paths * Update Codex cache version test --------- Co-authored-by: Xu Xiang --- .../Vendored/CostUsage/CostUsageCache.swift | 3 +- .../CostUsageScanner+Timestamp.swift | 4 + .../Vendored/CostUsage/CostUsageScanner.swift | 745 +++++++-- Tests/CodexBarTests/CostUsageCacheTests.swift | 2 +- .../CostUsageScannerBreakdownTests.swift | 1476 +++++++++++++++++ 5 files changed, 2119 insertions(+), 111 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index d9cb7a5802..73ccb4c594 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -4,7 +4,7 @@ enum CostUsageCacheIO { private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: - 2 + 4 case .claude, .vertexai: 2 default: @@ -77,6 +77,7 @@ struct CostUsageFileUsage: Codable { var lastModel: String? var lastTotals: CostUsageCodexTotals? var sessionId: String? + var forkedFromId: String? var claudeRows: [CostUsageScanner.ClaudeUsageRow]? } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift index e7cda6310e..b8bf32153c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift @@ -26,6 +26,10 @@ private enum CostUsageTimestampParser { } extension CostUsageScanner { + static func dateFromTimestamp(_ text: String) -> Date? { + CostUsageTimestampParser.parseISO(text) + } + static func dayKeyFromTimestamp(_ text: String) -> String? { let bytes = Array(text.utf8) guard bytes.count >= 20 else { return nil } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a982934ae6..91de4e1cac 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1,6 +1,9 @@ import Foundation +// swiftlint:disable type_body_length enum CostUsageScanner { + private static let log = CodexBarLog.logger(LogCategories.tokenCost) + enum ClaudeLogProviderFilter { case all case vertexAIOnly @@ -37,6 +40,7 @@ enum CostUsageScanner { let lastModel: String? let lastTotals: CostUsageCodexTotals? let sessionId: String? + let forkedFromId: String? } private struct CodexScanState { @@ -44,6 +48,144 @@ enum CostUsageScanner { var seenFileIds: Set = [] } + private struct CodexTimestampedTotals { + let timestamp: String + let date: Date? + let totals: CostUsageCodexTotals + } + + private struct CodexScanResources { + let fileIndex: CodexSessionFileIndex + let inheritedResolver: CodexInheritedTotalsResolver + } + + private final class CodexSessionFileIndex { + private let files: [URL] + private let roots: [URL] + private var nextUnindexedFile = 0 + private var fileURLBySessionId: [String: URL] = [:] + private var missingSessionIds: Set = [] + + init(files: [URL], roots: [URL], cachedSessionFiles: [String: URL] = [:]) { + self.files = files + self.roots = roots + self.fileURLBySessionId = cachedSessionFiles + } + + func remember(fileURL: URL, sessionId: String?) { + guard let sessionId, !sessionId.isEmpty else { return } + self.fileURLBySessionId[sessionId] = fileURL + } + + func fileURL(for sessionId: String) -> URL? { + if let cached = self.fileURLBySessionId[sessionId] { + return cached + } + if self.missingSessionIds.contains(sessionId) { + return nil + } + + while self.nextUnindexedFile < self.files.count { + let fileURL = self.files[self.nextUnindexedFile] + self.nextUnindexedFile += 1 + guard let indexedSessionId = CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL) else { + continue + } + self.fileURLBySessionId[indexedSessionId] = fileURL + if indexedSessionId == sessionId { + return fileURL + } + } + + for root in self.roots { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { continue } + + while let fileURL = enumerator.nextObject() as? URL { + guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } + if self.files.contains(where: { $0.path == fileURL.path }) { + continue + } + guard let indexedSessionId = CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL) else { + continue + } + self.fileURLBySessionId[indexedSessionId] = fileURL + if indexedSessionId == sessionId { + return fileURL + } + } + } + + self.missingSessionIds.insert(sessionId) + return nil + } + } + + private final class CodexInheritedTotalsResolver { + private let fileIndex: CodexSessionFileIndex + private var snapshotsBySessionId: [String: [CodexTimestampedTotals]] = [:] + + init(fileIndex: CodexSessionFileIndex) { + self.fileIndex = fileIndex + } + + func inheritedTotals(for sessionId: String, atOrBefore cutoffTimestamp: String) -> CostUsageCodexTotals? { + guard !cutoffTimestamp.isEmpty else { return nil } + let cutoffDate = CostUsageScanner.dateFromTimestamp(cutoffTimestamp) + if cutoffDate == nil { + CostUsageScanner.log.warning( + "Codex cost usage could not parse fork timestamp; falling back to lexical comparison", + metadata: ["sessionId": sessionId, "timestamp": cutoffTimestamp]) + } + let snapshots = self.snapshots(for: sessionId) + var inherited: CostUsageCodexTotals? + for snapshot in snapshots { + let isAtOrBefore: Bool = if let snapshotDate = snapshot.date, let cutoffDate { + snapshotDate <= cutoffDate + } else { + snapshot.timestamp <= cutoffTimestamp + } + if isAtOrBefore { + inherited = snapshot.totals + } + } + return inherited + } + + private func snapshots(for sessionId: String) -> [CodexTimestampedTotals] { + if let cached = self.snapshotsBySessionId[sessionId] { + return cached + } + guard let fileURL = self.fileIndex.fileURL(for: sessionId) else { + CostUsageScanner.log.warning( + "Codex cost usage parent session file not found", + metadata: ["sessionId": sessionId]) + return [] + } + let parsed = CostUsageScanner.parseCodexTokenSnapshots(fileURL: fileURL) + guard let parsedSessionId = parsed.sessionId else { + CostUsageScanner.log.warning( + "Codex cost usage parent session missing session metadata", + metadata: ["sessionId": sessionId, "path": fileURL.path]) + return [] + } + if parsedSessionId != sessionId { + CostUsageScanner.log.warning( + "Codex cost usage parent session resolved to mismatched session id", + metadata: [ + "requestedSessionId": sessionId, + "resolvedSessionId": parsedSessionId, + "path": fileURL.path, + ]) + } + self.snapshotsBySessionId[parsedSessionId] = parsed.snapshots + return self.snapshotsBySessionId[sessionId] ?? [] + } + } + struct ClaudeParseResult { let days: [String: [String: [Int]]] let rows: [ClaudeUsageRow] @@ -157,21 +299,92 @@ enum CostUsageScanner { .appendingPathComponent("archived_sessions", isDirectory: true) } - private static func listCodexSessionFiles(root: URL, scanSinceKey: String, scanUntilKey: String) -> [URL] { + private static func listCodexSessionFiles( + root: URL, + scanSinceKey: String, + scanUntilKey: String, + includeRecursive: Bool) -> [URL] + { let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) let flat = self.listCodexSessionFilesFlat(root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) + let recursive = includeRecursive ? self.listCodexSessionFilesRecursive(root: root) : [] var seen: Set = [] var out: [URL] = [] - for item in partitioned + flat where !seen.contains(item.path) { + for item in partitioned + flat + recursive where !seen.contains(item.path) { seen.insert(item.path) out.append(item) } return out } + private static func cachedCodexSessionFiles( + cache: CostUsageCache, + range: CostUsageDayRange, + roots: [URL]) -> [URL] + { + cache.files.compactMap { path, usage in + let hasRelevantDay = usage.days.keys.contains { + CostUsageDayRange.isInRange(dayKey: $0, since: range.scanSinceKey, until: range.scanUntilKey) + } + guard hasRelevantDay else { return nil } + guard FileManager.default.fileExists(atPath: path) else { return nil } + let fileURL = URL(fileURLWithPath: path) + guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { return nil } + return fileURL + } + } + + private static func cachedCodexSessionIndex(cache: CostUsageCache, roots: [URL]) -> [String: URL] { + var out: [String: URL] = [:] + for (path, usage) in cache.files { + guard let sessionId = usage.sessionId, !sessionId.isEmpty else { continue } + guard FileManager.default.fileExists(atPath: path) else { continue } + let fileURL = URL(fileURLWithPath: path) + guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { continue } + out[sessionId] = fileURL + } + return out + } + + private static func codexRootsFingerprint(_ roots: [URL]) -> [String: Int64] { + var out: [String: Int64] = [:] + for root in roots { + out[root.standardizedFileURL.path] = 0 + } + return out + } + + private static func listCodexRecentlyModifiedFiles(root: URL, modifiedSince: Date) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { return [] } + + var out: [URL] = [] + while let fileURL = enumerator.nextObject() as? URL { + guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) + guard values?.isRegularFile == true else { continue } + guard let modifiedAt = values?.contentModificationDate, modifiedAt >= modifiedSince else { continue } + out.append(fileURL) + } + return out + } + + private static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { + let filePath = fileURL.standardizedFileURL.path + return roots.contains { root in + let rootPath = root.standardizedFileURL.path + if filePath == rootPath { return true } + let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + return filePath.hasPrefix(prefix) + } + } + private static func listCodexSessionFilesByDatePartition( root: URL, scanSinceKey: String, @@ -228,6 +441,22 @@ enum CostUsageScanner { return out } + private static func listCodexSessionFilesRecursive(root: URL) -> [URL] { + guard FileManager.default.fileExists(atPath: root.path) else { return [] } + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { return [] } + + var out: [URL] = [] + while let item = enumerator.nextObject() as? URL { + guard item.pathExtension.lowercased() == "jsonl" else { continue } + out.append(item) + } + return out + } + private static let codexFilenameDateRegex = try? NSRegularExpression(pattern: "(\\d{4}-\\d{2}-\\d{2})") private static func dayKeyFromFilename(_ filename: String) -> String? { @@ -247,16 +476,177 @@ enum CostUsageScanner { return String(describing: identifier) } + private struct CodexSessionMetadata { + let sessionId: String? + let forkedFromId: String? + let forkTimestamp: String? + } + + private static func parseCodexSessionIdentifier(fileURL: URL) -> String? { + self.parseCodexSessionMetadata(fileURL: fileURL)?.sessionId + } + + private static func parseCodexSessionMetadata(fileURL: URL) -> CodexSessionMetadata? { + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: fileURL) + } catch { + self.log.warning( + "Codex cost usage failed to open session file for session id parsing", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + return nil + } + defer { try? handle.close() } + + var buffer = Data() + let newline = Data([0x0A]) + + func parseSessionMetadata(from lineData: Data) -> CodexSessionMetadata? { + guard !lineData.isEmpty else { return nil } + guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] else { return nil } + guard obj["type"] as? String == "session_meta" else { return nil } + let payload = obj["payload"] as? [String: Any] + return CodexSessionMetadata( + sessionId: payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String, + forkedFromId: payload?["forked_from_id"] as? String + ?? payload?["forkedFromId"] as? String + ?? payload?["parent_session_id"] as? String + ?? payload?["parentSessionId"] as? String, + forkTimestamp: payload?["timestamp"] as? String + ?? obj["timestamp"] as? String) + } + + do { + while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty { + buffer.append(chunk) + while let newlineRange = buffer.range(of: newline) { + let lineData = buffer.subdata(in: 0.. ( + sessionId: String?, + snapshots: [CodexTimestampedTotals]) + { + var sessionId: String? + var previousTotals: CostUsageCodexTotals? + var snapshots: [CodexTimestampedTotals] = [] + var warnedAboutUnparsedTimestamp = false + + func parsedSnapshotDate(timestamp: String) -> Date? { + let date = Self.dateFromTimestamp(timestamp) + if date == nil, !warnedAboutUnparsedTimestamp { + warnedAboutUnparsedTimestamp = true + self.log.warning( + "Codex cost usage could not parse parent token snapshot timestamp; " + + "falling back to lexical comparison", + metadata: ["path": fileURL.path, "timestamp": timestamp]) + } + return date + } + + do { + _ = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 512 * 1024, + prefixBytes: 512 * 1024, + onLine: { line in + guard !line.bytes.isEmpty, !line.wasTruncated else { return } + guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] + else { return } + + if obj["type"] as? String == "session_meta" { + let payload = obj["payload"] as? [String: Any] + if sessionId == nil { + sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String + } + return + } + + guard obj["type"] as? String == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + guard payload["type"] as? String == "token_count" else { return } + guard let info = payload["info"] as? [String: Any] else { return } + guard let timestamp = obj["timestamp"] as? String else { return } + + func toInt(_ value: Any?) -> Int { + if let number = value as? NSNumber { return number.intValue } + return 0 + } + + if let total = info["total_token_usage"] as? [String: Any] { + let next = CostUsageCodexTotals( + input: toInt(total["input_tokens"]), + cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), + output: toInt(total["output_tokens"])) + previousTotals = next + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: next)) + } else if let last = info["last_token_usage"] as? [String: Any] { + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let next = CostUsageCodexTotals( + input: base.input + toInt(last["input_tokens"]), + cached: base.cached + toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"]), + output: base.output + toInt(last["output_tokens"])) + previousTotals = next + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: next)) + } + }) + } catch { + self.log.warning( + "Codex cost usage failed while scanning parent token snapshots", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + } + + return (sessionId, snapshots) + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length static func parseCodexFile( fileURL: URL, range: CostUsageDayRange, startOffset: Int64 = 0, initialModel: String? = nil, - initialTotals: CostUsageCodexTotals? = nil) -> CodexParseResult + initialTotals: CostUsageCodexTotals? = nil, + inheritedTotalsResolver: ((String, String) -> CostUsageCodexTotals?)? = nil) -> CodexParseResult { var currentModel = initialModel var previousTotals = initialTotals var sessionId: String? + var forkedFromId: String? + var inheritedTotals: CostUsageCodexTotals? + var remainingInheritedTotals: CostUsageCodexTotals? var days: [String: [String: [Int]]] = [:] @@ -277,116 +667,199 @@ enum CostUsageScanner { let maxLineBytes = 256 * 1024 let prefixBytes = 32 * 1024 - let parsedBytes = (try? CostUsageJsonl.scan( - fileURL: fileURL, - offset: startOffset, - maxLineBytes: maxLineBytes, - prefixBytes: prefixBytes, - onLine: { line in - guard !line.bytes.isEmpty else { return } - guard !line.wasTruncated else { return } - - guard - line.bytes.containsAscii(#""type":"event_msg""#) - || line.bytes.containsAscii(#""type":"turn_context""#) - || line.bytes.containsAscii(#""type":"session_meta""#) - else { return } - - if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { - return - } + if startOffset == 0, + let metadata = Self.parseCodexSessionMetadata(fileURL: fileURL) + { + sessionId = metadata.sessionId + forkedFromId = metadata.forkedFromId + if let forkedFromId = metadata.forkedFromId, + inheritedTotals == nil + { + let forkedAt = metadata.forkTimestamp ?? "" + inheritedTotals = inheritedTotalsResolver?(forkedFromId, forkedAt) + remainingInheritedTotals = inheritedTotals + } + } - guard - let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], - let type = obj["type"] as? String - else { return } + let parsedBytes: Int64 + do { + parsedBytes = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: startOffset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + onLine: { line in + guard !line.bytes.isEmpty else { return } + guard !line.wasTruncated else { return } + + guard + line.bytes.containsAscii(#""type":"event_msg""#) + || line.bytes.containsAscii(#""type":"turn_context""#) + || line.bytes.containsAscii(#""type":"session_meta""#) + else { return } + + if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { + return + } - if type == "session_meta" { - if sessionId == nil { + guard + let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + let type = obj["type"] as? String + else { return } + + if type == "session_meta" { let payload = obj["payload"] as? [String: Any] - sessionId = payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String + if sessionId == nil { + sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String + } + if forkedFromId == nil { + forkedFromId = payload?["forked_from_id"] as? String + ?? payload?["forkedFromId"] as? String + ?? payload?["parent_session_id"] as? String + ?? payload?["parentSessionId"] as? String + } + if inheritedTotals == nil, let forkedFromId { + let forkedAt = payload?["timestamp"] as? String + ?? obj["timestamp"] as? String + ?? "" + inheritedTotals = inheritedTotalsResolver?(forkedFromId, forkedAt) + remainingInheritedTotals = inheritedTotals + } + return } - return - } - - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } - if type == "turn_context" { - if let payload = obj["payload"] as? [String: Any] { - if let model = payload["model"] as? String { - currentModel = model - } else if let info = payload["info"] as? [String: Any], let model = info["model"] as? String { - currentModel = model + guard let tsText = obj["timestamp"] as? String else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + else { return } + + if type == "turn_context" { + if let payload = obj["payload"] as? [String: Any] { + if let model = payload["model"] as? String { + currentModel = model + } else if let info = payload["info"] as? [String: Any], + let model = info["model"] as? String + { + currentModel = model + } } + return } - return - } - guard type == "event_msg" else { return } - guard let payload = obj["payload"] as? [String: Any] else { return } - guard (payload["type"] as? String) == "token_count" else { return } + guard type == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + guard (payload["type"] as? String) == "token_count" else { return } - let info = payload["info"] as? [String: Any] - let modelFromInfo = info?["model"] as? String - ?? info?["model_name"] as? String - ?? payload["model"] as? String - ?? obj["model"] as? String - let model = modelFromInfo ?? currentModel ?? "gpt-5" + let info = payload["info"] as? [String: Any] + let modelFromInfo = info?["model"] as? String + ?? info?["model_name"] as? String + ?? payload["model"] as? String + ?? obj["model"] as? String + let model = modelFromInfo ?? currentModel ?? "gpt-5" - func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } - return 0 - } + func toInt(_ v: Any?) -> Int { + if let n = v as? NSNumber { return n.intValue } + return 0 + } - let total = (info?["total_token_usage"] as? [String: Any]) - let last = (info?["last_token_usage"] as? [String: Any]) - - var deltaInput = 0 - var deltaCached = 0 - var deltaOutput = 0 - - if let total { - let input = toInt(total["input_tokens"]) - let cached = toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]) - let output = toInt(total["output_tokens"]) - - let prev = previousTotals - deltaInput = max(0, input - (prev?.input ?? 0)) - deltaCached = max(0, cached - (prev?.cached ?? 0)) - deltaOutput = max(0, output - (prev?.output ?? 0)) - previousTotals = CostUsageCodexTotals(input: input, cached: cached, output: output) - } else if let last { - deltaInput = max(0, toInt(last["input_tokens"])) - deltaCached = max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])) - deltaOutput = max(0, toInt(last["output_tokens"])) - } else { - return - } + let total = (info?["total_token_usage"] as? [String: Any]) + let last = (info?["last_token_usage"] as? [String: Any]) + + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 + + func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { + guard var remaining = remainingInheritedTotals else { return rawDelta } + + let adjusted = CostUsageCodexTotals( + input: max(0, rawDelta.input - remaining.input), + cached: max(0, rawDelta.cached - remaining.cached), + output: max(0, rawDelta.output - remaining.output)) + + remaining.input = max(0, remaining.input - rawDelta.input) + remaining.cached = max(0, remaining.cached - rawDelta.cached) + remaining.output = max(0, remaining.output - rawDelta.output) + remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, + remaining.output == 0 + { + nil + } else { + remaining + } + + return adjusted + } - if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } - let cachedClamp = min(deltaCached, deltaInput) - add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput) - })) ?? startOffset + if let total { + let rawTotals = CostUsageCodexTotals( + input: toInt(total["input_tokens"]), + cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), + output: toInt(total["output_tokens"])) + + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + deltaInput = max(0, currentTotals.input - prev.input) + deltaCached = max(0, currentTotals.cached - prev.cached) + deltaOutput = max(0, currentTotals.output - prev.output) + previousTotals = currentTotals + remainingInheritedTotals = nil + } else if let last { + let rawDelta = CostUsageCodexTotals( + input: max(0, toInt(last["input_tokens"])), + cached: max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])), + output: max(0, toInt(last["output_tokens"]))) + let adjustedDelta = adjustedLastDelta(rawDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = CostUsageCodexTotals( + input: prev.input + deltaInput, + cached: prev.cached + deltaCached, + output: prev.output + deltaOutput) + } else { + return + } + + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } + let cachedClamp = min(deltaCached, deltaInput) + add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput) + }) + } catch { + self.log.warning( + "Codex cost usage failed while scanning session file", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + parsedBytes = startOffset + } return CodexParseResult( days: days, parsedBytes: parsedBytes, lastModel: currentModel, lastTotals: previousTotals, - sessionId: sessionId) + sessionId: sessionId, + forkedFromId: forkedFromId) } private static func scanCodexFile( fileURL: URL, range: CostUsageDayRange, cache: inout CostUsageCache, - state: inout CodexScanState) + state: inout CodexScanState, + resources: CodexScanResources) { let path = fileURL.path let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] @@ -432,6 +905,7 @@ enum CostUsageScanner { let startOffset = cached.parsedBytes ?? cached.size let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size && cached.lastTotals != nil + && cached.forkedFromId == nil if canIncremental { let delta = Self.parseCodexFile( fileURL: fileURL, @@ -458,9 +932,11 @@ enum CostUsageScanner { parsedBytes: delta.parsedBytes, lastModel: delta.lastModel, lastTotals: delta.lastTotals, - sessionId: sessionId) + sessionId: sessionId, + forkedFromId: delta.forkedFromId ?? cached.forkedFromId) if let sessionId { state.seenSessionIds.insert(sessionId) + resources.fileIndex.remember(fileURL: fileURL, sessionId: sessionId) } if let fileId { state.seenFileIds.insert(fileId) @@ -473,7 +949,10 @@ enum CostUsageScanner { Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) } - let parsed = Self.parseCodexFile(fileURL: fileURL, range: range) + let parsed = Self.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: resources.inheritedResolver.inheritedTotals(for:atOrBefore:)) let sessionId = parsed.sessionId ?? cached?.sessionId if let sessionId, state.seenSessionIds.contains(sessionId) { cache.files.removeValue(forKey: path) @@ -487,11 +966,13 @@ enum CostUsageScanner { parsedBytes: parsed.parsedBytes, lastModel: parsed.lastModel, lastTotals: parsed.lastTotals, - sessionId: sessionId) + sessionId: sessionId, + forkedFromId: parsed.forkedFromId) cache.files[path] = usage Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1) if let sessionId { state.seenSessionIds.insert(sessionId) + resources.fileIndex.remember(fileURL: fileURL, sessionId: sessionId) } if let fileId { state.seenFileIds.insert(fileId) @@ -503,34 +984,75 @@ enum CostUsageScanner { let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) - let shouldRefresh = refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs - - let roots = self.codexSessionsRoots(options: options) - var seenPaths: Set = [] - var files: [URL] = [] - for root in roots { - let rootFiles = Self.listCodexSessionFiles( - root: root, - scanSinceKey: range.scanSinceKey, - scanUntilKey: range.scanUntilKey) - for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { - seenPaths.insert(fileURL.path) - files.append(fileURL) - } - } - let filePathsInScan = Set(files.map(\.path)) + let shouldRefresh = options.forceRescan + || refreshMs == 0 + || cache.lastScanUnixMs == 0 + || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { if options.forceRescan { cache = CostUsageCache() } + + let roots = self.codexSessionsRoots(options: options) + let includeRecursive = options.forceRescan + let rootsFingerprint = Self.codexRootsFingerprint(roots) + let rootsChanged = cache.roots != nil && cache.roots != rootsFingerprint + let shouldRunColdCacheLookback = cache.files.isEmpty || rootsChanged + let coldCacheLookbackStart = Self.parseDayKey(range.scanSinceKey) + .map { Calendar.current.startOfDay(for: $0) } + var seenPaths: Set = [] + var files: [URL] = [] + for root in roots { + let rootFiles = Self.listCodexSessionFiles( + root: root, + scanSinceKey: range.scanSinceKey, + scanUntilKey: range.scanUntilKey, + includeRecursive: includeRecursive) + for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { + seenPaths.insert(fileURL.path) + files.append(fileURL) + } + + if !includeRecursive, shouldRunColdCacheLookback, let coldCacheLookbackStart { + let recentlyModifiedFiles = Self.listCodexRecentlyModifiedFiles( + root: root, + modifiedSince: coldCacheLookbackStart) + for fileURL in recentlyModifiedFiles.sorted(by: { $0.path < $1.path }) + where !seenPaths.contains(fileURL.path) + { + seenPaths.insert(fileURL.path) + files.append(fileURL) + } + } + } + + for fileURL in Self.cachedCodexSessionFiles(cache: cache, range: range, roots: roots) + .sorted(by: { $0.path < $1.path }) + where !seenPaths.contains(fileURL.path) + { + seenPaths.insert(fileURL.path) + files.append(fileURL) + } + + let filePathsInScan = Set(files.map(\.path)) + var scanState = CodexScanState() + let fileIndex = CodexSessionFileIndex( + files: files, + roots: includeRecursive ? [] : roots, + cachedSessionFiles: Self.cachedCodexSessionIndex(cache: cache, roots: roots)) + let inheritedResolver = CodexInheritedTotalsResolver(fileIndex: fileIndex) + let resources = CodexScanResources( + fileIndex: fileIndex, + inheritedResolver: inheritedResolver) for fileURL in files { Self.scanCodexFile( fileURL: fileURL, range: range, cache: &cache, - state: &scanState) + state: &scanState, + resources: resources) } for key in cache.files.keys where !filePathsInScan.contains(key) { @@ -541,6 +1063,7 @@ enum CostUsageScanner { } Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + cache.roots = rootsFingerprint cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) } @@ -643,6 +1166,7 @@ enum CostUsageScanner { lastModel: String? = nil, lastTotals: CostUsageCodexTotals? = nil, sessionId: String? = nil, + forkedFromId: String? = nil, claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage { CostUsageFileUsage( @@ -653,6 +1177,7 @@ enum CostUsageScanner { lastModel: lastModel, lastTotals: lastTotals, sessionId: sessionId, + forkedFromId: forkedFromId, claudeRows: claudeRows) } @@ -759,6 +1284,8 @@ enum CostUsageScanner { } } +// swiftlint:enable type_body_length + extension Data { func containsAscii(_ needle: String) -> Bool { guard let n = needle.data(using: .utf8) else { return false } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 4ad6391d9a..fddb9bcf16 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -10,7 +10,7 @@ struct CostUsageCacheTests { let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) - #expect(codexURL.lastPathComponent == "codex-v2.json") + #expect(codexURL.lastPathComponent == "codex-v4.json") #expect(claudeURL.lastPathComponent == "claude-v2.json") } } diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 32288c2ea4..197db118cf 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -2,6 +2,8 @@ import Foundation import Testing @testable import CodexBarCore +// swiftlint:disable file_length +// swiftlint:disable type_body_length struct CostUsageScannerBreakdownTests { @Test func `codex daily report parses token counts and caches`() throws { @@ -166,6 +168,1478 @@ struct CostUsageScannerBreakdownTests { #expect(second.data[0].totalTokens == 110) } + @Test + func `codex daily report includes long lived sessions stored under older date partitions`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let fileDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let reportDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + + _ = try env.writeCodexSessionFile( + day: fileDay, + filename: "rollout-2026-02-27T11-29-28-cross-day.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": "cross-day-session", + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: reportDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: reportDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + "model": model, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: reportDay, + until: reportDay, + now: reportDay, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 100) + #expect(report.data[0].outputTokens == 10) + #expect(report.data[0].totalTokens == 110) + } + + @Test + func `codex forked child subtracts parent totals at fork timestamp`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let parentTs0 = env.isoString(for: parentDay) + let parentTs1 = env.isoString(for: parentDay.addingTimeInterval(1)) + let parentTs2 = env.isoString(for: parentDay.addingTimeInterval(2)) + let parentTs3 = env.isoString(for: parentDay.addingTimeInterval(3)) + let childForkTs = env.isoString(for: parentDay.addingTimeInterval(2.5)) + let childTs1 = env.isoString(for: childDay.addingTimeInterval(1)) + let childTs2 = env.isoString(for: childDay.addingTimeInterval(2)) + + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent" + let childSessionId = "sess-child" + + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-27T11-29-28-\(parentSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": parentSessionId, + ], + ], + [ + "type": "turn_context", + "timestamp": parentTs0, + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": parentTs1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": parentTs2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": parentTs3, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 30, + "cached_input_tokens": 8, + "output_tokens": 3, + ], + "model": model, + ], + ], + ], + ])) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T11-30-27-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": childForkTs, + ], + ], + [ + "type": "turn_context", + "timestamp": childDay.ISO8601Format(), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": childTs1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": childTs2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 27, + "cached_input_tokens": 7, + "output_tokens": 4, + ], + "model": model, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.2-codex", + inputTokens: 7, + cachedInputTokens: 2, + outputTokens: 2) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 7) + #expect(report.data[0].outputTokens == 2) + #expect(report.data[0].totalTokens == 9) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `codex forked child subtracts inherited replay from last token usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let parentTs0 = env.isoString(for: parentDay) + let parentTs1 = env.isoString(for: parentDay.addingTimeInterval(1)) + let parentTs2 = env.isoString(for: parentDay.addingTimeInterval(2)) + let childTs1 = env.isoString(for: childDay.addingTimeInterval(1)) + let childTs2 = env.isoString(for: childDay.addingTimeInterval(2)) + let childTs3 = env.isoString(for: childDay.addingTimeInterval(3)) + + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-last" + let childSessionId = "sess-child-last" + let forkTs = env.isoString(for: parentDay.addingTimeInterval(2.5)) + + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-27T11-29-28-\(parentSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": parentSessionId, + ], + ], + [ + "type": "turn_context", + "timestamp": parentTs0, + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": parentTs1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": parentTs2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + ])) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T11-30-27-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTs, + ], + ], + [ + "type": "turn_context", + "timestamp": childDay.ISO8601Format(), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": childTs1, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": childTs2, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 3, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": childTs3, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 7, + "cached_input_tokens": 2, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 7, + cachedInputTokens: 2, + outputTokens: 2) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 7) + #expect(report.data[0].outputTokens == 2) + #expect(report.data[0].totalTokens == 9) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + // swiftlint:disable:next function_body_length + func `codex forked child ignores replayed parent prefix sequence`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-prefix" + let childSessionId = "sess-child-prefix" + let forkTs = env.isoString(for: parentDay.addingTimeInterval(5)) + + let parentEvents: [[String: Any]] = [ + [ + "type": "session_meta", + "payload": [ + "id": parentSessionId, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: parentDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(3)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 30, + "cached_input_tokens": 8, + "output_tokens": 3, + ], + "model": model, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-27T11-29-28-\(parentSessionId).jsonl", + contents: env.jsonl(parentEvents)) + + let childEvents: [[String: Any]] = [ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTs, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: childDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(3)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 30, + "cached_input_tokens": 8, + "output_tokens": 3, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(4)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 30, + "cached_input_tokens": 8, + "output_tokens": 3, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(5)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 35, + "cached_input_tokens": 9, + "output_tokens": 4, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(6)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 42, + "cached_input_tokens": 11, + "output_tokens": 5, + ], + "model": model, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T11-30-27-\(childSessionId).jsonl", + contents: env.jsonl(childEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.2-codex", + inputTokens: 12, + cachedInputTokens: 3, + outputTokens: 2) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 12) + #expect(report.data[0].outputTokens == 2) + #expect(report.data[0].totalTokens == 14) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + // swiftlint:disable:next function_body_length + func `codex forked child subtracts inherited replay even when session meta appears late`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-late-meta" + let childSessionId = "sess-child-late-meta" + let forkTs = env.isoString(for: parentDay.addingTimeInterval(5)) + + let parentEvents: [[String: Any]] = [ + [ + "type": "session_meta", + "payload": [ + "id": parentSessionId, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: parentDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(3)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 30, + "cached_input_tokens": 8, + "output_tokens": 3, + ], + "model": model, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-27T11-29-28-\(parentSessionId).jsonl", + contents: env.jsonl(parentEvents)) + + let childEvents: [[String: Any]] = [ + [ + "type": "turn_context", + "timestamp": env.isoString(for: childDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(3)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 30, + "cached_input_tokens": 8, + "output_tokens": 3, + ], + "model": model, + ], + ], + ], + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTs, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(4)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 35, + "cached_input_tokens": 9, + "output_tokens": 4, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(5)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 42, + "cached_input_tokens": 11, + "output_tokens": 5, + ], + "model": model, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T11-30-27-\(childSessionId).jsonl", + contents: env.jsonl(childEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.2-codex", + inputTokens: 12, + cachedInputTokens: 3, + outputTokens: 2) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 12) + #expect(report.data[0].outputTokens == 2) + #expect(report.data[0].totalTokens == 14) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `codex forked child resolves parent when parent session file is a symlink`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-symlink" + let childSessionId = "sess-child-symlink" + let forkTs = env.isoString(for: parentDay.addingTimeInterval(3)) + + let parentContents = try env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": parentSessionId, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: parentDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 1, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + ]) + + let parentTarget = env.root.appendingPathComponent("parent-target.jsonl", isDirectory: false) + try parentContents.write(to: parentTarget, atomically: true, encoding: .utf8) + + let comps = Calendar.current.dateComponents([.year, .month, .day], from: parentDay) + let parentDir = env.codexSessionsRoot + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + let parentLink = parentDir.appendingPathComponent( + "rollout-2026-02-27T11-29-28-\(parentSessionId).jsonl", + isDirectory: false) + try FileManager.default.createSymbolicLink(at: parentLink, withDestinationURL: parentTarget) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T11-30-27-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTs, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: childDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 27, + "cached_input_tokens": 7, + "output_tokens": 4, + ], + "model": model, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.2-codex", + inputTokens: 7, + cachedInputTokens: 2, + outputTokens: 2) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 7) + #expect(report.data[0].outputTokens == 2) + #expect(report.data[0].totalTokens == 9) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + // swiftlint:disable:next function_body_length + func `codex forked child resolves parent by exact session meta id`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let wantedParentSessionId = "sess-parent-exact" + let wrongParentSessionId = "sess-parent-exact-extra" + let childSessionId = "sess-child-exact" + let forkTs = env.isoString(for: parentDay.addingTimeInterval(3)) + + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-27T11-29-28-\(wrongParentSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": wrongParentSessionId, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: parentDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 1000, + "cached_input_tokens": 100, + "output_tokens": 100, + ], + "model": model, + ], + ], + ], + ])) + + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-27T11-29-29-\(wantedParentSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": wantedParentSessionId, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: parentDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: parentDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 30, + "cached_input_tokens": 8, + "output_tokens": 3, + ], + "model": model, + ], + ], + ], + ])) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T11-30-27-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": wantedParentSessionId, + "timestamp": forkTs, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: childDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 35, + "cached_input_tokens": 9, + "output_tokens": 4, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 42, + "cached_input_tokens": 11, + "output_tokens": 5, + ], + "model": model, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.2-codex", + inputTokens: 12, + cachedInputTokens: 3, + outputTokens: 2) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 12) + #expect(report.data[0].outputTokens == 2) + #expect(report.data[0].totalTokens == 14) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `codex forked child compares parent snapshots by parsed timestamp`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-timestamp" + let childSessionId = "sess-child-timestamp" + + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-27T11-29-28-\(parentSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": parentSessionId, + ], + ], + [ + "type": "turn_context", + "timestamp": "2026-02-27T23:59:58Z", + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": "2026-02-27T23:59:59Z", + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 20, + "cached_input_tokens": 5, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": "2026-02-28T00:00:01Z", + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + "model": model, + ], + ], + ], + ])) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T11-30-27-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": "2026-02-28T08:00:00+08:00", + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: childDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 25, + "cached_input_tokens": 7, + "output_tokens": 4, + ], + "model": model, + ], + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: childDay.addingTimeInterval(2)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 30, + "cached_input_tokens": 10, + "output_tokens": 6, + ], + "model": model, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 4) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 10) + #expect(report.data[0].outputTokens == 4) + #expect(report.data[0].totalTokens == 14) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `codex first refresh keeps unrelated archived sessions out of cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let reportDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let archivedDay = try env.makeLocalNoon(year: 2025, month: 1, day: 1) + let model = "openai/gpt-5.2-codex" + + _ = try env.writeCodexSessionFile( + day: reportDay, + filename: "rollout-2026-03-11T11-30-27-session-recent.jsonl", + contents: env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: reportDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: reportDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 7, + "cached_input_tokens": 2, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + ])) + + let archivedURL = try env.writeCodexArchivedSessionFile( + filename: "rollout-2025-01-01T12-00-00-session-archived.jsonl", + contents: env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: archivedDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: archivedDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 10, + "output_tokens": 5, + ], + "model": model, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: reportDay, + until: reportDay, + now: reportDay, + options: options) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + + #expect(report.data.count == 1) + #expect(cache.files.keys.contains { $0.hasSuffix("session-recent.jsonl") }) + #expect(!cache.files.keys.contains(archivedURL.path)) + } + + @Test + func `codex root switch reloads long lived sessions from older partitions`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + func writeSessionFile( + root: URL, + day: Date, + filename: String, + contents: String) throws -> URL + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = root + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent(filename, isDirectory: false) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + let fileDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let reportDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let otherSessionsRoot = env.root + .appendingPathComponent("other-codex-home", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: otherSessionsRoot, withIntermediateDirectories: true) + + let oldRootURL = try env.writeCodexSessionFile( + day: reportDay, + filename: "rollout-2026-03-11T11-30-27-session-old-root.jsonl", + contents: env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: reportDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: reportDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 7, + "cached_input_tokens": 2, + "output_tokens": 2, + ], + "model": model, + ], + ], + ], + ])) + + _ = try writeSessionFile( + root: otherSessionsRoot, + day: fileDay, + filename: "rollout-2026-02-27T11-30-27-session-new-root.jsonl", + contents: env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: reportDay), + "payload": [ + "model": model, + ], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: reportDay.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 10, + "cached_input_tokens": 5, + "output_tokens": 4, + ], + "model": model, + ], + ], + ], + ])) + + var firstOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + firstOptions.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: reportDay, + until: reportDay, + now: reportDay, + options: firstOptions) + + var secondOptions = CostUsageScanner.Options( + codexSessionsRoot: otherSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + secondOptions.refreshMinIntervalSeconds = 0 + + let secondReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: reportDay, + until: reportDay, + now: reportDay, + options: secondOptions) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + + #expect(secondReport.data.count == 1) + #expect(secondReport.data[0].inputTokens == 10) + #expect(secondReport.data[0].outputTokens == 4) + #expect(secondReport.data[0].totalTokens == 14) + #expect(!cache.files.keys.contains(oldRootURL.path)) + } + @Test func `claude daily report parses usage and caches`() throws { let env = try CostUsageTestEnvironment() @@ -341,3 +1815,5 @@ struct CostUsageScannerBreakdownTests { #expect(report.data[0].modelBreakdowns?.map(\.totalTokens) == [110, 40, 30, 15]) } } + +// swiftlint:enable type_body_length From 78d748eb4322d1c5948b58367591069a35f53832 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 12 Apr 2026 17:33:04 +0530 Subject: [PATCH 0164/1239] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfff543e5a..99aa118480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. - Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! - Antigravity: accept localhost TLS challenges when probing the local language server so usage/account info loads again instead of reporting `no working API port found` (#693, fixes #692). Thanks @anirudhvee! +- Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205! +- Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! - Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps still prefer user-managed and native Homebrew binaries when multiple installs exist. - Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! @@ -14,7 +16,9 @@ - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). - Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! +- Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps prefer `~/.claude/bin/claude`, then Homebrew, before the bundled `cmux.app` binary when shell-based resolution is unavailable. +- Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han! - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. - Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. From 889275762da6f35a1f1ae44ed7f5bcc0a926bf7f Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 12 Apr 2026 21:07:21 +0530 Subject: [PATCH 0165/1239] Enable usage pace visualization for OpenCode provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenCode to the provider allowlists in weeklyPace() and CLI paceLine() so the weekly bar shows reserve percentage, expected-usage marker, and "Lasts until reset" — matching the existing Codex and Claude experience. Co-Authored-By: Zachary --- Sources/CodexBar/UsageStore+HistoricalPace.swift | 2 +- Sources/CodexBarCLI/CLIRenderer.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index 9a1650a299..08b38a5b5f 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -5,7 +5,7 @@ import Foundation extension UsageStore { func supportsWeeklyPace(for provider: UsageProvider) -> Bool { switch provider { - case .codex, .claude: + case .codex, .claude, .opencode: true default: false diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 357c815bcc..b2c863fda6 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -295,7 +295,7 @@ enum CLIRenderer { useColor: Bool, now: Date) -> String? { - guard provider == .codex || provider == .claude else { return nil } + guard provider == .codex || provider == .claude || provider == .opencode else { return nil } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil } guard pace.expectedUsedPercent >= Self.paceMinimumExpectedPercent else { return nil } From 3a5496ab29dcdd2ef018d279ba931e21495d0e80 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 12 Apr 2026 21:10:39 +0530 Subject: [PATCH 0166/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99aa118480..5e8c708776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). - Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! +- OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary! - Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps prefer `~/.claude/bin/claude`, then Homebrew, before the bundled `cmux.app` binary when shell-based resolution is unavailable. - Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han! From 583f3198c0f7570cb3c85efdb5f33ca8d9a0221d Mon Sep 17 00:00:00 2001 From: Anirudh Venkatachalam <50367124+anirudhvee@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:33:09 -0700 Subject: [PATCH 0167/1239] fix: recognize Ollama __Secure-session cookies --- .../Providers/Ollama/OllamaUsageFetcher.swift | 1 + .../OllamaUsageFetcherTests.swift | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 815e12b65f..d42077fc18 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -9,6 +9,7 @@ import SweetCookieKit private let ollamaSessionCookieNames: Set = [ "session", + "__Secure-session", "ollama_session", "__Host-ollama_session", "__Secure-next-auth.session-token", diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index 4a71624a45..01e7e1c396 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -61,6 +61,14 @@ struct OllamaUsageFetcherTests { #expect(resolved?.contains("next-auth.session-token.0=abc") == true) } + @Test + func `manual mode accepts secure session cookie header`() throws { + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: "__Secure-session=abc; theme=dark", + manualCookieMode: true) + #expect(resolved?.contains("__Secure-session=abc") == true) + } + @Test func `retry policy retries only for auth errors`() { #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.invalidCredentials)) @@ -124,6 +132,16 @@ struct OllamaUsageFetcherTests { #expect(selected.sourceLabel == "Profile C") } + @Test + func `cookie selector accepts secure session cookie`() throws { + let candidate = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "__Secure-session", value: "auth")], + sourceLabel: "Profile D") + + let selected = try OllamaCookieImporter.selectSessionInfo(from: [candidate]) + #expect(selected.sourceLabel == "Profile D") + } + @Test func `cookie selector keeps recognized candidates in order`() throws { let first = OllamaCookieImporter.SessionInfo( From 631f1ef529eec7d7c11b392b09777c29c6eeecca Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 14 Apr 2026 00:57:22 +0530 Subject: [PATCH 0168/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8c708776..f3935217e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). - Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! +- Ollama: recognize `__Secure-session` cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee! - OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary! - Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps prefer `~/.claude/bin/claude`, then Homebrew, before the bundled `cmux.app` binary when shell-based resolution is unavailable. From d09593cff813bdd3fa6f2cf9dbbcc042ff28523f Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 17:01:44 +0530 Subject: [PATCH 0169/1239] Fix menu and Codex battery regressions --- Sources/CodexBar/ProviderRegistry.swift | 5 +- .../StatusItemController+HostedSubmenus.swift | 152 ++++++++++++++++++ .../CodexBar/StatusItemController+Menu.swift | 114 ++----------- ...tatusItemController+UsageHistoryMenu.swift | 11 +- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 3 + .../OpenAIWeb/OpenAIDashboardFetcher.swift | 5 + 6 files changed, 185 insertions(+), 105 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+HostedSubmenus.swift diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index e3934d2888..9f0613c339 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -115,7 +115,10 @@ struct ProviderRegistry { // Mac's Codex sessions, not as account-owned remote state. If we later want // account-scoped token history in the UI, that needs an explicit product decision and // presentation change so the two concepts are not conflated. - if provider == .codex, let managedHomePath = settings.activeManagedCodexRemoteHomePath { + if provider == .codex, + case .managedAccount = settings.codexActiveSource, + let managedHomePath = settings.activeManagedCodexRemoteHomePath + { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) } return env diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift new file mode 100644 index 0000000000..2b6f126781 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -0,0 +1,152 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + private static let hostedSubviewWidth: CGFloat = 310 + + func makeHostedSubviewPlaceholderMenu(chartID: String, provider: UsageProvider? = nil) -> NSMenu { + let submenu = NSMenu() + submenu.delegate = self + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = chartID + chartItem.toolTip = provider?.rawValue + submenu.addItem(chartItem) + return submenu + } + + func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu) { + guard let placeholder = menu.items.first, + menu.items.count == 1, + placeholder.view == nil, + let chartID = placeholder.representedObject as? String + else { + return + } + + let width = Self.hostedSubviewWidth + menu.removeAllItems() + + let didHydrate: Bool = switch chartID { + case Self.usageBreakdownChartID: + self.appendUsageBreakdownChartItem(to: menu, width: width) + case Self.creditsHistoryChartID: + self.appendCreditsHistoryChartItem(to: menu, width: width) + case Self.costHistoryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendCostHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.usageHistoryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendUsageHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + default: + false + } + + guard !didHydrate else { return } + + let unavailableItem = NSMenuItem(title: "No data available", action: nil, keyEquivalent: "") + unavailableItem.isEnabled = false + unavailableItem.representedObject = chartID + menu.addItem(unavailableItem) + } + + @discardableResult + func appendUsageBreakdownChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { + let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + guard !breakdown.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.usageBreakdownChartID + submenu.addItem(chartItem) + return true + } + + let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.usageBreakdownChartID + submenu.addItem(chartItem) + return true + } + + @discardableResult + func appendCreditsHistoryChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { + let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] + guard !breakdown.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.creditsHistoryChartID + submenu.addItem(chartItem) + return true + } + + let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.creditsHistoryChartID + submenu.addItem(chartItem) + return true + } + + @discardableResult + func appendCostHistoryChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return false } + guard !tokenSnapshot.daily.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.costHistoryChartID + submenu.addItem(chartItem) + return true + } + + let chartView = CostHistoryChartMenuView( + provider: provider, + daily: tokenSnapshot.daily, + totalCostUSD: tokenSnapshot.last30DaysCostUSD, + width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.costHistoryChartID + submenu.addItem(chartItem) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 332ba5ab41..661f3ccc70 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -11,6 +11,10 @@ extension StatusItemController { private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit private static let overviewRowIdentifierPrefix = "overviewRow-" private static let menuOpenRefreshDelay: Duration = .seconds(1.2) + static let usageBreakdownChartID = "usageBreakdownChart" + static let creditsHistoryChartID = "creditsHistoryChart" + static let costHistoryChartID = "costHistoryChart" + static let usageHistoryChartID = "usageHistoryChart" private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { _ = menu @@ -29,6 +33,7 @@ extension StatusItemController { func menuWillOpen(_ menu: NSMenu) { if self.isHostedSubviewMenu(menu) { + self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") @@ -1214,112 +1219,27 @@ extension StatusItemController { } private func makeUsageBreakdownSubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] - let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu + guard !(self.store.openAIDashboard?.usageBreakdown ?? []).isEmpty else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageBreakdownChartID) } private func makeCreditsHistorySubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] - let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "creditsHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "creditsHistoryChart" - submenu.addItem(chartItem) - return submenu + guard !(self.store.openAIDashboard?.dailyBreakdown ?? []).isEmpty else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.creditsHistoryChartID) } private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } - let width = Self.menuCardBaseWidth - guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } - guard !tokenSnapshot.daily.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "costHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = CostHistoryChartMenuView( - provider: provider, - daily: tokenSnapshot.daily, - totalCostUSD: tokenSnapshot.last30DaysCostUSD, - width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "costHistoryChart" - submenu.addItem(chartItem) - return submenu + guard self.store.tokenSnapshot(for: provider)?.daily.isEmpty == false else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.costHistoryChartID, provider: provider) } private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ - "usageBreakdownChart", - "creditsHistoryChart", - "costHistoryChart", - "usageHistoryChart", + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, + Self.costHistoryChartID, + Self.usageHistoryChartID, ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } @@ -1329,8 +1249,8 @@ extension StatusItemController { private func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ - "usageBreakdownChart", - "creditsHistoryChart", + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 3c4e7f4c8b..a2d5b9600b 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -35,13 +35,10 @@ extension StatusItemController { private func makeUsageHistorySubmenu(provider: UsageProvider) -> NSMenu? { guard self.store.supportsPlanUtilizationHistory(for: provider) else { return nil } guard !self.store.shouldHidePlanUtilizationMenuItem(for: provider) else { return nil } - let width: CGFloat = 310 - let submenu = NSMenu() - submenu.delegate = self - return self.appendUsageHistoryChartItem(to: submenu, provider: provider, width: width) ? submenu : nil + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageHistoryChartID, provider: provider) } - private func appendUsageHistoryChartItem( + func appendUsageHistoryChartItem( to submenu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool @@ -52,7 +49,7 @@ extension StatusItemController { if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() chartItem.isEnabled = false - chartItem.representedObject = "usageHistoryChart" + chartItem.representedObject = Self.usageHistoryChartID submenu.addItem(chartItem) return true } @@ -70,7 +67,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.view = hosting chartItem.isEnabled = false - chartItem.representedObject = "usageHistoryChart" + chartItem.representedObject = Self.usageHistoryChartID submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 08a15e4a6b..d062d63a0a 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -236,6 +236,8 @@ extension UsageStore { attachedAccountEmail: attachedAccountEmail) } + OpenAIDashboardFetcher.evictCachedWebView(accountEmail: attachedAccountEmail) + case .displayOnly: self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: true) self.openAIDashboard = dashboard @@ -244,6 +246,7 @@ extension UsageStore { self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardError = nil self.openAIDashboardRequiresLogin = false + OpenAIDashboardFetcher.evictCachedWebView(accountEmail: attachedAccountEmail) case .failClosed: self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: false) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 64ca88b8de..48af62a8e3 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -313,6 +313,11 @@ public struct OpenAIDashboardFetcher { OpenAIDashboardWebViewCache.shared.evictAll() } + public static func evictCachedWebView(accountEmail: String?) { + let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) + } + public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, From d8084bbfec3c249d8ab061882c4339662a87e89f Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 17:03:23 +0530 Subject: [PATCH 0170/1239] Add battery investigation instrumentation --- .../Providers/Codex/CodexSettingsStore.swift | 13 +- .../Codex/UsageStore+CodexAccountState.swift | 19 ++ Sources/CodexBar/SettingsStore.swift | 10 + .../StatusItemController+Actions.swift | 9 + .../StatusItemController+Animation.swift | 28 +++ .../StatusItemController+HostedSubmenus.swift | 31 +++ .../CodexBar/StatusItemController+Menu.swift | 55 +++++ .../StatusItemController+MenuDebug.swift | 104 ++++++++++ ...tatusItemController+UsageHistoryMenu.swift | 11 + Sources/CodexBar/StatusItemController.swift | 14 ++ Sources/CodexBar/UsageStore+OpenAIWeb.swift | 73 ++++++- Sources/CodexBar/UsageStore.swift | 49 +++++ .../Logging/AgentDebugLogger.swift | 47 +++++ .../OpenAIWeb/OpenAIDashboardFetcher.swift | 133 +++++++++++- .../OpenAIDashboardWebViewCache.swift | 109 +++++++++- .../CodexBarCore/PiSessionCostScanner.swift | 13 ++ .../Providers/Codex/CodexStatusProbe.swift | 51 ++++- Sources/CodexBarCore/UsageFetcher.swift | 195 +++++++++++++----- .../CostUsage/CostUsageScanner+Claude.swift | 13 ++ .../Vendored/CostUsage/CostUsageScanner.swift | 12 ++ 20 files changed, 910 insertions(+), 79 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+MenuDebug.swift create mode 100644 Sources/CodexBarCore/Logging/AgentDebugLogger.swift diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 91463ca4c7..e4cedf866f 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -182,7 +182,18 @@ extension SettingsStore { } var codexVisibleAccountProjection: CodexVisibleAccountProjection { - CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) + let startedAt = Date() + let projection = CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) + AgentDebugLogger.log( + "0.20 Codex visible account projection computed", + hypothesisId: "R", + location: "CodexSettingsStore.swift:codexVisibleAccountProjection", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "mainThread": Thread.isMainThread ? "1" : "0", + "visibleAccounts": String(projection.visibleAccounts.count), + ]) + return projection } var codexVisibleAccounts: [CodexVisibleAccount] { diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 03e42f23da..1c94fce11c 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -23,6 +23,15 @@ extension UsageStore { async { let refreshStartedAt = Date() + AgentDebugLogger.log( + "0.20 Codex account-scoped refresh started", + hypothesisId: "H", + location: "UsageStore+CodexAccountState.swift:refreshCodexAccountScopedState", + data: [ + "allowDisabled": allowDisabled ? "1" : "0", + "openAIWebEnabled": self.settings.codexCookieSource.isEnabled ? "1" : "0", + "resolvedSource": String(describing: self.settings.codexResolvedActiveSource), + ]) self.prepareRefreshState(for: .codex) if self.prepareCodexAccountScopedRefreshIfNeeded() { phaseDidChange?(.invalidated) @@ -51,6 +60,16 @@ extension UsageStore { self.persistWidgetSnapshot(reason: "codex-account-refresh") phaseDidChange?(.completed) + AgentDebugLogger.log( + "0.20 Codex account-scoped refresh completed", + hypothesisId: "H", + location: "UsageStore+CodexAccountState.swift:refreshCodexAccountScopedState", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(refreshStartedAt) * 1000)), + "hasCredits": self.credits == nil ? "0" : "1", + "hasDashboard": self.openAIDashboard == nil ? "0" : "1", + "dashboardRequiresLogin": self.openAIDashboardRequiresLogin ? "1" : "0", + ]) } @discardableResult diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 68ba707aa8..ff157a3081 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -168,6 +168,16 @@ final class SettingsStore { config: config, hadExistingConfig: hadExistingConfig) } + AgentDebugLogger.log( + "0.20 startup OpenAI web access decision", + hypothesisId: "A", + location: "SettingsStore.swift:init", + data: [ + "codexCookieSource": self.codexCookieSource.rawValue, + "openAIWebAccessEnabled": self.openAIWebAccessEnabled ? "1" : "0", + "hasStoredPreference": hasStoredOpenAIWebAccessPreference ? "1" : "0", + "refreshFrequency": self.refreshFrequency.rawValue, + ]) if Self.shouldBridgeSharedDefaults(for: userDefaults) { Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index b470e049c9..f002028a1e 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -5,6 +5,15 @@ extension StatusItemController { // MARK: - Actions reachable from menus func refreshStore(forceTokenUsage: Bool) { + AgentDebugLogger.log( + "0.20 status item requested refresh", + hypothesisId: "L", + location: "StatusItemController+Actions.swift:refreshStore", + data: [ + "forceTokenUsage": forceTokenUsage ? "1" : "0", + "openMenus": String(self.openMenus.count), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) Task { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: forceTokenUsage) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 7206412fdb..2e985da994 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -595,6 +595,19 @@ extension StatusItemController { let needsAnimation = self.needsMenuBarIconAnimation() if needsAnimation { if self.animationDriver == nil { + let primaryProvider = self.primaryProviderForUnifiedIcon() + AgentDebugLogger.log( + "0.20 menu bar animation driver started", + hypothesisId: "O", + location: "StatusItemController+Animation.swift:updateAnimationState", + data: [ + "primaryProvider": primaryProvider.rawValue, + "selectedMenuProvider": self.selectedMenuProvider?.rawValue ?? "nil", + "snapshotKnown": self.store.snapshot(for: primaryProvider) == nil ? "0" : "1", + "mergedIcons": self.shouldMergeIcons ? "1" : "0", + "enabledProviders": String(self.store.enabledProvidersForDisplay().count), + "refreshingProviders": String(self.store.refreshingProviders.count), + ]) if let forced = self.settings.debugLoadingPattern { self.animationPattern = forced } else if !LoadingPattern.allCases.contains(self.animationPattern) { @@ -611,6 +624,21 @@ extension StatusItemController { self.animationPhase = 0 } } else { + if self.animationDriver != nil { + let primaryProvider = self.primaryProviderForUnifiedIcon() + AgentDebugLogger.log( + "0.20 menu bar animation driver stopped", + hypothesisId: "O", + location: "StatusItemController+Animation.swift:updateAnimationState", + data: [ + "primaryProvider": primaryProvider.rawValue, + "selectedMenuProvider": self.selectedMenuProvider?.rawValue ?? "nil", + "snapshotKnown": self.store.snapshot(for: primaryProvider) == nil ? "0" : "1", + "mergedIcons": self.shouldMergeIcons ? "1" : "0", + "enabledProviders": String(self.store.enabledProvidersForDisplay().count), + "refreshingProviders": String(self.store.refreshingProviders.count), + ]) + } self.animationDriver?.stop() self.animationDriver = nil self.animationPhase = 0 diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 2b6f126781..e9a8f693c3 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -76,9 +76,19 @@ extension StatusItemController { let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) + let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + AgentDebugLogger.log( + "0.20 usage breakdown submenu rendered", + hypothesisId: "N", + location: "StatusItemController+HostedSubmenus.swift:appendUsageBreakdownChartItem", + data: [ + "days": String(breakdown.count), + "height": String(Int(size.height)), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) let chartItem = NSMenuItem() chartItem.view = hosting @@ -103,9 +113,19 @@ extension StatusItemController { let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) + let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + AgentDebugLogger.log( + "0.20 credits history submenu rendered", + hypothesisId: "N", + location: "StatusItemController+HostedSubmenus.swift:appendCreditsHistoryChartItem", + data: [ + "days": String(breakdown.count), + "height": String(Int(size.height)), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) let chartItem = NSMenuItem() chartItem.view = hosting @@ -138,9 +158,20 @@ extension StatusItemController { totalCostUSD: tokenSnapshot.last30DaysCostUSD, width: width) let hosting = MenuHostingView(rootView: chartView) + let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + AgentDebugLogger.log( + "0.20 cost history submenu rendered", + hypothesisId: "N", + location: "StatusItemController+HostedSubmenus.swift:appendCostHistoryChartItem", + data: [ + "provider": provider.rawValue, + "days": String(tokenSnapshot.daily.count), + "height": String(Int(size.height)), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 661f3ccc70..1a21f3f35f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -36,6 +36,14 @@ extension StatusItemController { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { + AgentDebugLogger.log( + "0.20 OpenAI web submenu opened", + hypothesisId: "N", + location: "StatusItemController+Menu.swift:menuWillOpen", + data: [ + "menuItems": String(menu.items.count), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } self.openMenus[ObjectIdentifier(menu)] = menu @@ -68,7 +76,19 @@ extension StatusItemController { self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } + AgentDebugLogger.log( + "0.20 top-level menu opened", + hypothesisId: "L", + location: "StatusItemController+Menu.swift:menuWillOpen", + data: [ + "provider": provider?.rawValue ?? "overview", + "didRefresh": didRefresh ? "1" : "0", + "menuItems": String(menu.items.count), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + "menuRefreshEnabled": Self.menuRefreshEnabled ? "1" : "0", + ]) self.openMenus[ObjectIdentifier(menu)] = menu + self.logOpenMenuStructure(menu, provider: provider) // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -101,6 +121,7 @@ extension StatusItemController { } private func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { + let startedAt = Date() let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -150,6 +171,17 @@ extension StatusItemController { currentProvider: currentProvider, menuWidth: menuWidth, openAIContext: openAIContext) + AgentDebugLogger.log( + "0.20 menu populated using smart update", + hypothesisId: "P", + location: "StatusItemController+Menu.swift:populateMenu", + data: [ + "provider": currentProvider.rawValue, + "menuItems": String(menu.items.count), + "enabledProviders": String(enabledProviders.count), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "hasOpenAIWebItems": openAIContext.hasOpenAIWebMenuItems ? "1" : "0", + ]) return } @@ -208,6 +240,29 @@ extension StatusItemController { } } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) + let cardMode = if isOverviewSelected { + "overview" + } else if tokenAccountDisplay?.showAll == true { + "token-accounts" + } else if openAIContext.hasOpenAIWebMenuItems { + "segmented-openai" + } else { + "single-card" + } + AgentDebugLogger.log( + "0.20 menu populated", + hypothesisId: "P", + location: "StatusItemController+Menu.swift:populateMenu", + data: [ + "provider": currentProvider.rawValue, + "cardMode": cardMode, + "menuItems": String(menu.items.count), + "enabledProviders": String(enabledProviders.count), + "hasUsageBreakdown": openAIContext.hasUsageBreakdown ? "1" : "0", + "hasCreditsHistory": openAIContext.hasCreditsHistory ? "1" : "0", + "hasCostHistory": openAIContext.hasCostHistory ? "1" : "0", + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) } /// Smart update: only rebuild content sections when switching providers (keep the switcher intact). diff --git a/Sources/CodexBar/StatusItemController+MenuDebug.swift b/Sources/CodexBar/StatusItemController+MenuDebug.swift new file mode 100644 index 0000000000..4e3fb2ba8f --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuDebug.swift @@ -0,0 +1,104 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + private struct MenuStructureSummary { + let itemCount: Int + var viewBackedItems = 0 + var menuCardItems = 0 + var switcherItems = 0 + var submenuItems = 0 + var chartSubviewMenus = 0 + var totalViews = 0 + var hostingViews = 0 + var buttonViews = 0 + var layerBackedViews = 0 + } + + func logOpenMenuStructure(_ menu: NSMenu, provider: UsageProvider?) { + Task { @MainActor [weak self, weak menu] in + guard let self, let menu else { return } + await Task.yield() + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + let summary = self.menuStructureSummary(for: menu) + AgentDebugLogger.log( + "0.20 top-level menu structure", + hypothesisId: "Q", + location: "StatusItemController+MenuDebug.swift:logOpenMenuStructure", + data: [ + "provider": provider?.rawValue ?? "overview", + "itemCount": String(summary.itemCount), + "viewBackedItems": String(summary.viewBackedItems), + "menuCardItems": String(summary.menuCardItems), + "switcherItems": String(summary.switcherItems), + "submenuItems": String(summary.submenuItems), + "chartSubviewMenus": String(summary.chartSubviewMenus), + "totalViews": String(summary.totalViews), + "hostingViews": String(summary.hostingViews), + "buttonViews": String(summary.buttonViews), + "layerBackedViews": String(summary.layerBackedViews), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + } + } + + private func menuStructureSummary(for menu: NSMenu) -> MenuStructureSummary { + var summary = MenuStructureSummary(itemCount: menu.items.count) + for item in menu.items { + if let represented = item.representedObject as? String, + represented.hasPrefix("menuCard") + { + summary.menuCardItems += 1 + } + if item.view != nil { + summary.viewBackedItems += 1 + } + if item.view is ProviderSwitcherView || + item.view is TokenAccountSwitcherView || + item.view is CodexAccountSwitcherView + { + summary.switcherItems += 1 + } + if let submenu = item.submenu { + summary.submenuItems += 1 + if self.isChartSubviewMenu(submenu) { + summary.chartSubviewMenus += 1 + } + } + if let view = item.view { + self.accumulateMenuViewSummary(from: view, into: &summary) + } + } + return summary + } + + private func accumulateMenuViewSummary(from view: NSView, into summary: inout MenuStructureSummary) { + summary.totalViews += 1 + let typeName = String(describing: type(of: view)) + if typeName.contains("HostingView") { + summary.hostingViews += 1 + } + if view is NSButton { + summary.buttonViews += 1 + } + if view.wantsLayer || view.layer != nil { + summary.layerBackedViews += 1 + } + for subview in view.subviews { + self.accumulateMenuViewSummary(from: subview, into: &summary) + } + } + + private func isChartSubviewMenu(_ menu: NSMenu) -> Bool { + let ids: Set = [ + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, + Self.costHistoryChartID, + Self.usageHistoryChartID, + ] + return menu.items.contains { item in + guard let id = item.representedObject as? String else { return false } + return ids.contains(id) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index a2d5b9600b..f9de969aca 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -43,6 +43,7 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { + let startedAt = Date() let histories = self.store.planUtilizationHistory(for: provider) let snapshot = self.store.snapshot(for: provider) @@ -63,6 +64,16 @@ extension StatusItemController { let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + AgentDebugLogger.log( + "0.20 usage history submenu rendered", + hypothesisId: "T", + location: "StatusItemController+UsageHistoryMenu.swift:appendUsageHistoryChartItem", + data: [ + "provider": provider.rawValue, + "seriesCount": String(histories.count), + "height": String(Int(size.height)), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index c818e13cca..f0384b3a70 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -431,6 +431,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateIcons() { + let startedAt = Date() // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil @@ -443,6 +444,19 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } self.updateAnimationState() self.updateBlinkingState() + if !self.openMenus.isEmpty || self.store.isRefreshing { + AgentDebugLogger.log( + "0.20 updateIcons completed during active menu/refresh", + hypothesisId: "S", + location: "StatusItemController.swift:updateIcons", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "openMenus": String(self.openMenus.count), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + "shouldMergeIcons": self.shouldMergeIcons ? "1" : "0", + "needsAnimation": self.needsMenuBarIconAnimation() ? "1" : "0", + ]) + } } /// Lazily retrieves or creates a status item for the given provider diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index d062d63a0a..fa436df14a 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -67,6 +67,18 @@ extension UsageStore { "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", ]) + AgentDebugLogger.log( + "0.20 stale OpenAI web request evaluated", + hypothesisId: "C", + location: "UsageStore+OpenAIWeb.swift:requestOpenAIDashboardRefreshIfStale", + data: [ + "reason": reason, + "force": forceRefresh ? "1" : "0", + "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", + "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", + "refreshIntervalSeconds": String(Int(refreshInterval)), + "lastUpdatedAgeSeconds": lastUpdatedAt.map { String(Int(now.timeIntervalSince($0))) } ?? "none", + ]) let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } @@ -99,6 +111,15 @@ extension UsageStore { authorityInput: authority.input, attachedAccountEmail: attachedAccountEmail, allowCodexUsageBackfill: allowCodexUsageBackfill) + OpenAIDashboardFetcher.evictCachedWebView(accountEmail: targetEmail) + AgentDebugLogger.log( + "0.20 evicts cached OpenAI webview after successful dashboard fetch", + hypothesisId: "K", + location: "UsageStore+OpenAIWeb.swift:applyOpenAIDashboard", + data: [ + "targetEmailKnown": targetEmail == nil ? "0" : "1", + "attachedEmailKnown": attachedAccountEmail == nil ? "0" : "1", + ]) } func applyOpenAIDashboardFailure( @@ -236,8 +257,6 @@ extension UsageStore { attachedAccountEmail: attachedAccountEmail) } - OpenAIDashboardFetcher.evictCachedWebView(accountEmail: attachedAccountEmail) - case .displayOnly: self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: true) self.openAIDashboard = dashboard @@ -246,7 +265,6 @@ extension UsageStore { self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardError = nil self.openAIDashboardRequiresLogin = false - OpenAIDashboardFetcher.evictCachedWebView(accountEmail: attachedAccountEmail) case .failClosed: self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: false) @@ -376,6 +394,26 @@ extension UsageStore { let now = Date() let minInterval = self.openAIWebRefreshIntervalSeconds() + let snapshotAgeSeconds = self.lastOpenAIDashboardSnapshot.map { Int(now.timeIntervalSince($0.updatedAt)) } + let willSkipBecauseSnapshotIsFresh = !force && + !self.openAIWebAccountDidChange && + self.lastOpenAIDashboardError == nil && + snapshotAgeSeconds != nil && + TimeInterval(snapshotAgeSeconds ?? 0) < minInterval + AgentDebugLogger.log( + "0.20 OpenAI web refresh gate evaluated", + hypothesisId: "C", + location: "UsageStore+OpenAIWeb.swift:refreshOpenAIDashboardIfNeeded", + data: [ + "force": force ? "1" : "0", + "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", + "accountDidChange": self.openAIWebAccountDidChange ? "1" : "0", + "hasLastError": self.lastOpenAIDashboardError == nil ? "0" : "1", + "snapshotAgeSeconds": snapshotAgeSeconds.map(String.init) ?? "none", + "refreshIntervalSeconds": String(Int(minInterval)), + "willSkipBecauseSnapshotIsFresh": willSkipBecauseSnapshotIsFresh ? "1" : "0", + "targetEmailKnown": targetEmail == nil ? "0" : "1", + ]) let refreshGate = OpenAIWebRefreshGateContext( force: force, accountDidChange: self.openAIWebAccountDidChange, @@ -385,6 +423,19 @@ extension UsageStore { now: now, refreshInterval: minInterval) if Self.shouldSkipOpenAIWebRefresh(refreshGate) { + if let lastAttemptAt = self.lastOpenAIDashboardAttemptAt, + now.timeIntervalSince(lastAttemptAt) < minInterval + { + AgentDebugLogger.log( + "0.20 OpenAI web refresh skipped because recent attempt is still within gate", + hypothesisId: "C", + location: "UsageStore+OpenAIWeb.swift:refreshOpenAIDashboardIfNeeded", + data: [ + "secondsSinceLastAttempt": String(Int(now.timeIntervalSince(lastAttemptAt))), + "refreshIntervalSeconds": String(Int(minInterval)), + "hasLastError": self.lastOpenAIDashboardError == nil ? "0" : "1", + ]) + } return } self.lastOpenAIDashboardAttemptAt = now @@ -515,6 +566,14 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { + AgentDebugLogger.log( + "0.20 OpenAI web refresh retried after missing dashboard data", + hypothesisId: "I", + location: "UsageStore+OpenAIWeb.swift:retryOpenAIDashboardAfterNoData", + data: [ + "bodyPresent": body.isEmpty ? "0" : "1", + "targetEmailKnown": context.targetEmail == nil ? "0" : "1", + ]) let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) @@ -574,6 +633,14 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { + AgentDebugLogger.log( + "0.20 OpenAI web refresh retried after login-required result", + hypothesisId: "I", + location: "UsageStore+OpenAIWeb.swift:retryOpenAIDashboardAfterLoginRequired", + data: [ + "targetEmailKnown": context.targetEmail == nil ? "0" : "1", + "cookieStatusKnown": latestCookieImportStatus == nil ? "0" : "1", + ]) let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0ecbc1820d..3c49ebc905 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -441,6 +441,18 @@ final class UsageStore { let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { + AgentDebugLogger.log( + "0.20 refresh loop scope", + hypothesisId: "D", + location: "UsageStore.swift:refresh", + data: [ + "phase": refreshPhase == .startup ? "startup" : "regular", + "enabledProviders": String(enabledProviders.count), + "allProviders": String(UsageProvider.allCases.count), + "statusChecksEnabled": self.settings.statusChecksEnabled ? "1" : "0", + "forceTokenUsage": forceTokenUsage ? "1" : "0", + "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", + ]) self.isRefreshing = true defer { self.isRefreshing = false @@ -480,6 +492,17 @@ final class UsageStore { "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", "phase": refreshPhase == .startup ? "startup" : "regular", ]) + AgentDebugLogger.log( + "0.20 main OpenAI web refresh policy evaluated", + hypothesisId: "C", + location: "UsageStore.swift:refresh", + data: [ + "allowed": shouldRefreshOpenAIWeb ? "1" : "0", + "accessEnabled": refreshPolicy.accessEnabled ? "1" : "0", + "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", + "force": refreshPolicy.force ? "1" : "0", + "phase": refreshPhase == .startup ? "startup" : "regular", + ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( @@ -1184,6 +1207,14 @@ extension UsageStore { let providerText = provider.rawValue self.tokenCostLogger .debug("cost usage start provider=\(providerText) force=\(force)") + AgentDebugLogger.log( + "0.20 token usage refresh started", + hypothesisId: "G", + location: "UsageStore.swift:refreshTokenUsage", + data: [ + "provider": providerText, + "force": force ? "1" : "0", + ]) do { let fetcher = self.costUsageFetcher @@ -1230,6 +1261,15 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") + AgentDebugLogger.log( + "0.20 token usage refresh succeeded", + hypothesisId: "G", + location: "UsageStore.swift:refreshTokenUsage", + data: [ + "provider": providerText, + "durationMs": String(Int(duration * 1000)), + "dailyEntries": String(snapshot.daily.count), + ]) } catch { if error is CancellationError { return } let duration = Date().timeIntervalSince(startedAt) @@ -1246,6 +1286,15 @@ extension UsageStore { } else { self.tokenErrors[provider] = nil } + AgentDebugLogger.log( + "0.20 token usage refresh failed", + hypothesisId: "G", + location: "UsageStore.swift:refreshTokenUsage", + data: [ + "provider": providerText, + "durationMs": String(Int(duration * 1000)), + "error": String(describing: error), + ]) } } } diff --git a/Sources/CodexBarCore/Logging/AgentDebugLogger.swift b/Sources/CodexBarCore/Logging/AgentDebugLogger.swift new file mode 100644 index 0000000000..e499d6e6d2 --- /dev/null +++ b/Sources/CodexBarCore/Logging/AgentDebugLogger.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum AgentDebugLogger { + private static let lock = NSLock() + private static let logURL = URL( + fileURLWithPath: "/Users/ratulsarna/Developer/staipete/CodexBar/.cursor/debug-4f7ebf.log") + private static let sessionID = "4f7ebf" + + public static func log( + _ message: String, + hypothesisId: String, + location: String, + runId: String = "baseline", + data: [String: String] = [:]) + { + let payload: [String: Any] = [ + "sessionId": self.sessionID, + "runId": runId, + "hypothesisId": hypothesisId, + "location": location, + "message": message, + "data": data, + "timestamp": Int(Date().timeIntervalSince1970 * 1000), + ] + guard JSONSerialization.isValidJSONObject(payload), + let raw = try? JSONSerialization.data(withJSONObject: payload), + var line = String(data: raw, encoding: .utf8) + else { + return + } + line.append("\n") + guard let encoded = line.data(using: .utf8) else { return } + + self.lock.lock() + defer { self.lock.unlock() } + + if FileManager.default.fileExists(atPath: self.logURL.path), + let handle = try? FileHandle(forWritingTo: self.logURL) + { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: encoded) + } else { + try? encoded.write(to: self.logURL, options: .atomic) + } + } +} diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 48af62a8e3..7972cab81c 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -42,6 +42,81 @@ public struct OpenAIDashboardFetcher { 0.001 } + private nonisolated static func logDashboardEvent( + _ message: String, + data: [String: String]) + { + AgentDebugLogger.log( + message, + hypothesisId: "J", + location: "OpenAIDashboardFetcher.swift:loadLatestDashboard", + data: data) + } + + private struct DashboardFetchTrace { + let startedAt: Date + let timeout: TimeInterval + var scrapeIterations = 0 + var routeReloadCount = 0 + var workspaceWaitCount = 0 + var creditsScrollWaitCount = 0 + var creditsHydrationWaitCount = 0 + var breakdownHydrationWaitCount = 0 + } + + private struct DashboardSnapshotComponents { + let scrape: ScrapeResult + let codeReview: Double? + let codeReviewLimit: RateWindow? + let events: [CreditEvent] + let breakdown: [OpenAIDashboardDailyBreakdown] + let usageBreakdown: [OpenAIDashboardDailyBreakdown] + let rateLimits: (primary: RateWindow?, secondary: RateWindow?) + let creditsRemaining: Double? + let accountPlan: String? + } + + private nonisolated static func emitDashboardSummary( + message: String, + trace: DashboardFetchTrace, + anyDashboardSignalAt: Date?, + extra: [String: String] = [:]) + { + var data: [String: String] = [ + "durationMs": String(Int(Date().timeIntervalSince(trace.startedAt) * 1000)), + "timeoutSeconds": String(Int(trace.timeout)), + "iterations": String(trace.scrapeIterations), + "routeReloads": String(trace.routeReloadCount), + "workspaceWaits": String(trace.workspaceWaitCount), + "creditsScrollWaits": String(trace.creditsScrollWaitCount), + "creditsHydrationWaits": String(trace.creditsHydrationWaitCount), + "breakdownHydrationWaits": String(trace.breakdownHydrationWaitCount), + "hadDashboardSignal": anyDashboardSignalAt == nil ? "0" : "1", + ] + for (key, value) in extra { + data[key] = value + } + Self.logDashboardEvent(message, data: data) + } + + private nonisolated static func makeDashboardSnapshot(_ components: DashboardSnapshotComponents) + -> OpenAIDashboardSnapshot + { + OpenAIDashboardSnapshot( + signedInEmail: components.scrape.signedInEmail, + codeReviewRemainingPercent: components.codeReview, + codeReviewLimit: components.codeReviewLimit, + creditEvents: components.events, + dailyBreakdown: components.breakdown, + usageBreakdown: components.usageBreakdown, + creditsPurchaseURL: components.scrape.creditsPurchaseURL, + primaryLimit: components.rateLimits.primary, + secondaryLimit: components.rateLimits.secondary, + creditsRemaining: components.creditsRemaining, + accountPlan: components.accountPlan, + updatedAt: Date()) + } + public struct ProbeResult: Sendable { public let href: String? public let loginRequired: Bool @@ -81,12 +156,14 @@ public struct OpenAIDashboardFetcher { timeout: timeout) } + // swiftlint:disable function_body_length public func loadLatestDashboard( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { + var trace = DashboardFetchTrace(startedAt: Date(), timeout: timeout) let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, @@ -105,8 +182,8 @@ public struct OpenAIDashboardFetcher { var creditsHeaderVisibleAt: Date? var lastUsageBreakdownDebug: String? var lastCreditsPurchaseURL: String? - while Date() < deadline { + trace.scrapeIterations += 1 let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody lastHTML = scrape.bodyHTML ?? lastHTML @@ -125,12 +202,14 @@ public struct OpenAIDashboardFetcher { } if scrape.workspacePicker { + trace.workspaceWaitCount += 1 try? await Task.sleep(for: .milliseconds(500)) continue } // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if let href = scrape.href, !Self.isUsageRoute(href) { + trace.routeReloadCount += 1 _ = webView.load(URLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue @@ -140,6 +219,14 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = scrape.bodyHTML { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) } + Self.emitDashboardSummary( + message: "0.20 OpenAI dashboard fetch returned login-required", + trace: trace, + anyDashboardSignalAt: anyDashboardSignalAt, + extra: [ + "cloudflare": scrape.cloudflareInterstitial ? "1" : "0", + "workspacePicker": scrape.workspacePicker ? "1" : "0", + ]) throw FetchError.loginRequired } @@ -147,6 +234,10 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = scrape.bodyHTML { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) } + Self.emitDashboardSummary( + message: "0.20 OpenAI dashboard fetch hit Cloudflare interstitial", + trace: trace, + anyDashboardSignalAt: anyDashboardSignalAt) throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") } @@ -187,6 +278,7 @@ public struct OpenAIDashboardFetcher { "inViewport=\(scrape.creditsHeaderInViewport) didScroll=\(scrape.didScrollToCredits) " + "rows=\(scrape.rows.count)") if scrape.didScrollToCredits { + trace.creditsScrollWaitCount += 1 log("scrollIntoView(Credits usage history) requested; waiting…") try? await Task.sleep(for: .milliseconds(600)) continue @@ -205,6 +297,7 @@ public struct OpenAIDashboardFetcher { creditsHeaderInViewport: scrape.creditsHeaderInViewport, didScrollToCredits: scrape.didScrollToCredits)) { + trace.creditsHydrationWaitCount += 1 try? await Task.sleep(for: .milliseconds(400)) continue } @@ -218,23 +311,31 @@ public struct OpenAIDashboardFetcher { if codeReview != nil, usageBreakdown.isEmpty { let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date()) if elapsed < 6 { + trace.breakdownHydrationWaitCount += 1 try? await Task.sleep(for: .milliseconds(400)) continue } } - return OpenAIDashboardSnapshot( - signedInEmail: scrape.signedInEmail, - codeReviewRemainingPercent: codeReview, + Self.emitDashboardSummary( + message: "0.20 OpenAI dashboard fetch succeeded", + trace: trace, + anyDashboardSignalAt: anyDashboardSignalAt, + extra: [ + "creditRows": String(events.count), + "usageBreakdownDays": String(usageBreakdown.count), + "hasRateLimits": hasUsageLimits ? "1" : "0", + "hasCreditsRemaining": creditsRemaining == nil ? "0" : "1", + ]) + return Self.makeDashboardSnapshot(.init( + scrape: scrape, + codeReview: codeReview, codeReviewLimit: codeReviewLimit, - creditEvents: events, - dailyBreakdown: breakdown, + events: events, + breakdown: breakdown, usageBreakdown: usageBreakdown, - creditsPurchaseURL: scrape.creditsPurchaseURL, - primaryLimit: rateLimits.primary, - secondaryLimit: rateLimits.secondary, + rateLimits: rateLimits, creditsRemaining: creditsRemaining, - accountPlan: accountPlan, - updatedAt: Date()) + accountPlan: accountPlan)) } try? await Task.sleep(for: .milliseconds(500)) @@ -243,9 +344,19 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = lastHTML { Self.writeDebugArtifacts(html: html, bodyText: lastBody, logger: log) } + Self.emitDashboardSummary( + message: "0.20 OpenAI dashboard fetch exhausted timeout without data", + trace: trace, + anyDashboardSignalAt: anyDashboardSignalAt, + extra: [ + "lastBodyPresent": lastBody == nil ? "0" : "1", + "lastHrefKnown": lastHref == nil ? "0" : "1", + ]) throw FetchError.noDashboardData(body: lastBody ?? "") } + // swiftlint:enable function_body_length + struct CreditsHistoryWaitContext { let now: Date let anyDashboardSignalAt: Date? diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index e7c9291e1a..a88d939732 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -34,6 +34,50 @@ final class OpenAIDashboardWebViewCache { private let idleTimeout: TimeInterval = 60 private let blankURL = URL(string: "about:blank")! + private func logCacheEvent( + _ message: String, + location: String, + data: [String: String]) + { + AgentDebugLogger.log( + message, + hypothesisId: "B", + location: location, + data: data) + } + + private func releaseCachedEntry(_ entry: Entry) { + entry.isBusy = false + entry.lastUsedAt = Date() + self.logCacheEvent( + "0.20 releases cached OpenAI webview to idle state", + location: "OpenAIDashboardWebViewCache.swift:releaseCached", + data: [ + "currentURLHost": entry.webView.url?.host ?? "none", + "currentURLPath": entry.webView.url?.path ?? "", + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "entriesAfterRelease": String(self.entries.count), + ]) + self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) + self.prune(now: Date()) + } + + private func releaseNewEntry(_ entry: Entry, webView: WKWebView) { + entry.isBusy = false + entry.lastUsedAt = Date() + self.logCacheEvent( + "0.20 releases newly created OpenAI webview to idle state", + location: "OpenAIDashboardWebViewCache.swift:releaseNew", + data: [ + "currentURLHost": entry.webView.url?.host ?? "none", + "currentURLPath": entry.webView.url?.path ?? "", + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "entriesAfterRelease": String(self.entries.count), + ]) + self.prepareCachedWebViewForIdle(webView, host: entry.host) + self.prune(now: Date()) + } + // MARK: - Testing support #if DEBUG @@ -129,6 +173,13 @@ final class OpenAIDashboardWebViewCache { try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { + self.logCacheEvent( + "0.20 temporary OpenAI webview timed out during prepare", + location: "OpenAIDashboardWebViewCache.swift:acquireBusyRetry", + data: [ + "existingEntries": String(self.entries.count), + "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), + ]) host.close() log("Temporary OpenAI WebView timed out; retrying with a fresh WebView.") return try await self.acquireTemporaryWebView( @@ -148,11 +199,27 @@ final class OpenAIDashboardWebViewCache { entry.isBusy = true entry.lastUsedAt = now + self.logCacheEvent( + "0.20 reuses cached OpenAI webview", + location: "OpenAIDashboardWebViewCache.swift:acquire", + data: [ + "existingEntries": String(self.entries.count), + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "usageURLHost": usageURL.host ?? "none", + "usageURLPath": usageURL.path, + ]) entry.host.show() do { try await self.prepareWebView(entry.webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { + self.logCacheEvent( + "0.20 cached OpenAI webview timed out during prepare", + location: "OpenAIDashboardWebViewCache.swift:acquireCachedRetry", + data: [ + "existingEntries": String(self.entries.count), + "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), + ]) entry.isBusy = false entry.lastUsedAt = Date() entry.host.close() @@ -178,22 +245,35 @@ final class OpenAIDashboardWebViewCache { log: log, release: { [weak self, weak entry] in guard let self, let entry else { return } - entry.isBusy = false - entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) - self.prune(now: Date()) + self.releaseCachedEntry(entry) }) } let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore) let entry = Entry(webView: webView, host: host, lastUsedAt: now, isBusy: true) self.entries[key] = entry + self.logCacheEvent( + "0.20 creates cached OpenAI webview", + location: "OpenAIDashboardWebViewCache.swift:createEntry", + data: [ + "existingEntries": String(self.entries.count), + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "usageURLHost": usageURL.host ?? "none", + "usageURLPath": usageURL.path, + ]) host.show() do { try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { + self.logCacheEvent( + "0.20 newly created OpenAI webview timed out during prepare", + location: "OpenAIDashboardWebViewCache.swift:createEntryRetry", + data: [ + "existingEntries": String(self.entries.count), + "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), + ]) self.entries.removeValue(forKey: key) host.close() log("OpenAI WebView timed out during prepare; retrying once.") @@ -215,10 +295,7 @@ final class OpenAIDashboardWebViewCache { log: log, release: { [weak self, weak entry] in guard let self, let entry else { return } - entry.isBusy = false - entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(webView, host: entry.host) - self.prune(now: Date()) + self.releaseNewEntry(entry, webView: webView) }) } @@ -232,6 +309,15 @@ final class OpenAIDashboardWebViewCache { func evictAll() { let existing = self.entries self.entries.removeAll() + if !existing.isEmpty { + self.logCacheEvent( + "0.20 evicts cached OpenAI webviews after refresh failure or reset", + location: "OpenAIDashboardWebViewCache.swift:evictAll", + data: [ + "evictedEntries": String(existing.count), + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + ]) + } for (_, entry) in existing { entry.host.close() } @@ -255,6 +341,13 @@ final class OpenAIDashboardWebViewCache { !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout } for (key, entry) in expired { + self.logCacheEvent( + "0.20 prunes cached OpenAI webview after shorter idle timeout", + location: "OpenAIDashboardWebViewCache.swift:prune", + data: [ + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "entriesBeforePrune": String(self.entries.count), + ]) entry.host.close() self.entries.removeValue(forKey: key) Self.log.debug("OpenAI webview pruned") diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 49c9e6998a..635a1ef2c8 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -58,6 +58,7 @@ enum PiSessionCostScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { + let startedAt = Date() let root = self.defaultPiSessionsRoot(options: options) let startCutoff = self.dateFromDayKey(range.scanSinceKey) ?? since let files = self.listPiSessionFiles(root: root, startCutoffLocal: startCutoff) @@ -85,6 +86,18 @@ enum PiSessionCostScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot) + AgentDebugLogger.log( + "0.20 PI session cost scanner refreshed cache", + hypothesisId: "G", + location: "PiSessionCostScanner.swift:loadDailyReport", + data: [ + "provider": provider.rawValue, + "fileCount": String(files.count), + "cacheFiles": String(cache.files.count), + "forceRescan": options.forceRescan ? "1" : "0", + "windowExpanded": windowExpanded ? "1" : "0", + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) } return self.buildReport(provider: provider, cache: cache, range: range) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index 3226674b3a..1baf368c05 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -76,6 +76,7 @@ public struct CodexStatusProbe { } public func fetch() async throws -> CodexStatusSnapshot { + let startedAt = Date() let env = self.environment let resolved = BinaryLocator.resolveCodexBinary(env: env, loginPATH: LoginShellPathCache.shared.current) ?? self.codexBinary @@ -83,19 +84,65 @@ public struct CodexStatusProbe { throw CodexStatusProbeError.codexNotInstalled } do { - return try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) + let snapshot = try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) + AgentDebugLogger.log( + "0.20 Codex status probe completed on first attempt", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + ]) + return snapshot } catch let error as CodexStatusProbeError { // Retry only parser-level flakes with a short second attempt. switch error { case .parseFailed: - return try await self.runAndParse( + AgentDebugLogger.log( + "0.20 Codex status probe retried after parser failure", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + ]) + let snapshot = try await self.runAndParse( binary: resolved, rows: 70, cols: 220, timeout: Self.parseRetryTimeoutSeconds) + AgentDebugLogger.log( + "0.20 Codex status probe completed after parser retry", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + ]) + return snapshot default: + AgentDebugLogger.log( + "0.20 Codex status probe failed", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + "error": String(describing: error), + ]) throw error } + } catch { + AgentDebugLogger.log( + "0.20 Codex status probe failed", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + "error": String(describing: error), + ]) + throw error } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index b560f005e9..c1c83355a9 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -566,56 +566,100 @@ public struct UsageFetcher: Sendable { } private func loadRPCUsage() async throws -> UsageSnapshot { + let startedAt = Date() let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } - - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - // The app-server answers on a single stdout stream, so keep requests - // serialized to avoid starving one reader when multiple awaiters race - // for the same pipe. - let limits = try await rpc.fetchRateLimits().rateLimits - let account = try? await rpc.fetchAccount() - - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: account?.account.flatMap { details in - if case let .chatgpt(email, _) = details { email } else { nil } - }, - accountOrganization: nil, - loginMethod: account?.account.flatMap { details in - if case let .chatgpt(_, plan) = details { plan } else { nil } - }) - guard let state = CodexReconciledState.fromCLI( - primary: Self.makeWindow(from: limits.primary), - secondary: Self.makeWindow(from: limits.secondary), - identity: identity) - else { - throw UsageError.noRateLimitsFound + do { + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + // The app-server answers on a single stdout stream, so keep requests + // serialized to avoid starving one reader when multiple awaiters race + // for the same pipe. + let limits = try await rpc.fetchRateLimits().rateLimits + let account = try? await rpc.fetchAccount() + + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account?.account.flatMap { details in + if case let .chatgpt(email, _) = details { email } else { nil } + }, + accountOrganization: nil, + loginMethod: account?.account.flatMap { details in + if case let .chatgpt(_, plan) = details { plan } else { nil } + }) + guard let state = CodexReconciledState.fromCLI( + primary: Self.makeWindow(from: limits.primary), + secondary: Self.makeWindow(from: limits.secondary), + identity: identity) + else { + throw UsageError.noRateLimitsFound + } + AgentDebugLogger.log( + "0.20 Codex RPC usage fetch succeeded", + hypothesisId: "E", + location: "UsageFetcher.swift:loadRPCUsage", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "accountEmailKnown": identity.accountEmail == nil ? "0" : "1", + ]) + return state.toUsageSnapshot() + } catch { + AgentDebugLogger.log( + "0.20 Codex RPC usage fetch failed", + hypothesisId: "E", + location: "UsageFetcher.swift:loadRPCUsage", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "error": String(describing: error), + ]) + throw error } - return state.toUsageSnapshot() } private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - let status = try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) - .fetch() - guard let state = CodexReconciledState.fromCLI( - primary: Self.makeTTYWindow( - percentLeft: status.fiveHourPercentLeft, - windowMinutes: 300, - resetsAt: status.fiveHourResetsAt, - resetDescription: status.fiveHourResetDescription), - secondary: Self.makeTTYWindow( - percentLeft: status.weeklyPercentLeft, - windowMinutes: 10080, - resetsAt: status.weeklyResetsAt, - resetDescription: status.weeklyResetDescription), - identity: nil) - else { - throw UsageError.noRateLimitsFound + let startedAt = Date() + do { + let status = try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + environment: self.environment) + .fetch() + guard let state = CodexReconciledState.fromCLI( + primary: Self.makeTTYWindow( + percentLeft: status.fiveHourPercentLeft, + windowMinutes: 300, + resetsAt: status.fiveHourResetsAt, + resetDescription: status.fiveHourResetDescription), + secondary: Self.makeTTYWindow( + percentLeft: status.weeklyPercentLeft, + windowMinutes: 10080, + resetsAt: status.weeklyResetsAt, + resetDescription: status.weeklyResetDescription), + identity: nil) + else { + throw UsageError.noRateLimitsFound + } + AgentDebugLogger.log( + "0.20 Codex TTY usage fetch succeeded", + hypothesisId: "F", + location: "UsageFetcher.swift:loadTTYUsage", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", + "hasFiveHour": status.fiveHourPercentLeft == nil ? "0" : "1", + "hasWeekly": status.weeklyPercentLeft == nil ? "0" : "1", + ]) + return state.toUsageSnapshot() + } catch { + AgentDebugLogger.log( + "0.20 Codex TTY usage fetch failed", + hypothesisId: "F", + location: "UsageFetcher.swift:loadTTYUsage", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", + "error": String(describing: error), + ]) + throw error } - return state.toUsageSnapshot() } public func loadLatestCredits(keepCLISessionsAlive: Bool = false) async throws -> CreditsSnapshot { @@ -625,22 +669,65 @@ public struct UsageFetcher: Sendable { } private func loadRPCCredits() async throws -> CreditsSnapshot { + let startedAt = Date() let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - let limits = try await rpc.fetchRateLimits().rateLimits - guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } - let remaining = Self.parseCredits(credits.balance) - return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + do { + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + let limits = try await rpc.fetchRateLimits().rateLimits + guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } + let remaining = Self.parseCredits(credits.balance) + AgentDebugLogger.log( + "0.20 Codex RPC credits fetch succeeded", + hypothesisId: "E", + location: "UsageFetcher.swift:loadRPCCredits", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "creditsKnown": "1", + ]) + return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + } catch { + AgentDebugLogger.log( + "0.20 Codex RPC credits fetch failed", + hypothesisId: "E", + location: "UsageFetcher.swift:loadRPCCredits", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "error": String(describing: error), + ]) + throw error + } } private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - let status = try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) - .fetch() - guard let credits = status.credits else { throw UsageError.noRateLimitsFound } - return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) + let startedAt = Date() + do { + let status = try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + environment: self.environment) + .fetch() + guard let credits = status.credits else { throw UsageError.noRateLimitsFound } + AgentDebugLogger.log( + "0.20 Codex TTY credits fetch succeeded", + hypothesisId: "F", + location: "UsageFetcher.swift:loadTTYCredits", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", + ]) + return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) + } catch { + AgentDebugLogger.log( + "0.20 Codex TTY credits fetch failed", + hypothesisId: "F", + location: "UsageFetcher.swift:loadTTYCredits", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", + "error": String(describing: error), + ]) + throw error + } } private func withFallback( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index c1ea28ed45..2e3f1c1a95 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -542,6 +542,7 @@ extension CostUsageScanner { var touched: Set = [] if shouldRefresh { + let startedAt = Date() if options.forceRescan { cache = CostUsageCache() } @@ -565,6 +566,18 @@ extension CostUsageScanner { Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) + AgentDebugLogger.log( + "0.20 Claude local cost scanner refreshed cache", + hypothesisId: "G", + location: "CostUsageScanner+Claude.swift:loadClaudeDaily", + data: [ + "provider": provider.rawValue, + "rootCount": String(roots.count), + "touchedFiles": String(touched.count), + "cacheFiles": String(cache.files.count), + "forceRescan": options.forceRescan ? "1" : "0", + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) } return Self.buildClaudeReportFromCache(cache: cache, range: range) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 91de4e1cac..c344fa6881 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -990,6 +990,7 @@ enum CostUsageScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { + let startedAt = Date() if options.forceRescan { cache = CostUsageCache() } @@ -1066,6 +1067,17 @@ enum CostUsageScanner { cache.roots = rootsFingerprint cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) + AgentDebugLogger.log( + "0.20 Codex local cost scanner refreshed cache", + hypothesisId: "G", + location: "CostUsageScanner.swift:loadCodexDaily", + data: [ + "fileCount": String(files.count), + "rootCount": String(roots.count), + "cacheFiles": String(cache.files.count), + "forceRescan": options.forceRescan ? "1" : "0", + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) } return Self.buildCodexReportFromCache(cache: cache, range: range) From eb7ca61bed3055c74c1d1f5118768f5f69933d7e Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 17:13:28 +0530 Subject: [PATCH 0171/1239] Limit background work to runnable providers --- Sources/CodexBar/UsageStore.swift | 17 +++++++++---- .../UsageStoreCoverageTests.swift | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3c49ebc905..1722ccb417 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -331,6 +331,11 @@ final class UsageStore { self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) } + /// Providers that should actually participate in background refresh/status/token work. + func enabledProvidersForBackgroundWork() -> [UsageProvider] { + self.enabledProviders() + } + var statusChecksEnabled: Bool { self.settings.statusChecksEnabled } @@ -436,8 +441,9 @@ final class UsageStore { guard !self.isRefreshing else { return } self.prepareRefreshState() let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup - let enabledProviders = self.enabledProvidersForDisplay() - let enabledProviderSet = Set(enabledProviders) + let displayEnabledProviders = self.enabledProvidersForDisplay() + let enabledProviderSet = Set(displayEnabledProviders) + let refreshProviders = self.enabledProvidersForBackgroundWork() let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { @@ -447,7 +453,8 @@ final class UsageStore { location: "UsageStore.swift:refresh", data: [ "phase": refreshPhase == .startup ? "startup" : "regular", - "enabledProviders": String(enabledProviders.count), + "enabledProviders": String(refreshProviders.count), + "displayEnabledProviders": String(displayEnabledProviders.count), "allProviders": String(UsageProvider.allCases.count), "statusChecksEnabled": self.settings.statusChecksEnabled ? "1" : "0", "forceTokenUsage": forceTokenUsage ? "1" : "0", @@ -462,7 +469,7 @@ final class UsageStore { self.clearDisabledProviderState(enabledProviders: enabledProviderSet) await withTaskGroup(of: Void.self) { group in - for provider in enabledProviders { + for provider in refreshProviders { group.addTask { await self.refreshProvider(provider) } group.addTask { await self.refreshStatus(provider) } } @@ -571,7 +578,7 @@ final class UsageStore { return } - let providers = self.enabledProvidersForDisplay() + let providers = self.enabledProvidersForBackgroundWork() self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in guard let self else { return } defer { diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 49dd6e681c..75213dfdcb 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -203,6 +203,31 @@ struct UsageStoreCoverageTests { #expect(store.statuses[.synthetic]?.indicator == .major) } + @Test + func backgroundWorkExcludesEnabledButUnavailableProviders() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-unavailable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + @Test func statusIndicatorsAndFailureGate() { #expect(!ProviderStatusIndicator.none.hasIssue) From a64539b4d464b0fbe59b96dc1c23d89d1c63af03 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 19:31:16 +0530 Subject: [PATCH 0172/1239] Refactor StatusItemController and SettingsStore by removing debug logging and enhancing icon rendering logic - Removed various debug logging statements from StatusItemController, SettingsStore, and UsageStore to streamline the code and reduce log clutter. - Introduced a mechanism to skip redundant icon rendering in StatusItemController, improving performance during icon updates. - Added a new property to track the last applied merged icon render signature for better management of icon updates. - Updated CodexAccountMenuDisplay to conform to Equatable for improved comparison capabilities. --- .../Providers/Codex/CodexSettingsStore.swift | 13 +-- .../Codex/UsageStore+CodexAccountState.swift | 19 ---- Sources/CodexBar/SettingsStore.swift | 10 -- .../StatusItemController+Actions.swift | 9 -- .../StatusItemController+Animation.swift | 107 ++++++++++++------ .../StatusItemController+HostedSubmenus.swift | 31 ----- .../CodexBar/StatusItemController+Menu.swift | 80 +++---------- .../StatusItemController+MenuDebug.swift | 104 ----------------- .../StatusItemController+MenuTypes.swift | 2 +- .../StatusItemController+SwitcherViews.swift | 4 +- ...tatusItemController+UsageHistoryMenu.swift | 11 -- Sources/CodexBar/StatusItemController.swift | 38 ++++--- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 69 ----------- Sources/CodexBar/UsageStore.swift | 67 +++-------- .../Logging/AgentDebugLogger.swift | 47 -------- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 85 -------------- .../OpenAIDashboardWebViewCache.swift | 85 -------------- .../CodexBarCore/PiSessionCostScanner.swift | 13 --- .../Providers/Codex/CodexStatusProbe.swift | 49 +------- Sources/CodexBarCore/UsageFetcher.swift | 72 ------------ .../CostUsage/CostUsageScanner+Claude.swift | 13 --- .../Vendored/CostUsage/CostUsageScanner.swift | 12 -- 22 files changed, 136 insertions(+), 804 deletions(-) delete mode 100644 Sources/CodexBar/StatusItemController+MenuDebug.swift delete mode 100644 Sources/CodexBarCore/Logging/AgentDebugLogger.swift diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index e4cedf866f..91463ca4c7 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -182,18 +182,7 @@ extension SettingsStore { } var codexVisibleAccountProjection: CodexVisibleAccountProjection { - let startedAt = Date() - let projection = CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) - AgentDebugLogger.log( - "0.20 Codex visible account projection computed", - hypothesisId: "R", - location: "CodexSettingsStore.swift:codexVisibleAccountProjection", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "mainThread": Thread.isMainThread ? "1" : "0", - "visibleAccounts": String(projection.visibleAccounts.count), - ]) - return projection + CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) } var codexVisibleAccounts: [CodexVisibleAccount] { diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 1c94fce11c..03e42f23da 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -23,15 +23,6 @@ extension UsageStore { async { let refreshStartedAt = Date() - AgentDebugLogger.log( - "0.20 Codex account-scoped refresh started", - hypothesisId: "H", - location: "UsageStore+CodexAccountState.swift:refreshCodexAccountScopedState", - data: [ - "allowDisabled": allowDisabled ? "1" : "0", - "openAIWebEnabled": self.settings.codexCookieSource.isEnabled ? "1" : "0", - "resolvedSource": String(describing: self.settings.codexResolvedActiveSource), - ]) self.prepareRefreshState(for: .codex) if self.prepareCodexAccountScopedRefreshIfNeeded() { phaseDidChange?(.invalidated) @@ -60,16 +51,6 @@ extension UsageStore { self.persistWidgetSnapshot(reason: "codex-account-refresh") phaseDidChange?(.completed) - AgentDebugLogger.log( - "0.20 Codex account-scoped refresh completed", - hypothesisId: "H", - location: "UsageStore+CodexAccountState.swift:refreshCodexAccountScopedState", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(refreshStartedAt) * 1000)), - "hasCredits": self.credits == nil ? "0" : "1", - "hasDashboard": self.openAIDashboard == nil ? "0" : "1", - "dashboardRequiresLogin": self.openAIDashboardRequiresLogin ? "1" : "0", - ]) } @discardableResult diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index ff157a3081..68ba707aa8 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -168,16 +168,6 @@ final class SettingsStore { config: config, hadExistingConfig: hadExistingConfig) } - AgentDebugLogger.log( - "0.20 startup OpenAI web access decision", - hypothesisId: "A", - location: "SettingsStore.swift:init", - data: [ - "codexCookieSource": self.codexCookieSource.rawValue, - "openAIWebAccessEnabled": self.openAIWebAccessEnabled ? "1" : "0", - "hasStoredPreference": hasStoredOpenAIWebAccessPreference ? "1" : "0", - "refreshFrequency": self.refreshFrequency.rawValue, - ]) if Self.shouldBridgeSharedDefaults(for: userDefaults) { Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index f002028a1e..b470e049c9 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -5,15 +5,6 @@ extension StatusItemController { // MARK: - Actions reachable from menus func refreshStore(forceTokenUsage: Bool) { - AgentDebugLogger.log( - "0.20 status item requested refresh", - hypothesisId: "L", - location: "StatusItemController+Actions.swift:refreshStore", - data: [ - "forceTokenUsage": forceTokenUsage ? "1" : "0", - "openMenus": String(self.openMenus.count), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - ]) Task { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: forceTokenUsage) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 2e985da994..29c592a25b 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -217,8 +217,10 @@ extension StatusItemController { return false } - func applyIcon(phase: Double?) { - guard let button = self.statusItem.button else { return } + // swiftlint:disable function_body_length + @discardableResult + func applyIcon(phase: Double?) -> Bool { + guard let button = self.statusItem.button else { return false } let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed @@ -299,31 +301,88 @@ extension StatusItemController { } return .none }() + let debugDouble: (Double?) -> String = { value in + guard let value else { return "nil" } + return String(format: "%.3f", value) + } if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) + let signature = [ + "mode=brandPercent", + "provider=\(primaryProvider.rawValue)", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "text=\(displayText ?? "nil")", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } self.setButtonImage(brand, for: button) self.setButtonTitle(displayText, for: button) - return + return false } if Self.shouldUseOpenRouterBrandFallback(provider: primaryProvider, snapshot: snapshot), let brand = ProviderBrandIcon.image(for: primaryProvider) { + let signature = [ + "mode=openRouterFallback", + "provider=\(primaryProvider.rawValue)", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } self.setButtonTitle(nil, for: button) self.setButtonImage( Self.brandImageWithStatusOverlay(brand: brand, statusIndicator: statusIndicator), for: button) - return + return false } self.setButtonTitle(nil, for: button) if let morphProgress { + let signature = [ + "mode=morph", + "provider=\(primaryProvider.rawValue)", + "morph=\(debugDouble(morphProgress))", + "status=\(statusIndicator.rawValue)", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage(image, for: button) } else { + let signature = [ + "mode=icon", + "provider=\(primaryProvider.rawValue)", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "blink=\(debugDouble(Double(blink)))", + "wiggle=\(debugDouble(Double(wiggle)))", + "tilt=\(debugDouble(Double(tilt)))", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } let image = IconRenderer.makeIcon( primaryRemaining: primary, weeklyRemaining: weekly, @@ -336,6 +395,18 @@ extension StatusItemController { statusIndicator: statusIndicator) self.setButtonImage(image, for: button) } + return false + } + + // swiftlint:enable function_body_length + + private func shouldSkipMergedIconRender(_ signature: String) -> Bool { + guard self.shouldMergeIcons else { return false } + if self.lastAppliedMergedIconRenderSignature == signature { + return true + } + self.lastAppliedMergedIconRenderSignature = signature + return false } func applyIcon(for provider: UsageProvider, phase: Double?) { @@ -595,19 +666,6 @@ extension StatusItemController { let needsAnimation = self.needsMenuBarIconAnimation() if needsAnimation { if self.animationDriver == nil { - let primaryProvider = self.primaryProviderForUnifiedIcon() - AgentDebugLogger.log( - "0.20 menu bar animation driver started", - hypothesisId: "O", - location: "StatusItemController+Animation.swift:updateAnimationState", - data: [ - "primaryProvider": primaryProvider.rawValue, - "selectedMenuProvider": self.selectedMenuProvider?.rawValue ?? "nil", - "snapshotKnown": self.store.snapshot(for: primaryProvider) == nil ? "0" : "1", - "mergedIcons": self.shouldMergeIcons ? "1" : "0", - "enabledProviders": String(self.store.enabledProvidersForDisplay().count), - "refreshingProviders": String(self.store.refreshingProviders.count), - ]) if let forced = self.settings.debugLoadingPattern { self.animationPattern = forced } else if !LoadingPattern.allCases.contains(self.animationPattern) { @@ -624,21 +682,6 @@ extension StatusItemController { self.animationPhase = 0 } } else { - if self.animationDriver != nil { - let primaryProvider = self.primaryProviderForUnifiedIcon() - AgentDebugLogger.log( - "0.20 menu bar animation driver stopped", - hypothesisId: "O", - location: "StatusItemController+Animation.swift:updateAnimationState", - data: [ - "primaryProvider": primaryProvider.rawValue, - "selectedMenuProvider": self.selectedMenuProvider?.rawValue ?? "nil", - "snapshotKnown": self.store.snapshot(for: primaryProvider) == nil ? "0" : "1", - "mergedIcons": self.shouldMergeIcons ? "1" : "0", - "enabledProviders": String(self.store.enabledProvidersForDisplay().count), - "refreshingProviders": String(self.store.refreshingProviders.count), - ]) - } self.animationDriver?.stop() self.animationDriver = nil self.animationPhase = 0 diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index e9a8f693c3..2b6f126781 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -76,19 +76,9 @@ extension StatusItemController { let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - AgentDebugLogger.log( - "0.20 usage breakdown submenu rendered", - hypothesisId: "N", - location: "StatusItemController+HostedSubmenus.swift:appendUsageBreakdownChartItem", - data: [ - "days": String(breakdown.count), - "height": String(Int(size.height)), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) let chartItem = NSMenuItem() chartItem.view = hosting @@ -113,19 +103,9 @@ extension StatusItemController { let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - AgentDebugLogger.log( - "0.20 credits history submenu rendered", - hypothesisId: "N", - location: "StatusItemController+HostedSubmenus.swift:appendCreditsHistoryChartItem", - data: [ - "days": String(breakdown.count), - "height": String(Int(size.height)), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) let chartItem = NSMenuItem() chartItem.view = hosting @@ -158,20 +138,9 @@ extension StatusItemController { totalCostUSD: tokenSnapshot.last30DaysCostUSD, width: width) let hosting = MenuHostingView(rootView: chartView) - let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - AgentDebugLogger.log( - "0.20 cost history submenu rendered", - hypothesisId: "N", - location: "StatusItemController+HostedSubmenus.swift:appendCostHistoryChartItem", - data: [ - "provider": provider.rawValue, - "days": String(tokenSnapshot.daily.count), - "height": String(Int(size.height)), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1a21f3f35f..88055ad39e 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -36,14 +36,6 @@ extension StatusItemController { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { - AgentDebugLogger.log( - "0.20 OpenAI web submenu opened", - hypothesisId: "N", - location: "StatusItemController+Menu.swift:menuWillOpen", - data: [ - "menuItems": String(menu.items.count), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - ]) self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } self.openMenus[ObjectIdentifier(menu)] = menu @@ -76,19 +68,7 @@ extension StatusItemController { self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } - AgentDebugLogger.log( - "0.20 top-level menu opened", - hypothesisId: "L", - location: "StatusItemController+Menu.swift:menuWillOpen", - data: [ - "provider": provider?.rawValue ?? "overview", - "didRefresh": didRefresh ? "1" : "0", - "menuItems": String(menu.items.count), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - "menuRefreshEnabled": Self.menuRefreshEnabled ? "1" : "0", - ]) self.openMenus[ObjectIdentifier(menu)] = menu - self.logOpenMenuStructure(menu, provider: provider) // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -121,7 +101,6 @@ extension StatusItemController { } private func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { - let startedAt = Date() let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -144,13 +123,15 @@ extension StatusItemController { currentProvider: currentProvider, showAllTokenAccounts: showAllTokenAccounts) - let hasAuxiliarySwitcher = menu.items.contains { - $0.view is TokenAccountSwitcherView || $0.view is CodexAccountSwitcherView - } + let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } + let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } let switcherProvidersMatch = enabledProviders == self.lastSwitcherProviders let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview + let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher + let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && + ((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher)) let canSmartUpdate = self.shouldMergeIcons && enabledProviders.count > 1 && !isOverviewSelected && @@ -158,9 +139,8 @@ extension StatusItemController { switcherUsageBarsShowUsedMatch && switcherSelectionMatches && switcherOverviewAvailabilityMatches && - codexAccountDisplay == nil && - tokenAccountDisplay == nil && - !hasAuxiliarySwitcher && + tokenSwitcherCompatible && + codexSwitcherCompatible && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView @@ -171,17 +151,6 @@ extension StatusItemController { currentProvider: currentProvider, menuWidth: menuWidth, openAIContext: openAIContext) - AgentDebugLogger.log( - "0.20 menu populated using smart update", - hypothesisId: "P", - location: "StatusItemController+Menu.swift:populateMenu", - data: [ - "provider": currentProvider.rawValue, - "menuItems": String(menu.items.count), - "enabledProviders": String(enabledProviders.count), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "hasOpenAIWebItems": openAIContext.hasOpenAIWebMenuItems ? "1" : "0", - ]) return } @@ -210,6 +179,7 @@ extension StatusItemController { self.lastSwitcherIncludesOverview = includesOverview } self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay) + self.lastCodexAccountMenuDisplay = codexAccountDisplay self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( currentProvider: currentProvider, @@ -240,29 +210,6 @@ extension StatusItemController { } } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) - let cardMode = if isOverviewSelected { - "overview" - } else if tokenAccountDisplay?.showAll == true { - "token-accounts" - } else if openAIContext.hasOpenAIWebMenuItems { - "segmented-openai" - } else { - "single-card" - } - AgentDebugLogger.log( - "0.20 menu populated", - hypothesisId: "P", - location: "StatusItemController+Menu.swift:populateMenu", - data: [ - "provider": currentProvider.rawValue, - "cardMode": cardMode, - "menuItems": String(menu.items.count), - "enabledProviders": String(enabledProviders.count), - "hasUsageBreakdown": openAIContext.hasUsageBreakdown ? "1" : "0", - "hasCreditsHistory": openAIContext.hasCreditsHistory ? "1" : "0", - "hasCostHistory": openAIContext.hasCostHistory ? "1" : "0", - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) } /// Smart update: only rebuild content sections when switching providers (keep the switcher intact). @@ -282,6 +229,11 @@ extension StatusItemController { if menu.items.first?.view is ProviderSwitcherView { contentStartIndex = 2 } + if menu.items.count > contentStartIndex, + menu.items[contentStartIndex].view is CodexAccountSwitcherView + { + contentStartIndex += 2 + } if menu.items.count > contentStartIndex, menu.items[contentStartIndex].view is TokenAccountSwitcherView { @@ -910,7 +862,11 @@ extension StatusItemController { guard !Task.isCancelled else { return } guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard !self.store.isRefreshing else { return } - guard self.menuNeedsDelayedRefreshRetry(for: menu) else { return } + let retryProviders = self.delayedRefreshRetryProviders(for: menu) + let retryStaleProviderCount = retryProviders.count { self.store.isStale(provider: $0) } + let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } + let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 + guard willRetryRefresh else { return } self.refreshStore(forceTokenUsage: false) } } diff --git a/Sources/CodexBar/StatusItemController+MenuDebug.swift b/Sources/CodexBar/StatusItemController+MenuDebug.swift deleted file mode 100644 index 4e3fb2ba8f..0000000000 --- a/Sources/CodexBar/StatusItemController+MenuDebug.swift +++ /dev/null @@ -1,104 +0,0 @@ -import AppKit -import CodexBarCore - -extension StatusItemController { - private struct MenuStructureSummary { - let itemCount: Int - var viewBackedItems = 0 - var menuCardItems = 0 - var switcherItems = 0 - var submenuItems = 0 - var chartSubviewMenus = 0 - var totalViews = 0 - var hostingViews = 0 - var buttonViews = 0 - var layerBackedViews = 0 - } - - func logOpenMenuStructure(_ menu: NSMenu, provider: UsageProvider?) { - Task { @MainActor [weak self, weak menu] in - guard let self, let menu else { return } - await Task.yield() - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - let summary = self.menuStructureSummary(for: menu) - AgentDebugLogger.log( - "0.20 top-level menu structure", - hypothesisId: "Q", - location: "StatusItemController+MenuDebug.swift:logOpenMenuStructure", - data: [ - "provider": provider?.rawValue ?? "overview", - "itemCount": String(summary.itemCount), - "viewBackedItems": String(summary.viewBackedItems), - "menuCardItems": String(summary.menuCardItems), - "switcherItems": String(summary.switcherItems), - "submenuItems": String(summary.submenuItems), - "chartSubviewMenus": String(summary.chartSubviewMenus), - "totalViews": String(summary.totalViews), - "hostingViews": String(summary.hostingViews), - "buttonViews": String(summary.buttonViews), - "layerBackedViews": String(summary.layerBackedViews), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - ]) - } - } - - private func menuStructureSummary(for menu: NSMenu) -> MenuStructureSummary { - var summary = MenuStructureSummary(itemCount: menu.items.count) - for item in menu.items { - if let represented = item.representedObject as? String, - represented.hasPrefix("menuCard") - { - summary.menuCardItems += 1 - } - if item.view != nil { - summary.viewBackedItems += 1 - } - if item.view is ProviderSwitcherView || - item.view is TokenAccountSwitcherView || - item.view is CodexAccountSwitcherView - { - summary.switcherItems += 1 - } - if let submenu = item.submenu { - summary.submenuItems += 1 - if self.isChartSubviewMenu(submenu) { - summary.chartSubviewMenus += 1 - } - } - if let view = item.view { - self.accumulateMenuViewSummary(from: view, into: &summary) - } - } - return summary - } - - private func accumulateMenuViewSummary(from view: NSView, into summary: inout MenuStructureSummary) { - summary.totalViews += 1 - let typeName = String(describing: type(of: view)) - if typeName.contains("HostingView") { - summary.hostingViews += 1 - } - if view is NSButton { - summary.buttonViews += 1 - } - if view.wantsLayer || view.layer != nil { - summary.layerBackedViews += 1 - } - for subview in view.subviews { - self.accumulateMenuViewSummary(from: subview, into: &summary) - } - } - - private func isChartSubviewMenu(_ menu: NSMenu) -> Bool { - let ids: Set = [ - Self.usageBreakdownChartID, - Self.creditsHistoryChartID, - Self.costHistoryChartID, - Self.usageHistoryChartID, - ] - return menu.items.contains { item in - guard let id = item.representedObject as? String else { return false } - return ids.contains(id) - } - } -} diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 18243f3c1d..3e8c6c3ccb 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -55,7 +55,7 @@ struct TokenAccountMenuDisplay { let showSwitcher: Bool } -struct CodexAccountMenuDisplay { +struct CodexAccountMenuDisplay: Equatable { let accounts: [CodexVisibleAccount] let activeVisibleAccountID: String? } diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 7bd0ea5129..74bf8fae73 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -942,7 +942,6 @@ final class CodexAccountSwitcherView: NSView { let second = Array(self.accounts.dropFirst(perRow)) return [first, second] }() - let stack = NSStackView() stack.orientation = .vertical stack.alignment = .centerX @@ -959,8 +958,9 @@ final class CodexAccountSwitcherView: NSView { let buttonWidth = self.buttonWidth(for: rowAccounts.count) for account in rowAccounts { + let title = self.compactButtonTitle(for: account, buttonWidth: buttonWidth) let button = PaddedToggleButton( - title: self.compactButtonTitle(for: account, buttonWidth: buttonWidth), + title: title, target: self, action: #selector(self.handleSelect)) button.identifier = NSUserInterfaceItemIdentifier(account.id) diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index f9de969aca..a2d5b9600b 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -43,7 +43,6 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { - let startedAt = Date() let histories = self.store.planUtilizationHistory(for: provider) let snapshot = self.store.snapshot(for: provider) @@ -64,16 +63,6 @@ extension StatusItemController { let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - AgentDebugLogger.log( - "0.20 usage history submenu rendered", - hypothesisId: "T", - location: "StatusItemController+UsageHistoryMenu.swift:appendUsageHistoryChartItem", - data: [ - "provider": provider.rawValue, - "seriesCount": String(histories.count), - "height": String(Int(size.height)), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index f0384b3a70..b77865f96f 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -120,6 +120,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastSwitcherProviders: [UsageProvider] = [] /// Tracks which switcher tab state was used for the current merged-menu switcher instance. var lastMergedSwitcherSelection: ProviderSwitcherSelection? + /// Tracks the visible Codex account switcher contents for merged-menu smart updates. + var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? + var lastAppliedMergedIconRenderSignature: String? let loginLogger = CodexBarLog.logger(LogCategories.login) var selectedMenuProvider: UsageProvider? { get { self.settings.selectedMenuProvider } @@ -279,6 +282,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private func wireBindings() { self.observeStoreChanges() + self.observeStoreIconChanges() self.observeDebugForceAnimation() self.observeSettingsChanges() self.observeUpdaterChanges() @@ -293,8 +297,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin guard let self else { return } self.observeStoreChanges() self.invalidateMenus() + } + } + } + + private func observeStoreIconChanges() { + withObservationTracking { + _ = self.store.iconObservationToken + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.observeStoreIconChanges() self.updateIcons() - self.updateBlinkingState() } } } @@ -431,12 +445,17 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateIcons() { - let startedAt = Date() // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil if self.shouldMergeIcons { - self.applyIcon(phase: phase) + let skippedMergedRender = self.applyIcon(phase: phase) + if skippedMergedRender, + let mergedMenu = self.mergedMenu, + self.statusItem.menu === mergedMenu + { + return + } self.attachMenus() } else { UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: phase) } @@ -444,19 +463,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } self.updateAnimationState() self.updateBlinkingState() - if !self.openMenus.isEmpty || self.store.isRefreshing { - AgentDebugLogger.log( - "0.20 updateIcons completed during active menu/refresh", - hypothesisId: "S", - location: "StatusItemController.swift:updateIcons", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "openMenus": String(self.openMenus.count), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - "shouldMergeIcons": self.shouldMergeIcons ? "1" : "0", - "needsAnimation": self.needsMenuBarIconAnimation() ? "1" : "0", - ]) - } } /// Lazily retrieves or creates a status item for the given provider diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index fa436df14a..88851717e5 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -67,18 +67,6 @@ extension UsageStore { "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", ]) - AgentDebugLogger.log( - "0.20 stale OpenAI web request evaluated", - hypothesisId: "C", - location: "UsageStore+OpenAIWeb.swift:requestOpenAIDashboardRefreshIfStale", - data: [ - "reason": reason, - "force": forceRefresh ? "1" : "0", - "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", - "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", - "refreshIntervalSeconds": String(Int(refreshInterval)), - "lastUpdatedAgeSeconds": lastUpdatedAt.map { String(Int(now.timeIntervalSince($0))) } ?? "none", - ]) let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } @@ -112,14 +100,6 @@ extension UsageStore { attachedAccountEmail: attachedAccountEmail, allowCodexUsageBackfill: allowCodexUsageBackfill) OpenAIDashboardFetcher.evictCachedWebView(accountEmail: targetEmail) - AgentDebugLogger.log( - "0.20 evicts cached OpenAI webview after successful dashboard fetch", - hypothesisId: "K", - location: "UsageStore+OpenAIWeb.swift:applyOpenAIDashboard", - data: [ - "targetEmailKnown": targetEmail == nil ? "0" : "1", - "attachedEmailKnown": attachedAccountEmail == nil ? "0" : "1", - ]) } func applyOpenAIDashboardFailure( @@ -394,26 +374,6 @@ extension UsageStore { let now = Date() let minInterval = self.openAIWebRefreshIntervalSeconds() - let snapshotAgeSeconds = self.lastOpenAIDashboardSnapshot.map { Int(now.timeIntervalSince($0.updatedAt)) } - let willSkipBecauseSnapshotIsFresh = !force && - !self.openAIWebAccountDidChange && - self.lastOpenAIDashboardError == nil && - snapshotAgeSeconds != nil && - TimeInterval(snapshotAgeSeconds ?? 0) < minInterval - AgentDebugLogger.log( - "0.20 OpenAI web refresh gate evaluated", - hypothesisId: "C", - location: "UsageStore+OpenAIWeb.swift:refreshOpenAIDashboardIfNeeded", - data: [ - "force": force ? "1" : "0", - "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", - "accountDidChange": self.openAIWebAccountDidChange ? "1" : "0", - "hasLastError": self.lastOpenAIDashboardError == nil ? "0" : "1", - "snapshotAgeSeconds": snapshotAgeSeconds.map(String.init) ?? "none", - "refreshIntervalSeconds": String(Int(minInterval)), - "willSkipBecauseSnapshotIsFresh": willSkipBecauseSnapshotIsFresh ? "1" : "0", - "targetEmailKnown": targetEmail == nil ? "0" : "1", - ]) let refreshGate = OpenAIWebRefreshGateContext( force: force, accountDidChange: self.openAIWebAccountDidChange, @@ -423,19 +383,6 @@ extension UsageStore { now: now, refreshInterval: minInterval) if Self.shouldSkipOpenAIWebRefresh(refreshGate) { - if let lastAttemptAt = self.lastOpenAIDashboardAttemptAt, - now.timeIntervalSince(lastAttemptAt) < minInterval - { - AgentDebugLogger.log( - "0.20 OpenAI web refresh skipped because recent attempt is still within gate", - hypothesisId: "C", - location: "UsageStore+OpenAIWeb.swift:refreshOpenAIDashboardIfNeeded", - data: [ - "secondsSinceLastAttempt": String(Int(now.timeIntervalSince(lastAttemptAt))), - "refreshIntervalSeconds": String(Int(minInterval)), - "hasLastError": self.lastOpenAIDashboardError == nil ? "0" : "1", - ]) - } return } self.lastOpenAIDashboardAttemptAt = now @@ -566,14 +513,6 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { - AgentDebugLogger.log( - "0.20 OpenAI web refresh retried after missing dashboard data", - hypothesisId: "I", - location: "UsageStore+OpenAIWeb.swift:retryOpenAIDashboardAfterNoData", - data: [ - "bodyPresent": body.isEmpty ? "0" : "1", - "targetEmailKnown": context.targetEmail == nil ? "0" : "1", - ]) let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) @@ -633,14 +572,6 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { - AgentDebugLogger.log( - "0.20 OpenAI web refresh retried after login-required result", - hypothesisId: "I", - location: "UsageStore+OpenAIWeb.swift:retryOpenAIDashboardAfterLoginRequired", - data: [ - "targetEmailKnown": context.targetEmail == nil ? "0" : "1", - "cookieStatusKnown": latestCookieImportStatus == nil ? "0" : "1", - ]) let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 1722ccb417..a679ebc174 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -22,8 +22,6 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin - _ = self.openAIDashboardCookieImportStatus - _ = self.openAIDashboardCookieImportDebugLog _ = self.versions _ = self.isRefreshing _ = self.refreshingProviders @@ -34,6 +32,21 @@ extension UsageStore { return 0 } + var iconObservationToken: Int { + _ = self.snapshots + _ = self.errors + _ = self.credits + _ = self.lastCreditsError + _ = self.openAIDashboard + _ = self.lastOpenAIDashboardError + _ = self.openAIDashboardRequiresLogin + _ = self.isRefreshing + _ = self.refreshingProviders + _ = self.statuses + _ = self.historicalPaceRevision + return 0 + } + func observeSettingsChanges() { withObservationTracking { _ = self.settings.refreshFrequency @@ -447,19 +460,6 @@ final class UsageStore { let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { - AgentDebugLogger.log( - "0.20 refresh loop scope", - hypothesisId: "D", - location: "UsageStore.swift:refresh", - data: [ - "phase": refreshPhase == .startup ? "startup" : "regular", - "enabledProviders": String(refreshProviders.count), - "displayEnabledProviders": String(displayEnabledProviders.count), - "allProviders": String(UsageProvider.allCases.count), - "statusChecksEnabled": self.settings.statusChecksEnabled ? "1" : "0", - "forceTokenUsage": forceTokenUsage ? "1" : "0", - "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", - ]) self.isRefreshing = true defer { self.isRefreshing = false @@ -499,17 +499,6 @@ final class UsageStore { "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", "phase": refreshPhase == .startup ? "startup" : "regular", ]) - AgentDebugLogger.log( - "0.20 main OpenAI web refresh policy evaluated", - hypothesisId: "C", - location: "UsageStore.swift:refresh", - data: [ - "allowed": shouldRefreshOpenAIWeb ? "1" : "0", - "accessEnabled": refreshPolicy.accessEnabled ? "1" : "0", - "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", - "force": refreshPolicy.force ? "1" : "0", - "phase": refreshPhase == .startup ? "startup" : "regular", - ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( @@ -1214,14 +1203,6 @@ extension UsageStore { let providerText = provider.rawValue self.tokenCostLogger .debug("cost usage start provider=\(providerText) force=\(force)") - AgentDebugLogger.log( - "0.20 token usage refresh started", - hypothesisId: "G", - location: "UsageStore.swift:refreshTokenUsage", - data: [ - "provider": providerText, - "force": force ? "1" : "0", - ]) do { let fetcher = self.costUsageFetcher @@ -1268,15 +1249,6 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") - AgentDebugLogger.log( - "0.20 token usage refresh succeeded", - hypothesisId: "G", - location: "UsageStore.swift:refreshTokenUsage", - data: [ - "provider": providerText, - "durationMs": String(Int(duration * 1000)), - "dailyEntries": String(snapshot.daily.count), - ]) } catch { if error is CancellationError { return } let duration = Date().timeIntervalSince(startedAt) @@ -1293,15 +1265,6 @@ extension UsageStore { } else { self.tokenErrors[provider] = nil } - AgentDebugLogger.log( - "0.20 token usage refresh failed", - hypothesisId: "G", - location: "UsageStore.swift:refreshTokenUsage", - data: [ - "provider": providerText, - "durationMs": String(Int(duration * 1000)), - "error": String(describing: error), - ]) } } } diff --git a/Sources/CodexBarCore/Logging/AgentDebugLogger.swift b/Sources/CodexBarCore/Logging/AgentDebugLogger.swift deleted file mode 100644 index e499d6e6d2..0000000000 --- a/Sources/CodexBarCore/Logging/AgentDebugLogger.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -public enum AgentDebugLogger { - private static let lock = NSLock() - private static let logURL = URL( - fileURLWithPath: "/Users/ratulsarna/Developer/staipete/CodexBar/.cursor/debug-4f7ebf.log") - private static let sessionID = "4f7ebf" - - public static func log( - _ message: String, - hypothesisId: String, - location: String, - runId: String = "baseline", - data: [String: String] = [:]) - { - let payload: [String: Any] = [ - "sessionId": self.sessionID, - "runId": runId, - "hypothesisId": hypothesisId, - "location": location, - "message": message, - "data": data, - "timestamp": Int(Date().timeIntervalSince1970 * 1000), - ] - guard JSONSerialization.isValidJSONObject(payload), - let raw = try? JSONSerialization.data(withJSONObject: payload), - var line = String(data: raw, encoding: .utf8) - else { - return - } - line.append("\n") - guard let encoded = line.data(using: .utf8) else { return } - - self.lock.lock() - defer { self.lock.unlock() } - - if FileManager.default.fileExists(atPath: self.logURL.path), - let handle = try? FileHandle(forWritingTo: self.logURL) - { - defer { try? handle.close() } - _ = try? handle.seekToEnd() - try? handle.write(contentsOf: encoded) - } else { - try? encoded.write(to: self.logURL, options: .atomic) - } - } -} diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 7972cab81c..d83ba7a72c 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -42,28 +42,6 @@ public struct OpenAIDashboardFetcher { 0.001 } - private nonisolated static func logDashboardEvent( - _ message: String, - data: [String: String]) - { - AgentDebugLogger.log( - message, - hypothesisId: "J", - location: "OpenAIDashboardFetcher.swift:loadLatestDashboard", - data: data) - } - - private struct DashboardFetchTrace { - let startedAt: Date - let timeout: TimeInterval - var scrapeIterations = 0 - var routeReloadCount = 0 - var workspaceWaitCount = 0 - var creditsScrollWaitCount = 0 - var creditsHydrationWaitCount = 0 - var breakdownHydrationWaitCount = 0 - } - private struct DashboardSnapshotComponents { let scrape: ScrapeResult let codeReview: Double? @@ -76,29 +54,6 @@ public struct OpenAIDashboardFetcher { let accountPlan: String? } - private nonisolated static func emitDashboardSummary( - message: String, - trace: DashboardFetchTrace, - anyDashboardSignalAt: Date?, - extra: [String: String] = [:]) - { - var data: [String: String] = [ - "durationMs": String(Int(Date().timeIntervalSince(trace.startedAt) * 1000)), - "timeoutSeconds": String(Int(trace.timeout)), - "iterations": String(trace.scrapeIterations), - "routeReloads": String(trace.routeReloadCount), - "workspaceWaits": String(trace.workspaceWaitCount), - "creditsScrollWaits": String(trace.creditsScrollWaitCount), - "creditsHydrationWaits": String(trace.creditsHydrationWaitCount), - "breakdownHydrationWaits": String(trace.breakdownHydrationWaitCount), - "hadDashboardSignal": anyDashboardSignalAt == nil ? "0" : "1", - ] - for (key, value) in extra { - data[key] = value - } - Self.logDashboardEvent(message, data: data) - } - private nonisolated static func makeDashboardSnapshot(_ components: DashboardSnapshotComponents) -> OpenAIDashboardSnapshot { @@ -156,14 +111,12 @@ public struct OpenAIDashboardFetcher { timeout: timeout) } - // swiftlint:disable function_body_length public func loadLatestDashboard( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { - var trace = DashboardFetchTrace(startedAt: Date(), timeout: timeout) let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, @@ -183,7 +136,6 @@ public struct OpenAIDashboardFetcher { var lastUsageBreakdownDebug: String? var lastCreditsPurchaseURL: String? while Date() < deadline { - trace.scrapeIterations += 1 let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody lastHTML = scrape.bodyHTML ?? lastHTML @@ -202,14 +154,12 @@ public struct OpenAIDashboardFetcher { } if scrape.workspacePicker { - trace.workspaceWaitCount += 1 try? await Task.sleep(for: .milliseconds(500)) continue } // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if let href = scrape.href, !Self.isUsageRoute(href) { - trace.routeReloadCount += 1 _ = webView.load(URLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue @@ -219,14 +169,6 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = scrape.bodyHTML { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) } - Self.emitDashboardSummary( - message: "0.20 OpenAI dashboard fetch returned login-required", - trace: trace, - anyDashboardSignalAt: anyDashboardSignalAt, - extra: [ - "cloudflare": scrape.cloudflareInterstitial ? "1" : "0", - "workspacePicker": scrape.workspacePicker ? "1" : "0", - ]) throw FetchError.loginRequired } @@ -234,10 +176,6 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = scrape.bodyHTML { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) } - Self.emitDashboardSummary( - message: "0.20 OpenAI dashboard fetch hit Cloudflare interstitial", - trace: trace, - anyDashboardSignalAt: anyDashboardSignalAt) throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") } @@ -278,7 +216,6 @@ public struct OpenAIDashboardFetcher { "inViewport=\(scrape.creditsHeaderInViewport) didScroll=\(scrape.didScrollToCredits) " + "rows=\(scrape.rows.count)") if scrape.didScrollToCredits { - trace.creditsScrollWaitCount += 1 log("scrollIntoView(Credits usage history) requested; waiting…") try? await Task.sleep(for: .milliseconds(600)) continue @@ -297,7 +234,6 @@ public struct OpenAIDashboardFetcher { creditsHeaderInViewport: scrape.creditsHeaderInViewport, didScrollToCredits: scrape.didScrollToCredits)) { - trace.creditsHydrationWaitCount += 1 try? await Task.sleep(for: .milliseconds(400)) continue } @@ -311,21 +247,10 @@ public struct OpenAIDashboardFetcher { if codeReview != nil, usageBreakdown.isEmpty { let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date()) if elapsed < 6 { - trace.breakdownHydrationWaitCount += 1 try? await Task.sleep(for: .milliseconds(400)) continue } } - Self.emitDashboardSummary( - message: "0.20 OpenAI dashboard fetch succeeded", - trace: trace, - anyDashboardSignalAt: anyDashboardSignalAt, - extra: [ - "creditRows": String(events.count), - "usageBreakdownDays": String(usageBreakdown.count), - "hasRateLimits": hasUsageLimits ? "1" : "0", - "hasCreditsRemaining": creditsRemaining == nil ? "0" : "1", - ]) return Self.makeDashboardSnapshot(.init( scrape: scrape, codeReview: codeReview, @@ -344,19 +269,9 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = lastHTML { Self.writeDebugArtifacts(html: html, bodyText: lastBody, logger: log) } - Self.emitDashboardSummary( - message: "0.20 OpenAI dashboard fetch exhausted timeout without data", - trace: trace, - anyDashboardSignalAt: anyDashboardSignalAt, - extra: [ - "lastBodyPresent": lastBody == nil ? "0" : "1", - "lastHrefKnown": lastHref == nil ? "0" : "1", - ]) throw FetchError.noDashboardData(body: lastBody ?? "") } - // swiftlint:enable function_body_length - struct CreditsHistoryWaitContext { let now: Date let anyDashboardSignalAt: Date? diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index a88d939732..397e41138c 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -34,30 +34,9 @@ final class OpenAIDashboardWebViewCache { private let idleTimeout: TimeInterval = 60 private let blankURL = URL(string: "about:blank")! - private func logCacheEvent( - _ message: String, - location: String, - data: [String: String]) - { - AgentDebugLogger.log( - message, - hypothesisId: "B", - location: location, - data: data) - } - private func releaseCachedEntry(_ entry: Entry) { entry.isBusy = false entry.lastUsedAt = Date() - self.logCacheEvent( - "0.20 releases cached OpenAI webview to idle state", - location: "OpenAIDashboardWebViewCache.swift:releaseCached", - data: [ - "currentURLHost": entry.webView.url?.host ?? "none", - "currentURLPath": entry.webView.url?.path ?? "", - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "entriesAfterRelease": String(self.entries.count), - ]) self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) self.prune(now: Date()) } @@ -65,15 +44,6 @@ final class OpenAIDashboardWebViewCache { private func releaseNewEntry(_ entry: Entry, webView: WKWebView) { entry.isBusy = false entry.lastUsedAt = Date() - self.logCacheEvent( - "0.20 releases newly created OpenAI webview to idle state", - location: "OpenAIDashboardWebViewCache.swift:releaseNew", - data: [ - "currentURLHost": entry.webView.url?.host ?? "none", - "currentURLPath": entry.webView.url?.path ?? "", - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "entriesAfterRelease": String(self.entries.count), - ]) self.prepareCachedWebViewForIdle(webView, host: entry.host) self.prune(now: Date()) } @@ -173,13 +143,6 @@ final class OpenAIDashboardWebViewCache { try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { - self.logCacheEvent( - "0.20 temporary OpenAI webview timed out during prepare", - location: "OpenAIDashboardWebViewCache.swift:acquireBusyRetry", - data: [ - "existingEntries": String(self.entries.count), - "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), - ]) host.close() log("Temporary OpenAI WebView timed out; retrying with a fresh WebView.") return try await self.acquireTemporaryWebView( @@ -199,27 +162,11 @@ final class OpenAIDashboardWebViewCache { entry.isBusy = true entry.lastUsedAt = now - self.logCacheEvent( - "0.20 reuses cached OpenAI webview", - location: "OpenAIDashboardWebViewCache.swift:acquire", - data: [ - "existingEntries": String(self.entries.count), - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "usageURLHost": usageURL.host ?? "none", - "usageURLPath": usageURL.path, - ]) entry.host.show() do { try await self.prepareWebView(entry.webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { - self.logCacheEvent( - "0.20 cached OpenAI webview timed out during prepare", - location: "OpenAIDashboardWebViewCache.swift:acquireCachedRetry", - data: [ - "existingEntries": String(self.entries.count), - "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), - ]) entry.isBusy = false entry.lastUsedAt = Date() entry.host.close() @@ -252,28 +199,12 @@ final class OpenAIDashboardWebViewCache { let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore) let entry = Entry(webView: webView, host: host, lastUsedAt: now, isBusy: true) self.entries[key] = entry - self.logCacheEvent( - "0.20 creates cached OpenAI webview", - location: "OpenAIDashboardWebViewCache.swift:createEntry", - data: [ - "existingEntries": String(self.entries.count), - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "usageURLHost": usageURL.host ?? "none", - "usageURLPath": usageURL.path, - ]) host.show() do { try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { - self.logCacheEvent( - "0.20 newly created OpenAI webview timed out during prepare", - location: "OpenAIDashboardWebViewCache.swift:createEntryRetry", - data: [ - "existingEntries": String(self.entries.count), - "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), - ]) self.entries.removeValue(forKey: key) host.close() log("OpenAI WebView timed out during prepare; retrying once.") @@ -309,15 +240,6 @@ final class OpenAIDashboardWebViewCache { func evictAll() { let existing = self.entries self.entries.removeAll() - if !existing.isEmpty { - self.logCacheEvent( - "0.20 evicts cached OpenAI webviews after refresh failure or reset", - location: "OpenAIDashboardWebViewCache.swift:evictAll", - data: [ - "evictedEntries": String(existing.count), - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - ]) - } for (_, entry) in existing { entry.host.close() } @@ -341,13 +263,6 @@ final class OpenAIDashboardWebViewCache { !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout } for (key, entry) in expired { - self.logCacheEvent( - "0.20 prunes cached OpenAI webview after shorter idle timeout", - location: "OpenAIDashboardWebViewCache.swift:prune", - data: [ - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "entriesBeforePrune": String(self.entries.count), - ]) entry.host.close() self.entries.removeValue(forKey: key) Self.log.debug("OpenAI webview pruned") diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 635a1ef2c8..49c9e6998a 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -58,7 +58,6 @@ enum PiSessionCostScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { - let startedAt = Date() let root = self.defaultPiSessionsRoot(options: options) let startCutoff = self.dateFromDayKey(range.scanSinceKey) ?? since let files = self.listPiSessionFiles(root: root, startCutoffLocal: startCutoff) @@ -86,18 +85,6 @@ enum PiSessionCostScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot) - AgentDebugLogger.log( - "0.20 PI session cost scanner refreshed cache", - hypothesisId: "G", - location: "PiSessionCostScanner.swift:loadDailyReport", - data: [ - "provider": provider.rawValue, - "fileCount": String(files.count), - "cacheFiles": String(cache.files.count), - "forceRescan": options.forceRescan ? "1" : "0", - "windowExpanded": windowExpanded ? "1" : "0", - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) } return self.buildReport(provider: provider, cache: cache, range: range) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index 1baf368c05..13359aedb8 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -76,7 +76,6 @@ public struct CodexStatusProbe { } public func fetch() async throws -> CodexStatusSnapshot { - let startedAt = Date() let env = self.environment let resolved = BinaryLocator.resolveCodexBinary(env: env, loginPATH: LoginShellPathCache.shared.current) ?? self.codexBinary @@ -84,64 +83,20 @@ public struct CodexStatusProbe { throw CodexStatusProbeError.codexNotInstalled } do { - let snapshot = try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) - AgentDebugLogger.log( - "0.20 Codex status probe completed on first attempt", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - ]) - return snapshot + return try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) } catch let error as CodexStatusProbeError { // Retry only parser-level flakes with a short second attempt. switch error { case .parseFailed: - AgentDebugLogger.log( - "0.20 Codex status probe retried after parser failure", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - ]) - let snapshot = try await self.runAndParse( + return try await self.runAndParse( binary: resolved, rows: 70, cols: 220, timeout: Self.parseRetryTimeoutSeconds) - AgentDebugLogger.log( - "0.20 Codex status probe completed after parser retry", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - ]) - return snapshot default: - AgentDebugLogger.log( - "0.20 Codex status probe failed", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - "error": String(describing: error), - ]) throw error } } catch { - AgentDebugLogger.log( - "0.20 Codex status probe failed", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - "error": String(describing: error), - ]) throw error } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index c1c83355a9..962dc956d0 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -566,7 +566,6 @@ public struct UsageFetcher: Sendable { } private func loadRPCUsage() async throws -> UsageSnapshot { - let startedAt = Date() let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } do { @@ -593,30 +592,13 @@ public struct UsageFetcher: Sendable { else { throw UsageError.noRateLimitsFound } - AgentDebugLogger.log( - "0.20 Codex RPC usage fetch succeeded", - hypothesisId: "E", - location: "UsageFetcher.swift:loadRPCUsage", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "accountEmailKnown": identity.accountEmail == nil ? "0" : "1", - ]) return state.toUsageSnapshot() } catch { - AgentDebugLogger.log( - "0.20 Codex RPC usage fetch failed", - hypothesisId: "E", - location: "UsageFetcher.swift:loadRPCUsage", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "error": String(describing: error), - ]) throw error } } private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - let startedAt = Date() do { let status = try await CodexStatusProbe( keepCLISessionsAlive: keepCLISessionsAlive, @@ -637,27 +619,8 @@ public struct UsageFetcher: Sendable { else { throw UsageError.noRateLimitsFound } - AgentDebugLogger.log( - "0.20 Codex TTY usage fetch succeeded", - hypothesisId: "F", - location: "UsageFetcher.swift:loadTTYUsage", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", - "hasFiveHour": status.fiveHourPercentLeft == nil ? "0" : "1", - "hasWeekly": status.weeklyPercentLeft == nil ? "0" : "1", - ]) return state.toUsageSnapshot() } catch { - AgentDebugLogger.log( - "0.20 Codex TTY usage fetch failed", - hypothesisId: "F", - location: "UsageFetcher.swift:loadTTYUsage", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", - "error": String(describing: error), - ]) throw error } } @@ -669,7 +632,6 @@ public struct UsageFetcher: Sendable { } private func loadRPCCredits() async throws -> CreditsSnapshot { - let startedAt = Date() let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } do { @@ -677,55 +639,21 @@ public struct UsageFetcher: Sendable { let limits = try await rpc.fetchRateLimits().rateLimits guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } let remaining = Self.parseCredits(credits.balance) - AgentDebugLogger.log( - "0.20 Codex RPC credits fetch succeeded", - hypothesisId: "E", - location: "UsageFetcher.swift:loadRPCCredits", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "creditsKnown": "1", - ]) return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) } catch { - AgentDebugLogger.log( - "0.20 Codex RPC credits fetch failed", - hypothesisId: "E", - location: "UsageFetcher.swift:loadRPCCredits", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "error": String(describing: error), - ]) throw error } } private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - let startedAt = Date() do { let status = try await CodexStatusProbe( keepCLISessionsAlive: keepCLISessionsAlive, environment: self.environment) .fetch() guard let credits = status.credits else { throw UsageError.noRateLimitsFound } - AgentDebugLogger.log( - "0.20 Codex TTY credits fetch succeeded", - hypothesisId: "F", - location: "UsageFetcher.swift:loadTTYCredits", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", - ]) return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) } catch { - AgentDebugLogger.log( - "0.20 Codex TTY credits fetch failed", - hypothesisId: "F", - location: "UsageFetcher.swift:loadTTYCredits", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", - "error": String(describing: error), - ]) throw error } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 2e3f1c1a95..c1ea28ed45 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -542,7 +542,6 @@ extension CostUsageScanner { var touched: Set = [] if shouldRefresh { - let startedAt = Date() if options.forceRescan { cache = CostUsageCache() } @@ -566,18 +565,6 @@ extension CostUsageScanner { Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) - AgentDebugLogger.log( - "0.20 Claude local cost scanner refreshed cache", - hypothesisId: "G", - location: "CostUsageScanner+Claude.swift:loadClaudeDaily", - data: [ - "provider": provider.rawValue, - "rootCount": String(roots.count), - "touchedFiles": String(touched.count), - "cacheFiles": String(cache.files.count), - "forceRescan": options.forceRescan ? "1" : "0", - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) } return Self.buildClaudeReportFromCache(cache: cache, range: range) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c344fa6881..91de4e1cac 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -990,7 +990,6 @@ enum CostUsageScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { - let startedAt = Date() if options.forceRescan { cache = CostUsageCache() } @@ -1067,17 +1066,6 @@ enum CostUsageScanner { cache.roots = rootsFingerprint cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) - AgentDebugLogger.log( - "0.20 Codex local cost scanner refreshed cache", - hypothesisId: "G", - location: "CostUsageScanner.swift:loadCodexDaily", - data: [ - "fileCount": String(files.count), - "rootCount": String(roots.count), - "cacheFiles": String(cache.files.count), - "forceRescan": options.forceRescan ? "1" : "0", - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) } return Self.buildCodexReportFromCache(cache: cache, range: range) From 64f9ac1914e9e039e76b406447a8dddcbebd8986 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 20:34:30 +0530 Subject: [PATCH 0173/1239] Clear stale snapshots for unavailable enabled providers --- Sources/CodexBar/UsageStore.swift | 10 ++++- .../UsageStoreCoverageTests.swift | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index a679ebc174..fa172c228c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -346,7 +346,15 @@ final class UsageStore { /// Providers that should actually participate in background refresh/status/token work. func enabledProvidersForBackgroundWork() -> [UsageProvider] { - self.enabledProviders() + let availableProviders = Set(self.enabledProviders()) + return self.enabledProvidersForDisplay().filter { provider in + if availableProviders.contains(provider) { + return true + } + return self.snapshots[provider] != nil + || !(self.accountSnapshots[provider] ?? []).isEmpty + || self.tokenSnapshots[provider] != nil + } } var statusChecksEnabled: Bool { diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 75213dfdcb..768d38c738 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -228,6 +228,43 @@ struct UsageStoreCoverageTests { #expect(store.enabledProvidersForBackgroundWork().isEmpty) } + @Test + func unavailableProviderWithCachedSnapshotStaysEligibleForBackgroundCleanup() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let cachedSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(cachedSnapshot, provider: .synthetic) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork() == [.synthetic]) + + await store.refresh() + #expect(store.snapshot(for: .synthetic) != nil) + + await store.refresh() + #expect(store.snapshot(for: .synthetic) == nil) + #expect(store.error(for: .synthetic)?.isEmpty == false) + } + @Test func statusIndicatorsAndFailureGate() { #expect(!ProviderStatusIndicator.none.hasIssue) From 441d1a404b13666e53438044f5ece50d20a62a33 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 22:04:54 +0530 Subject: [PATCH 0174/1239] Clear unavailable provider state and preserve cached OpenAI web views --- .../PreferencesProviderDetailView.swift | 3 + .../StatusItemController+Animation.swift | 4 + Sources/CodexBar/UsageStore+Accessors.swift | 33 ++++- .../UsageStore+BackgroundRefresh.swift | 41 ++++--- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 1 - Sources/CodexBar/UsageStore.swift | 18 ++- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 5 - .../CodexManagedOpenAIWebTests.swift | 49 ++++++++ .../StatusItemAnimationSignatureTests.swift | 73 +++++++++++ .../StatusItemAnimationTests.swift | 10 +- .../UsageStoreCoverageTests.swift | 116 +++++++++++++++++- 11 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index edcc1d0dcc..5ecff079d5 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -314,6 +314,9 @@ private struct ProviderDetailInfoGrid: View { if self.store.refreshingProviders.contains(self.provider) { return "Refreshing" } + if self.store.unavailableMessage(for: self.provider) != nil { + return "Unavailable" + } return "Not fetched yet" } } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 29c592a25b..468f2d5666 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -313,6 +313,7 @@ extension StatusItemController { let signature = [ "mode=brandPercent", "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", "primary=\(debugDouble(primary))", "weekly=\(debugDouble(weekly))", "credits=\(debugDouble(credits))", @@ -335,6 +336,7 @@ extension StatusItemController { let signature = [ "mode=openRouterFallback", "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", "primary=\(debugDouble(primary))", "weekly=\(debugDouble(weekly))", "credits=\(debugDouble(credits))", @@ -357,6 +359,7 @@ extension StatusItemController { let signature = [ "mode=morph", "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", "morph=\(debugDouble(morphProgress))", "status=\(statusIndicator.rawValue)", "anim=\(needsAnimation ? "1" : "0")", @@ -370,6 +373,7 @@ extension StatusItemController { let signature = [ "mode=icon", "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", "primary=\(debugDouble(primary))", "weekly=\(debugDouble(weekly))", "credits=\(debugDouble(credits))", diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 7f8f828bef..743f405e2a 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -35,9 +35,36 @@ extension UsageStore { } func userFacingError(for provider: UsageProvider) -> String? { - let raw = self.errors[provider] - guard provider == .codex else { return raw } - return CodexUIErrorMapper.userFacingMessage(raw) + if let raw = self.errors[provider] { + guard provider == .codex else { return raw } + return CodexUIErrorMapper.userFacingMessage(raw) + } + return self.unavailableMessage(for: provider) + } + + func unavailableMessage(for provider: UsageProvider) -> String? { + guard self.enabledProvidersForDisplay().contains(provider), + !self.isProviderAvailable(provider) + else { + return nil + } + + switch provider { + case .synthetic: + return SyntheticSettingsError.missingToken.errorDescription + case .zai: + return ZaiSettingsError.missingToken.errorDescription + case .openrouter: + return OpenRouterSettingsError.missingToken.errorDescription + case .perplexity: + return PerplexityAPIError.missingToken.errorDescription + case .minimax: + return MiniMaxAPISettingsError.missingToken.errorDescription + case .kimi: + return KimiAPIError.missingToken.errorDescription + default: + return "\(self.metadata(for: provider).displayName) is unavailable in the current environment." + } } func status(for provider: UsageProvider) -> ProviderStatus? { diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index cfe602df38..46f1423471 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -3,22 +3,35 @@ import Foundation @MainActor extension UsageStore { + private func clearProviderState(_ provider: UsageProvider) { + self.refreshingProviders.remove(provider) + self.snapshots.removeValue(forKey: provider) + self.errors[provider] = nil + self.lastSourceLabels.removeValue(forKey: provider) + self.lastFetchAttempts.removeValue(forKey: provider) + self.accountSnapshots.removeValue(forKey: provider) + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.failureGates[provider]?.reset() + self.tokenFailureGates[provider]?.reset() + self.statuses.removeValue(forKey: provider) + self.lastKnownSessionRemaining.removeValue(forKey: provider) + self.lastKnownSessionWindowSource.removeValue(forKey: provider) + self.lastTokenFetchAt.removeValue(forKey: provider) + } + func clearDisabledProviderState(enabledProviders: Set) { for provider in UsageProvider.allCases where !enabledProviders.contains(provider) { - self.refreshingProviders.remove(provider) - self.snapshots.removeValue(forKey: provider) - self.errors[provider] = nil - self.lastSourceLabels.removeValue(forKey: provider) - self.lastFetchAttempts.removeValue(forKey: provider) - self.accountSnapshots.removeValue(forKey: provider) - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.failureGates[provider]?.reset() - self.tokenFailureGates[provider]?.reset() - self.statuses.removeValue(forKey: provider) - self.lastKnownSessionRemaining.removeValue(forKey: provider) - self.lastKnownSessionWindowSource.removeValue(forKey: provider) - self.lastTokenFetchAt.removeValue(forKey: provider) + self.clearProviderState(provider) + } + } + + func clearUnavailableProviderState( + displayEnabledProviders: Set, + availableProviders: Set) + { + for provider in displayEnabledProviders where !availableProviders.contains(provider) { + self.clearProviderState(provider) } } } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 88851717e5..08a15e4a6b 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -99,7 +99,6 @@ extension UsageStore { authorityInput: authority.input, attachedAccountEmail: attachedAccountEmail, allowCodexUsageBackfill: allowCodexUsageBackfill) - OpenAIDashboardFetcher.evictCachedWebView(accountEmail: targetEmail) } func applyOpenAIDashboardFailure( diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index fa172c228c..354147954b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -346,15 +346,7 @@ final class UsageStore { /// Providers that should actually participate in background refresh/status/token work. func enabledProvidersForBackgroundWork() -> [UsageProvider] { - let availableProviders = Set(self.enabledProviders()) - return self.enabledProvidersForDisplay().filter { provider in - if availableProviders.contains(provider) { - return true - } - return self.snapshots[provider] != nil - || !(self.accountSnapshots[provider] ?? []).isEmpty - || self.tokenSnapshots[provider] != nil - } + self.enabledProviders() } var statusChecksEnabled: Bool { @@ -465,6 +457,7 @@ final class UsageStore { let displayEnabledProviders = self.enabledProvidersForDisplay() let enabledProviderSet = Set(displayEnabledProviders) let refreshProviders = self.enabledProvidersForBackgroundWork() + let availableRefreshProviders = Set(self.enabledProviders()) let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { @@ -475,11 +468,16 @@ final class UsageStore { } self.clearDisabledProviderState(enabledProviders: enabledProviderSet) + self.clearUnavailableProviderState( + displayEnabledProviders: enabledProviderSet, + availableProviders: availableRefreshProviders) await withTaskGroup(of: Void.self) { group in for provider in refreshProviders { group.addTask { await self.refreshProvider(provider) } - group.addTask { await self.refreshStatus(provider) } + if availableRefreshProviders.contains(provider) { + group.addTask { await self.refreshStatus(provider) } + } } group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index d83ba7a72c..fe654bfab7 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -339,11 +339,6 @@ public struct OpenAIDashboardFetcher { OpenAIDashboardWebViewCache.shared.evictAll() } - public static func evictCachedWebView(accountEmail: String?) { - let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) - OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) - } - public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift index 39757119cf..5bde40281e 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift @@ -225,6 +225,55 @@ struct CodexManagedOpenAIWebTests { #expect(observedAllowAnyAccount == false) } + @Test + func `successful dashboard apply preserves cached open A I web view for same account`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebTests-preserve-cache-on-success") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + let cache = OpenAIDashboardWebViewCache.shared + cache.clearAllForTesting() + defer { + cache.clearAllForTesting() + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let websiteDataStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: managedAccount.email) + cache.cacheEntryForTesting(websiteDataStore: websiteDataStore) + + #expect(cache.hasCachedEntry(for: websiteDataStore)) + + await store.applyOpenAIDashboard( + OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 90, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 10, + accountPlan: "Pro", + updatedAt: Date()), + targetEmail: managedAccount.email) + + #expect(cache.hasCachedEntry(for: websiteDataStore)) + #expect(cache.entryCount == 1) + } + @Test func `dashboard refresh does not target stale last known live email`() async { let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebTests-live-system-refresh-strict-target") diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift new file mode 100644 index 0000000000..d2950ca09f --- /dev/null +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -0,0 +1,73 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +struct StatusItemAnimationSignatureTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `merged render signature changes when unified icon style changes`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-style-signature"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProviders() == [.codex, .synthetic]) + #expect(store.iconStyle == .combined) + controller.applyIcon(phase: nil) + let combinedSignature = controller.lastAppliedMergedIconRenderSignature + + settings.syntheticAPIToken = "" + + #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProviders() == [.codex]) + #expect(store.iconStyle == .codex) + controller.applyIcon(phase: nil) + let codexSignature = controller.lastAppliedMergedIconRenderSignature + + #expect(combinedSignature != nil) + #expect(codexSignature != nil) + #expect(combinedSignature != codexSignature) + #expect(codexSignature?.contains("style=codex") == true) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 13c5e0246b..dc098aead8 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -42,9 +42,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" if let geminiMeta = registry.metadata[.gemini] { settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) } @@ -88,9 +89,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 768d38c738..9e9258447e 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -229,7 +229,34 @@ struct UsageStoreCoverageTests { } @Test - func unavailableProviderWithCachedSnapshotStaysEligibleForBackgroundCleanup() async throws { + func visibleUnavailableProviderGetsExplicitUserFacingState() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-unavailable-message") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + + #expect(store.errors[.synthetic] == nil) + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.isProviderAvailable(.synthetic) == false) + #expect(store.userFacingError(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) + #expect(store.unavailableMessage(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) + } + + @Test + func refreshClearsEnabledButUnavailableCachedState() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -252,17 +279,96 @@ struct UsageStoreCoverageTests { secondary: nil, updatedAt: Date()) store._setSnapshotForTesting(cachedSnapshot, provider: .synthetic) + let account = ProviderTokenAccount(id: UUID(), label: "Account", token: "token", addedAt: 0, lastUsed: nil) + store.accountSnapshots[.synthetic] = [ + TokenAccountUsageSnapshot(account: account, snapshot: cachedSnapshot, error: nil, sourceLabel: "api"), + ] + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1.23, + last30DaysTokens: 100, + last30DaysCostUSD: 4.56, + daily: [], + updatedAt: Date()), + provider: .synthetic) #expect(store.enabledProvidersForDisplay() == [.synthetic]) #expect(store.enabledProviders().isEmpty) - #expect(store.enabledProvidersForBackgroundWork() == [.synthetic]) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) await store.refresh() - #expect(store.snapshot(for: .synthetic) != nil) + #expect(store.snapshot(for: .synthetic) == nil) + #expect((store.accountSnapshots[.synthetic] ?? []).isEmpty) + #expect(store.tokenSnapshots[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func refreshClearsEnabledButUnavailableFailureState() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-failure-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store._setErrorForTesting("stale", provider: .synthetic) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + store.tokenErrors[.synthetic] = "token stale" + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) await store.refresh() - #expect(store.snapshot(for: .synthetic) == nil) - #expect(store.error(for: .synthetic)?.isEmpty == false) + + #expect(store.errors[.synthetic] == nil) + #expect(store.tokenErrors[.synthetic] == nil) + #expect(store.statuses[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func unavailableProviderWithOnlyCachedStatusGetsSingleCleanupPass() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + + let metadata = ProviderRegistry.shared.metadata + + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + + await store.refresh() + + #expect(store.statuses[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) } @Test From a4852cec29a1678d9849acc71ca4fbd08d6dc7e2 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 23:06:28 +0530 Subject: [PATCH 0175/1239] Preserve hosted submenu provider context --- .../StatusItemController+HostedSubmenus.swift | 1 + Tests/CodexBarTests/StatusMenuTests.swift | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 2b6f126781..1676db9b19 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -58,6 +58,7 @@ extension StatusItemController { let unavailableItem = NSMenuItem(title: "No data available", action: nil, keyEquivalent: "") unavailableItem.isEnabled = false unavailableItem.representedObject = chartID + unavailableItem.toolTip = placeholder.toolTip menu.addItem(unavailableItem) } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index b0b676848a..c74ecf69cd 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -839,6 +839,62 @@ extension StatusMenuTests { #expect(try #require(creditsIndex) < costIndex!) } + @Test + func `hosted cost submenu preserves provider context after empty hydration`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title == "No data available") + #expect(submenu.items.first?.toolTip == UsageProvider.codex.rawValue) + + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title != "No data available") + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + } + @Test func `shows extra usage for claude when using menu card sections`() { self.disableMenuCardsForTesting() From 3fe9d0c7a510e6204ac71a45ed5216631c6e4751 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 14 Apr 2026 14:11:45 +0530 Subject: [PATCH 0176/1239] Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3935217e8..1f55fe0bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205! - Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! - Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! +- Battery / refresh: cut menu redraw churn, skip background work for unavailable providers, and reuse cached OpenAI web views more efficiently (#708). - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps still prefer user-managed and native Homebrew binaries when multiple installs exist. - Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! @@ -19,17 +20,21 @@ - Ollama: recognize `__Secure-session` cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee! - OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary! - Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! +- Refresh pipeline: skip background work for unavailable providers, clear stale cached state, and show explicit unavailable messages (#708). - Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps prefer `~/.claude/bin/claude`, then Homebrew, before the bundled `cmux.app` binary when shell-based resolution is unavailable. - Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han! - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. - Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. +- OpenAI web: keep cached WebViews across same-account refreshes and clean them up only when accounts or providers go stale (#708). ### Menu & Settings - Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm! - z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662). +- Menu bar: avoid redundant merged-icon redraws and make hosted chart submenus load lazily without losing provider context (#708). - Codex: add an OpenAI web battery-saver toggle, keep manual refresh available when battery saver is on, and hide OpenAI web submenus when web extras are disabled. ### Development & Tooling +- Diagnostics: add lightweight battery instrumentation for menu updates and refresh work (#708). - Build script: make CodexBar-owned ad-hoc keychain cleanup opt-in with `--clear-adhoc-keychain`, and extend the explicit reset path to clear both `com.steipete.CodexBar` and `com.steipete.codexbar.cache`. Thanks @magnaprog! ## 0.20 — 2026-04-07 From a1813708d728112a6175e93709a91c26a80c05cc Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Wed, 15 Apr 2026 12:48:11 +0530 Subject: [PATCH 0177/1239] Handle Codex Pro Lite plan responses and OAuth fallback edge cases (#710) * Handle Codex prolite plans and OAuth fallbacks * Update changelog for Codex Pro plan * Recover CLI rate limits from Pro Lite errors * Fix RPC JSON extraction and sanitize Pro Lite test data * Preserve TTY fallback when RPC recovery loses session lane * Update CHANGELOG.md --------- Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com> --- CHANGELOG.md | 5 +- Sources/CodexBar/MenuCardView.swift | 2 +- Sources/CodexBar/MenuDescriptor.swift | 2 +- Sources/CodexBarCLI/CLIRenderer.swift | 7 +- .../OpenAIWeb/OpenAIDashboardParser.swift | 2 +- .../CodexOAuth/CodexOAuthUsageFetcher.swift | 42 +++ .../Providers/Codex/CodexPlanFormatting.swift | 57 ++++ .../Codex/CodexProviderDescriptor.swift | 11 +- Sources/CodexBarCore/UsageFetcher.swift | 115 +++++++- Tests/CodexBarTests/CLISnapshotTests.swift | 28 ++ Tests/CodexBarTests/CodexOAuthTests.swift | 260 ++++++++++++++++++ .../CodexPlanFormattingTests.swift | 37 +++ ...dexPresentationCharacterizationTests.swift | 36 +++ .../CodexUsageFetcherFallbackTests.swift | 194 +++++++++++++ .../CodexUserFacingErrorTests.swift | 11 + .../OpenAIDashboardParserTests.swift | 14 + 16 files changed, 814 insertions(+), 9 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexPlanFormatting.swift create mode 100644 Tests/CodexBarTests/CodexPlanFormattingTests.swift create mode 100644 Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f55fe0bec..d07f9e9726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,9 @@ ## 0.21 — Unreleased ### Highlights -- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. - Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! -- Antigravity: accept localhost TLS challenges when probing the local language server so usage/account info loads again instead of reporting `no working API port found` (#693, fixes #692). Thanks @anirudhvee! +- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. +- Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF! - Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205! - Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! - Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! @@ -17,6 +17,7 @@ - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). - Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! +- Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709). - Ollama: recognize `__Secure-session` cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee! - OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary! - Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index d211a9d964..a47ecd6cce 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -840,7 +840,7 @@ extension UsageMenuCardView.Model { } private static func planDisplay(_ text: String) -> String { - let cleaned = UsageFormatter.cleanPlanName(text) + let cleaned = CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) return cleaned.isEmpty ? text : cleaned } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 68c81a10ad..5b2b4e5ceb 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -477,7 +477,7 @@ struct MenuDescriptor { private enum AccountFormatter { static func plan(_ text: String) -> String { - let cleaned = UsageFormatter.cleanPlanName(text) + let cleaned = CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) return cleaned.isEmpty ? text : cleaned } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index b2c863fda6..211cb9d52d 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -153,7 +153,12 @@ enum CLIRenderer { lines.append(self.labelValueLine("Activity", value: detail, useColor: context.useColor)) } } else if let plan = snapshot.loginMethod(for: provider), !plan.isEmpty { - lines.append(self.labelValueLine("Plan", value: plan.capitalized, useColor: context.useColor)) + let displayPlan = if provider == .codex { + CodexPlanFormatting.displayName(plan) ?? plan + } else { + plan.capitalized + } + lines.append(self.labelValueLine("Plan", value: displayPlan, useColor: context.useColor)) } for note in context.notes { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift index a2a3d914c8..d9be82bbdb 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift @@ -492,7 +492,7 @@ public enum OpenAIDashboardParser { "essential", ] guard allowed.contains(where: { lower.contains($0) }) else { return nil } - return UsageFormatter.cleanPlanName(trimmed) + return CodexPlanFormatting.displayName(trimmed) ?? UsageFormatter.cleanPlanName(trimmed) } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 7bcfe079ea..7cca42ff8d 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -14,6 +14,13 @@ public struct CodexUsageResponse: Decodable, Sendable { case credits } + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.planType = try? container.decodeIfPresent(PlanType.self, forKey: .planType) + self.rateLimit = try? container.decodeIfPresent(RateLimitDetails.self, forKey: .rateLimit) + self.credits = try? container.decodeIfPresent(CreditDetails.self, forKey: .credits) + } + public enum PlanType: Sendable, Decodable, Equatable { case guest case free @@ -75,11 +82,46 @@ public struct CodexUsageResponse: Decodable, Sendable { public struct RateLimitDetails: Decodable, Sendable { public let primaryWindow: WindowSnapshot? public let secondaryWindow: WindowSnapshot? + let primaryWindowDecodeFailed: Bool + let secondaryWindowDecodeFailed: Bool enum CodingKeys: String, CodingKey { case primaryWindow = "primary_window" case secondaryWindow = "secondary_window" } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let primaryHadValue = Self.hasNonNilValue(container: container, key: .primaryWindow) + do { + self.primaryWindow = try container.decodeIfPresent(WindowSnapshot.self, forKey: .primaryWindow) + self.primaryWindowDecodeFailed = false + } catch { + self.primaryWindow = nil + self.primaryWindowDecodeFailed = primaryHadValue + } + + let secondaryHadValue = Self.hasNonNilValue(container: container, key: .secondaryWindow) + do { + self.secondaryWindow = try container.decodeIfPresent(WindowSnapshot.self, forKey: .secondaryWindow) + self.secondaryWindowDecodeFailed = false + } catch { + self.secondaryWindow = nil + self.secondaryWindowDecodeFailed = secondaryHadValue + } + } + + private static func hasNonNilValue( + container: KeyedDecodingContainer, + key: CodingKeys) -> Bool + { + guard container.contains(key) else { return false } + return (try? container.decodeNil(forKey: key)) == false + } + + var hasWindowDecodeFailure: Bool { + self.primaryWindowDecodeFailed || self.secondaryWindowDecodeFailed + } } public struct WindowSnapshot: Decodable, Sendable { diff --git a/Sources/CodexBarCore/Providers/Codex/CodexPlanFormatting.swift b/Sources/CodexBarCore/Providers/Codex/CodexPlanFormatting.swift new file mode 100644 index 0000000000..c08d578fa3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexPlanFormatting.swift @@ -0,0 +1,57 @@ +import Foundation + +public enum CodexPlanFormatting { + private static let exactDisplayNames: [String: String] = [ + "prolite": "Pro Lite", + "pro_lite": "Pro Lite", + "pro-lite": "Pro Lite", + "pro lite": "Pro Lite", + ] + + private static let uppercaseWords: Set = [ + "cbp", + "k12", + ] + + public static func displayName(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + + let lower = raw.lowercased() + if let exact = Self.exactDisplayNames[lower] { + return exact + } + + let cleaned = UsageFormatter.cleanPlanName(raw) + let candidate = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + guard !candidate.isEmpty else { return raw } + + let components = candidate + .split(whereSeparator: { $0 == "_" || $0 == "-" || $0.isWhitespace }) + .map(String.init) + .filter { !$0.isEmpty } + + guard !components.isEmpty else { return candidate } + + let formatted = components.map(Self.wordDisplayName).joined(separator: " ") + return formatted.isEmpty ? candidate : formatted + } + + private static func wordDisplayName(_ raw: String) -> String { + let lower = raw.lowercased() + if let exact = Self.exactDisplayNames[lower] { + return exact + } + if Self.uppercaseWords.contains(lower) { + return lower.uppercased() + } + if raw == raw.uppercased(), raw.contains(where: \.isLetter) { + return raw + } + if let first = raw.first, first.isLowercase { + return raw.prefix(1).uppercased() + raw.dropFirst() + } + return raw + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index a715c1bd2f..202baa6007 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -174,12 +174,19 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { sourceMode: ProviderSourceMode) throws -> ProviderFetchResult { let credits = Self.mapCredits(usageResponse.credits) - - if let reconciled = CodexReconciledState.fromOAuth( + let reconciled = CodexReconciledState.fromOAuth( response: usageResponse, credentials: credentials, updatedAt: updatedAt) + + if sourceMode == .auto, + usageResponse.rateLimit?.hasWindowDecodeFailure == true, + reconciled?.session == nil { + throw UsageError.noRateLimitsFound + } + + if let reconciled { return CodexOAuthFetchStrategy().makeResult( usage: reconciled.toUsageSnapshot(), credits: credits, diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 962dc956d0..90360ca602 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -324,6 +324,20 @@ private struct RPCCreditsSnapshot: Decodable, Encodable { let balance: String? } +private struct RPCRateLimitsErrorBody: Decodable { + let email: String? + let planType: String? + let rateLimit: CodexUsageResponse.RateLimitDetails? + let credits: CodexUsageResponse.CreditDetails? + + enum CodingKeys: String, CodingKey { + case email + case planType = "plan_type" + case rateLimit = "rate_limit" + case credits + } +} + private enum RPCWireError: Error, LocalizedError { case startFailed(String) case requestFailed(String) @@ -575,7 +589,6 @@ public struct UsageFetcher: Sendable { // for the same pipe. let limits = try await rpc.fetchRateLimits().rateLimits let account = try? await rpc.fetchAccount() - let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: account?.account.flatMap { details in @@ -594,6 +607,9 @@ public struct UsageFetcher: Sendable { } return state.toUsageSnapshot() } catch { + if let snapshot = Self.recoverUsageFromRPCError(error) { + return snapshot + } throw error } } @@ -641,6 +657,9 @@ public struct UsageFetcher: Sendable { let remaining = Self.parseCredits(credits.balance) return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) } catch { + if let credits = Self.recoverCreditsFromRPCError(error) { + return credits + } throw error } } @@ -727,6 +746,16 @@ public struct UsageFetcher: Sendable { resetDescription: resetDescription) } + private static func makeWindow(from response: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let response else { return nil } + let resetsAtDate = Date(timeIntervalSince1970: TimeInterval(response.resetAt)) + return RateWindow( + usedPercent: Double(response.usedPercent), + windowMinutes: response.limitWindowSeconds / 60, + resetsAt: resetsAtDate, + resetDescription: UsageFormatter.resetDescription(from: resetsAtDate)) + } + private static func makeTTYWindow( percentLeft: Int?, windowMinutes: Int, @@ -746,6 +775,82 @@ public struct UsageFetcher: Sendable { return val } + private static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? { + guard let body = self.decodeRateLimitsErrorBody(from: error) else { return nil } + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: self.normalizedCodexAccountField(body.email), + accountOrganization: nil, + loginMethod: self.normalizedCodexAccountField(body.planType)) + guard let state = CodexReconciledState.fromCLI( + primary: self.makeWindow(from: body.rateLimit?.primaryWindow), + secondary: self.makeWindow(from: body.rateLimit?.secondaryWindow), + identity: identity) + else { + return nil + } + if body.rateLimit?.hasWindowDecodeFailure == true, + state.session == nil + { + return nil + } + return state.toUsageSnapshot() + } + + private static func recoverCreditsFromRPCError(_ error: Error) -> CreditsSnapshot? { + guard let credits = self.decodeRateLimitsErrorBody(from: error)?.credits else { return nil } + guard let remaining = credits.balance else { return nil } + return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + } + + private static func decodeRateLimitsErrorBody(from error: Error) -> RPCRateLimitsErrorBody? { + guard case let RPCWireError.requestFailed(message) = error else { return nil } + guard let json = self.extractJSONObject(after: "body=", in: message) else { return nil } + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(RPCRateLimitsErrorBody.self, from: data) + } + + private static func extractJSONObject(after marker: String, in text: String) -> String? { + guard let markerRange = text.range(of: marker) else { return nil } + let suffix = text[markerRange.upperBound...] + guard let start = suffix.firstIndex(of: "{") else { return nil } + + var depth = 0 + var inString = false + var isEscaped = false + + for index in suffix[start...].indices { + let character = suffix[index] + + if inString { + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + continue + } + + switch character { + case "\"": + inString = true + case "{": + depth += 1 + case "}": + depth -= 1 + if depth == 0 { + return String(suffix[start...index]) + } + default: + break + } + } + + return nil + } + private static func normalizedCodexAccountField(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil @@ -805,6 +910,14 @@ extension UsageFetcher { return state.toUsageSnapshot() } + public static func _recoverCodexRPCUsageFromErrorForTesting(_ message: String) -> UsageSnapshot? { + self.recoverUsageFromRPCError(RPCWireError.requestFailed(message)) + } + + public static func _recoverCodexRPCCreditsFromErrorForTesting(_ message: String) -> CreditsSnapshot? { + self.recoverCreditsFromRPCError(RPCWireError.requestFailed(message)) + } + private static func makeTestingWindow( _ value: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)) -> RateWindow diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index b3ce7ae523..700fb9257e 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -42,6 +42,34 @@ struct CLISnapshotTests { #expect(output.contains("Plan: Pro")) } + @Test + func `renders Codex prolite plan with spaced display name`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "prolite") + let snap = UsageSnapshot( + primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: "today at 3:00 PM"), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 1.2.3 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("Plan: Pro Lite")) + #expect(!output.contains("Plan: Prolite")) + } + @Test func `renders text snapshot for claude without weekly`() { let snap = UsageSnapshot( diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 32779a15a4..45d52013dc 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -87,6 +87,33 @@ struct CodexOAuthTests { #expect(response.credits?.unlimited == false) } + @Test + func `decodes prolite plan type without failing usage mapping`() throws { + let json = """ + { + "plan_type": "prolite", + "rate_limit": { + "primary_window": { + "used_percent": 12, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + } + """ + let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + #expect(response.planType?.rawValue == "prolite") + + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let mapped = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(mapped?.primary?.usedPercent == 12) + } + @Test func `maps usage windows from O auth`() throws { let json = """ @@ -282,6 +309,239 @@ struct CodexOAuthTests { #expect(snapshot == nil) } + @Test + func `keeps valid window when secondary window is malformed`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 18, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": "bad", + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot?.primary?.usedPercent == 18) + #expect(snapshot?.secondary == nil) + } + + @Test + func `auto mode falls back when primary window is malformed but weekly window survives`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": "bad", + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 43, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + + #expect(throws: UsageError.noRateLimitsFound) { + _ = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: creds, + sourceMode: .auto) + } + } + + @Test + func `explicit oauth keeps weekly window when primary window is malformed`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": "bad", + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 43, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: creds, + sourceMode: .oauth) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary?.usedPercent == 43) + #expect(result.usage.secondary?.windowMinutes == 10080) + } + + @Test + func `auto mode preserves reversed session window when primary window is malformed`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": "bad", + "reset_at": 1767407914, + "limit_window_seconds": 604800 + }, + "secondary_window": { + "used_percent": 18, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: creds, + sourceMode: .auto) + + #expect(result.usage.primary?.usedPercent == 18) + #expect(result.usage.primary?.windowMinutes == 300) + #expect(result.usage.secondary == nil) + } + + @Test + func `auto mode falls back when reversed session window is malformed in secondary`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 43, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + }, + "secondary_window": { + "used_percent": "bad", + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + + #expect(throws: UsageError.noRateLimitsFound) { + _ = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: creds, + sourceMode: .auto) + } + } + + @Test + func `explicit oauth keeps weekly window when reversed session window is malformed`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 43, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + }, + "secondary_window": { + "used_percent": "bad", + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: creds, + sourceMode: .oauth) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary?.usedPercent == 43) + #expect(result.usage.secondary?.windowMinutes == 10080) + } + + @Test + func `ignores malformed credits payload while keeping usage`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + }, + "credits": { + "has_credits": false, + "unlimited": false, + "balance": [] + } + } + """ + let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + #expect(response.credits?.hasCredits == false) + #expect(response.credits?.unlimited == false) + #expect(response.credits?.balance == nil) + + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot?.primary?.usedPercent == 22) + } + @Test func `credits only O auth payload still returns credits result`() throws { let json = """ diff --git a/Tests/CodexBarTests/CodexPlanFormattingTests.swift b/Tests/CodexBarTests/CodexPlanFormattingTests.swift new file mode 100644 index 0000000000..afc7a538b9 --- /dev/null +++ b/Tests/CodexBarTests/CodexPlanFormattingTests.swift @@ -0,0 +1,37 @@ +import CodexBarCore +import Foundation +import Testing + +struct CodexPlanFormattingTests { + @Test + func `maps prolite aliases to Pro Lite`() { + #expect(CodexPlanFormatting.displayName("prolite") == "Pro Lite") + #expect(CodexPlanFormatting.displayName("pro_lite") == "Pro Lite") + #expect(CodexPlanFormatting.displayName("pro-lite") == "Pro Lite") + #expect(CodexPlanFormatting.displayName("pro lite") == "Pro Lite") + } + + @Test + func `returns nil for empty plan values`() { + #expect(CodexPlanFormatting.displayName(nil) == nil) + #expect(CodexPlanFormatting.displayName("") == nil) + #expect(CodexPlanFormatting.displayName(" ") == nil) + } + + @Test + func `humanizes machine style plan identifiers`() { + #expect( + CodexPlanFormatting.displayName("enterprise_cbp_usage_based") + == "Enterprise CBP Usage Based") + #expect( + CodexPlanFormatting.displayName("self_serve_business_usage_based") + == "Self Serve Business Usage Based") + #expect(CodexPlanFormatting.displayName("k12") == "K12") + } + + @Test + func `preserves already readable plan text`() { + #expect(CodexPlanFormatting.displayName("Enterprise") == "Enterprise") + #expect(CodexPlanFormatting.displayName("Pro Lite") == "Pro Lite") + } +} diff --git a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift index 047a15246e..24345c25f7 100644 --- a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift @@ -95,6 +95,42 @@ struct CodexPresentationCharacterizationTests { #expect(!lines.contains("Plan: Max")) } + @Test + func `Codex menu humanizes prolite plan from snapshot identity`() { + let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-prolite") + settings.statusChecksEnabled = false + + let fetcher = UsageFetcher(environment: [:]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "prolite")), + provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: false) + + let lines = self.textLines(from: descriptor) + #expect(lines.contains("Plan: Pro Lite")) + #expect(!lines.contains("Plan: Prolite")) + } + @Test func `Codex menu prefers snapshot identity over conflicting fallback account info`() throws { let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-snapshot-precedence") diff --git a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift new file mode 100644 index 0000000000..c581a484e4 --- /dev/null +++ b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift @@ -0,0 +1,194 @@ +import CodexBarCore +import Foundation +import Testing + +struct CodexUsageFetcherFallbackTests { + @Test + func `CLI usage recovers from RPC decode mismatch body payload`() { + let snapshot = UsageFetcher._recoverCodexRPCUsageFromErrorForTesting( + Self.decodeMismatchBodyMessage) + + #expect(snapshot?.primary?.usedPercent == 4) + #expect(snapshot?.primary?.windowMinutes == 300) + #expect(snapshot?.secondary?.usedPercent == 19) + #expect(snapshot?.secondary?.windowMinutes == 10080) + #expect(snapshot?.accountEmail(for: UsageProvider.codex) == "prolite-test@example.com") + #expect(snapshot?.loginMethod(for: UsageProvider.codex) == "prolite") + } + + @Test + func `CLI credits recover from RPC decode mismatch body payload`() { + let credits = UsageFetcher._recoverCodexRPCCreditsFromErrorForTesting(Self.decodeMismatchBodyMessage) + + #expect(credits?.remaining == 0) + } + + @Test + func `CLI usage does not partially recover malformed RPC body without session lane`() { + let snapshot = UsageFetcher._recoverCodexRPCUsageFromErrorForTesting( + Self.partialDecodeBodyMessage) + + #expect(snapshot == nil) + } + + @Test + func `CLI usage falls back from RPC decode mismatch to TTY status`() async throws { + let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.decodeMismatchMessage) + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let snapshot = try await fetcher.loadLatestUsage() + + #expect(snapshot.primary?.usedPercent == 12) + #expect(snapshot.primary?.windowMinutes == 300) + #expect(snapshot.secondary?.usedPercent == 25) + #expect(snapshot.secondary?.windowMinutes == 10080) + } + + @Test + func `CLI credits fall back from RPC decode mismatch to TTY status`() async throws { + let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.decodeMismatchMessage) + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let credits = try await fetcher.loadLatestCredits() + + #expect(credits.remaining == 42) + } + + @Test + func `CLI usage falls back to TTY when RPC body recovery misses session lane`() async throws { + let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.partialDecodeBodyMessage) + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let snapshot = try await fetcher.loadLatestUsage() + + #expect(snapshot.primary?.usedPercent == 12) + #expect(snapshot.primary?.windowMinutes == 300) + #expect(snapshot.secondary?.usedPercent == 25) + #expect(snapshot.secondary?.windowMinutes == 10080) + } + + private static let decodeMismatchBodyMessage = """ + failed to fetch codex rate limits: Decode error for https://chatgpt.com/backend-api/wham/usage: + unknown variant `prolite`, expected one of `guest`, `free`, `go`, `plus`, `pro`; + content-type=application/json; body={ + "user_id": "user-TEST", + "account_id": "account-TEST", + "email": "prolite-test@example.com", + "plan_type": "prolite", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 4, + "limit_window_seconds": 18000, + "reset_after_seconds": 8657, + "reset_at": 1776216359 + }, + "secondary_window": { + "used_percent": 19, + "limit_window_seconds": 604800, + "reset_after_seconds": 187681, + "reset_at": 1776395384 + } + }, + "credits": { + "has_credits": false, + "unlimited": false, + "overage_limit_reached": false, + "balance": "0E-10" + } + } + """ + + private static let decodeMismatchMessage = """ + failed to fetch codex rate limits: Decode error for https://chatgpt.com/backend-api/wham/usage: + unknown variant `prolite`, expected one of `guest`, `free`, `go`, `plus`, `pro` + """ + + private static let partialDecodeBodyMessage = """ + failed to fetch codex rate limits: Decode error for https://chatgpt.com/backend-api/wham/usage: + unknown variant `prolite`, expected one of `guest`, `free`, `go`, `plus`, `pro`; + content-type=application/json; body={ + "email": "prolite-test@example.com", + "plan_type": "prolite", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": "oops", + "limit_window_seconds": 18000, + "reset_at": 1776216359 + }, + "secondary_window": { + "used_percent": 19, + "limit_window_seconds": 604800, + "reset_after_seconds": 187681, + "reset_at": 1776395384 + } + } + } + """ + + private func makeDecodeMismatchStubCodexCLI( + message: String = Self.decodeMismatchBodyMessage) + throws -> String + { + let script = """ + #!/usr/bin/python3 + import json + import sys + + args = sys.argv[1:] + if "app-server" in args: + for line in sys.stdin: + if not line.strip(): + continue + message = json.loads(line) + method = message.get("method") + if method == "initialized": + continue + + identifier = message.get("id") + if method == "initialize": + payload = {"id": identifier, "result": {}} + elif method == "account/rateLimits/read": + payload = { + "id": identifier, + "error": { + "message": '''\(message)''' + } + } + elif method == "account/read": + payload = { + "id": identifier, + "result": { + "account": { + "type": "chatgpt", + "email": "stub@example.com", + "planType": "prolite" + }, + "requiresOpenaiAuth": False + } + } + else: + payload = {"id": identifier, "result": {}} + + print(json.dumps(payload), flush=True) + else: + for line in sys.stdin: + if "/status" in line: + break + print("Credits: 42 credits", flush=True) + print("5h limit: [#####] 88% left", flush=True) + print("Weekly limit: [##] 75% left", flush=True) + """ + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-fallback-stub-\(UUID().uuidString)", isDirectory: false) + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } +} diff --git a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift index 9b789cde29..be6f954e29 100644 --- a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift +++ b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift @@ -27,6 +27,17 @@ struct CodexUserFacingErrorTests { #expect(store.userFacingError(for: .codex) == "Codex usage is temporarily unavailable. Try refreshing.") } + @Test + func `decode mismatch codex error is sanitized`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-decode-mismatch") + store.errors[.codex] = + "Codex connection failed: failed to fetch codex rate limits: " + + "Decode error for https://chatgpt.com/backend-api/wham/usage: " + + "unknown variant `prolite`, expected one of `guest`, `free`, `go`, `plus`, `pro`" + + #expect(store.userFacingError(for: .codex) == "Codex usage is temporarily unavailable. Try refreshing.") + } + @Test func `cached credits failure preserves cached suffix while sanitizing body`() { let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-cached-credits") diff --git a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift index 8b17597249..3fbe446757 100644 --- a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift @@ -98,6 +98,20 @@ struct OpenAIDashboardParserTests { #expect(OpenAIDashboardParser.parsePlanFromHTML(html: html) == "Plus") } + @Test + func `parses prolite plan from client bootstrap`() { + let html = """ + + + + + + """ + #expect(OpenAIDashboardParser.parsePlanFromHTML(html: html) == "Pro Lite") + } + @Test func `parses credit events from table rows`() { let rows: [[String]] = [ From c56ee9b13ff487672452a12810e5c0795371e9f8 Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Thu, 16 Apr 2026 13:23:37 +0530 Subject: [PATCH 0178/1239] Add Abacus AI provider (#729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(abacus): add Abacus AI provider with cookie-based usage fetching Add support for Abacus AI (ChatLLM/RouteLLM) as a new provider. Uses browser cookie authentication against the describeUser API endpoint to fetch compute point usage. Values are in centi-credits (divided by 100 for display). Primary window shows monthly credit usage as percentage, secondary window shows 7-day usage. Reset date derived from lastBilledAt + 1 month. * fix(abacus): fix usage display formatting and match Claude pattern - Fix credits format string (Swift String(format:) has no comma flag; use NumberFormatter for thousands separators) - Remove secondary weekly window (Abacus has monthly billing only) - Show credits detail below gauge (follow Warp/Kilo pattern for resetDescription rendering) - Add pace/reserve/deficit estimate on primary monthly window - Remove inactive status page URL (abacus.statuspage.io is inactive) - Hide account email/org from menu (not relevant for display) - Set windowMinutes to 30 days so pace calculation works correctly * feat(abacus): add pace tick and detail lines to card view - Show pace indicator tick (green/red) on the primary gauge bar - Add reserve/deficit line below gauge with pace right label - Show credits used/total as detail text below the gauge - Restore account identity (email, org, plan tier) in card header * fix(abacus): use correct API endpoints for credits and billing date Switch from describeUser (stale centi-credit values, no billing date) to _getOrganizationComputePoints (accurate credits in real units) and _getBillingInfo (exact nextBillingDate and subscription tier). Both endpoints are fetched concurrently. * fix(abacus): validate session cookies and preserve API errors Skip browser cookie sets that only contain anonymous/marketing cookies by checking for session/auth cookie names before accepting a set. This prevents using invalid cookies when a valid session exists in a later browser profile. Separate the cookie import and API fetch try blocks so that network, parse, or auth errors from the API are not misreported as "Browser cookie import failed" and incorrectly replaced with noSessionCookie. * fix(abacus): fix compilation after UsagePaceText API refactor Update three call sites that still used the removed UsagePaceText.weeklySummary(provider:window:) and weeklyPaceDetail(provider:window:now:showUsed:) signatures. MenuDescriptor and MenuCardView now use the store.weeklyPace()+ UsagePaceText.weeklySummary(pace:) pattern. StatusItemController computes weeklyPace from primary for Abacus (no secondary window). * fix(abacus): fix menu bar metric options and pace indicator Remove Secondary (Weekly) from the menu bar metric picker since Abacus has no secondary window; only Automatic and Primary (Credits) are valid options. Enable pace computation for Abacus in weeklyPace() so the bar tick indicator (reserve/deficit/on-pace) is rendered correctly. Abacus uses the simple UsagePace.weekly() path with the monthly window already set in RateWindow (30 days). * docs(abacus): add provider documentation and update provider listings Add docs/abacus.md with setup, API details, and troubleshooting for the Abacus AI provider. Add Abacus AI entry to docs/providers.md strategy table and detailed section. Add Abacus AI to README provider list. * test(abacus): add unit tests for Abacus AI provider Add AbacusProviderTests.swift with 23 tests in 3 suites covering: - AbacusDescriptorTests: provider metadata, source modes, CLI config - AbacusUsageSnapshotTests: credit conversion, formatting, edge cases - AbacusErrorTests: error description completeness Uses the Swift Testing framework (@Test + #expect) matching the convention established by recent upstream provider tests. * fix(abacus): hoist paceWindow binding out of if-expression Swift 6.2.4 does not allow let bindings inside the else branch of an if-expression. Move the paceWindow computation before the if-expression to fix compilation. * fix(abacus): tighten session cookie matching to avoid false positives Replace overly broad "id" substring pattern with exact known cookie names (sessionid, auth_token, etc.) checked first, then conservative substring fallback. Prevents selecting unauthenticated cookie sets from browsers that only have analytics cookies for abacus.ai. * fix(abacus): add manual cookie header field to settings UI settingsFields was returning empty array, making Manual cookie mode non-functional. Add secure text field bound to abacusCookieHeader, visible only when cookie source is set to manual, with link to open the Abacus AI dashboard. * fix(abacus): route cookie reads through BrowserCookieAccessGate Use codexBarRecords instead of records directly so the access gate cooldown is consulted before attempting Chromium keychain reads. Also detect session-expired API error payloads and map them to .sessionExpired so cached cookies are properly evicted. * fix(abacus): suppress reset line when billing date unavailable Clear primaryResetText when resetsAt is nil to prevent displaying a misleading "Resets ..." line that duplicates the credit detail. * fix(abacus): remove CSRF from session cookies, propagate billing auth errors Remove csrftoken/csrf_token from knownSessionCookieNames — CSRF tokens exist in anonymous jars and caused false-positive session detection. Also replace try? on billing fetch with explicit error handling that propagates auth errors while tolerating other failures. * fix(abacus): tighten cookie matching and retry on auth failure Remove "token" from fallback substrings (matched csrftoken). Add excludedCookiePrefixes to reject csrf/analytics cookies even on substring match. Change importSession to importSessions returning all candidates so fetchUsage can try each in turn — stale session in first source no longer blocks valid ones further down. * refactor(abacus): comprehensive provider overhaul addressing review feedback Split AbacusUsageFetcher.swift (396 lines) into 4 files matching the Kimi/Perplexity provider pattern: - AbacusCookieImporter.swift — browser cookie extraction - AbacusUsageFetcher.swift — API fetching logic - AbacusUsageSnapshot.swift — data model + conversion - AbacusUsageError.swift — error types Bug fixes: - Pin NumberFormatter locale to en_US (was locale-dependent) - Cached cookie parse failures now clear cache and fall through to fresh browser import instead of aborting - Session loop retries on all recoverable errors (auth + parse), not just auth — prevents one stale source blocking valid ones - Preserve last error on loop exhaustion instead of generic .noSessionCookie - Log billing fetch failures instead of silently discarding - Truncate HTTP response body in error messages (security) - Capture JSON parse error details instead of using try? - Throw parseFailed when critical credit fields missing instead of returning hollow 0% snapshot Architecture alignment: - Add structured CodexBarLog logging (abacusCookie, abacusUsage) - Use cookieImportCandidates(using:) for browser filtering - Accept BrowserDetection parameter for testability - Add hasSession() convenience for isAvailable() check - Improve isAvailable() to probe for actual session existence - Add import AppKit (was implicit via SwiftUI) * fix(abacus): derive pace window from actual billing cycle length Use Calendar to compute the real billing cycle duration (one calendar month before resetsAt) instead of hardcoding 30 days. Falls back to 30-day approximation when resetsAt is nil. Fixes pace drift on 28/31-day months. * fix(abacus): require both credit fields and fix menu bar pace display Require both totalComputePoints and computePointsLeft in API response — partial data no longer silently shows 0% usage. Fall back to primary window for menu bar pace calculation when secondary is nil, so Abacus pace renders in Pace/Both display modes. * fix(abacus): restrict primary-window pace fallback to Abacus only The supportsWeeklyPace guard was too broad — it applied to all pace-capable providers, causing Claude's 5-hour primary window to be misused for pace calculation. Scope the fallback to .abacus which is the only provider using primary window for pace. * fix(abacus): correct dashboard URL in manual cookie settings action Point Open Dashboard to /chatllm/admin/compute-points-usage matching the provider's configured dashboardURL. * fix(abacus): default cookie import to Chrome-only per AGENTS.md AGENTS.md mandates Chrome-only cookie imports by default. Change browserCookieOrder from defaultImportOrder (all browsers) to [.chrome] to avoid unnecessary browser/keychain prompts. * fix(abacus): comprehensive quality pass from multi-agent review Critical fixes: - Remove .parseFailed from isRecoverable (deterministic error, retrying other sessions gives same result) - Fix NumberFormatter thread safety (allocate per call instead of mutating shared static state) High fixes: - Add public init on SessionInfo (API surface parity with peers) - Consistent self. usage throughout fetcher (was mixing Self/self) - Make notSupported public (thrown from public function) - Add Equatable conformance to AbacusUsageError (peer pattern) - Simplify isAuthRelated to delegate to isRecoverable Medium fixes: - Add MARK sections throughout all files - Add #if canImport(FoundationNetworking) guard (Linux compat) - Add explanatory comment on pace fallback in StatusItemController Tests: - Add isRecoverable/isAuthRelated classification tests for all five error cases (28 tests total, 4 suites) * fix(abacus): classify unauthorized JSON errors as auth failures Add unauthorized/unauthenticated/forbidden to the API error keyword detection so JSON-level auth failures (HTTP 200 with success:false) throw .unauthorized instead of .parseFailed. This enables the multi-session fallthrough for stale cookies that return auth errors in the JSON payload rather than via HTTP status codes. * fix(abacus): Chrome-first cookie import with multi-browser fallback Reconcile AGENTS.md Chrome-only guideline with real-world need to support Safari/Firefox users. Match OpenCodeCookieImporter pattern: - Descriptor browserCookieOrder: full defaultImportOrder (all browsers) - AbacusCookieImporter.importSessions: preferredBrowsers: [.chrome] default, empty array falls back to the full descriptor order - fetchUsage: try Chrome first, broaden to all browsers if Chrome yields no sessions - isAvailable: same Chrome-first then fallback probe Users with Chrome get single-browser probe (AGENTS.md compliant); Safari/Firefox users still get their cookies imported when Chrome is absent or empty. * fix(abacus): fall back to all browsers after Chrome auth exhaustion Previously, the all-browsers fallback only triggered when Chrome import threw. If Chrome cookies existed but all returned expired/ unauthorized, the loop exhausted Chrome candidates and returned an auth error without ever trying Safari/Firefox. Extract browser-tier logic into tryFetchFromBrowsers helper; call it first with [.chrome], then with [] (all browsers) if Chrome yielded no valid snapshot for any reason (import failure OR all sessions failing with recoverable errors). * fix(abacus): register in TokenAccountSupportCatalog and bound billing timeout Two issues caught by Codex review: - AbacusSettingsStore referenced TokenAccountSupportCatalog.support( for: .abacus) but no catalog entry existed, so token account overrides were dead code. Add cookie-header injection entry matching the pattern used by other cookie-based providers. - Billing info fetch shared the full 15s timeout with credits. Cap it at min(timeout, 5s) so a slow/flaky billing endpoint doesn't delay credit rendering — billing is optional anyway. * Improve Abacus web fetch error handling * Harden Abacus web fetch fallbacks * Fix Abacus lint failure * Fix provider order test for Abacus * Fallback after stale Abacus cache failures * Handle Abacus fetch concurrency with sendable task results --------- Co-authored-by: Christian C. Berclaz --- README.md | 3 +- Sources/CodexBar/MenuCardView.swift | 36 +- Sources/CodexBar/MenuDescriptor.swift | 14 +- .../CodexBar/PreferencesProvidersPane.swift | 12 +- .../Abacus/AbacusProviderImplementation.swift | 100 ++++++ .../Abacus/AbacusSettingsStore.swift | 61 ++++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-abacus.svg | 18 + .../StatusItemController+Animation.swift | 5 +- .../CodexBar/StatusItemController+Menu.swift | 4 +- .../CodexBar/UsageStore+HistoricalPace.swift | 2 +- Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 13 +- .../CodexBarCore/Logging/LogCategories.swift | 2 + .../Abacus/AbacusCookieImporter.swift | 134 +++++++ .../Abacus/AbacusProviderDescriptor.swift | 92 +++++ .../Providers/Abacus/AbacusUsageError.swift | 67 ++++ .../Providers/Abacus/AbacusUsageFetcher.swift | 338 ++++++++++++++++++ .../Abacus/AbacusUsageSnapshot.swift | 77 ++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 27 +- .../CodexBarCore/Providers/Providers.swift | 2 + .../TokenAccountSupportCatalog+Data.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + Tests/CodexBarTests/AbacusProviderTests.swift | 289 +++++++++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 1 + docs/abacus.md | 67 ++++ docs/providers.md | 11 +- 30 files changed, 1370 insertions(+), 22 deletions(-) create mode 100644 Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-abacus.svg create mode 100644 Sources/CodexBarCore/Providers/Abacus/AbacusCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Abacus/AbacusUsageError.swift create mode 100644 Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/Abacus/AbacusUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/AbacusProviderTests.swift create mode 100644 docs/abacus.md diff --git a/README.md b/README.md index 78d34d3e0c..d0e0474de1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodexBar 🎚️ - May your tokens never run out. -Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, and Perplexity limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, and Abacus AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot @@ -47,6 +47,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking. - [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking. - [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers. +- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index a47ecd6cce..4d9c05127b 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1057,6 +1057,34 @@ extension UsageMenuCardView.Model { if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil { primaryResetText = nil } + // Abacus: show credits as detail, compute pace on the primary monthly window + var primaryDetailLeft: String? + var primaryDetailRight: String? + var primaryPacePercent: Double? + var primaryPaceOnTop = true + if input.provider == .abacus { + if let detail = primary.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + primaryDetailText = detail + } + if primary.resetsAt == nil { + primaryResetText = nil + } + if let pace = input.weeklyPace { + let paceDetail = Self.weeklyPaceDetail( + window: primary, + now: input.now, + pace: pace, + showUsed: input.usageBarsShowUsed) + if let paceDetail { + primaryDetailLeft = paceDetail.leftLabel + primaryDetailRight = paceDetail.rightLabel + primaryPacePercent = paceDetail.pacePercent + primaryPaceOnTop = paceDetail.paceOnTop + } + } + } return Metric( id: "primary", title: input.metadata.sessionLabel, @@ -1065,10 +1093,10 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, resetText: primaryResetText, detailText: primaryDetailText, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) + detailLeftText: primaryDetailLeft, + detailRightText: primaryDetailRight, + pacePercent: primaryPacePercent, + paceOnTop: primaryPaceOnTop) } private static func secondaryMetric( diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 5b2b4e5ceb..46e0a2c6ae 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -146,9 +146,9 @@ struct MenuDescriptor { if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle if let primary = snap.primary { - let primaryWindow = if provider == .warp || provider == .kilo { - // Warp/Kilo primary uses resetDescription for non-reset detail (e.g., "Unlimited", "X/Y credits"). - // Avoid rendering it as a "Resets ..." line. + let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus { + // Warp/Kilo/Abacus primary uses resetDescription for non-reset detail + // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. RateWindow( usedPercent: primary.usedPercent, windowMinutes: primary.windowMinutes, @@ -163,12 +163,18 @@ struct MenuDescriptor { window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) - if provider == .warp || provider == .kilo, + if provider == .warp || provider == .kilo || provider == .abacus, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { entries.append(.text(detail, .secondary)) } + if provider == .abacus, + let pace = store.weeklyPace(provider: provider, window: primary) + { + let paceSummary = UsagePaceText.weeklySummary(pace: pace) + entries.append(.text(paceSummary, .secondary)) + } } if let weekly = snap.secondary { let weeklyResetOverride: String? = { diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 9e8a636405..7e39ee0e2d 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -452,6 +452,14 @@ struct ProvidersPane: View { id: MenuBarMetricPreference.primary.rawValue, title: "Primary (API key limit)"), ] + } else if provider == .abacus { + let metadata = self.store.metadata(for: provider) + options = [ + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.primary.rawValue, + title: "Primary (\(metadata.sessionLabel))"), + ] } else { let metadata = self.store.metadata(for: provider) let snapshot = self.store.snapshot(for: provider) @@ -535,12 +543,14 @@ struct ProvidersPane: View { tokenError = nil } + // Abacus uses primary for monthly credits (no secondary window) + let paceWindow = provider == .abacus ? snapshot?.primary : snapshot?.secondary let weeklyPace = if let codexProjection, let weekly = codexProjection.rateWindow(for: .weekly) { self.store.weeklyPace(provider: provider, window: weekly, now: now) } else { - snapshot?.secondary.flatMap { window in + paceWindow.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } } diff --git a/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift new file mode 100644 index 0000000000..1e0cdeb9f4 --- /dev/null +++ b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift @@ -0,0 +1,100 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct AbacusProviderImplementation: ProviderImplementation { + let id: UsageProvider = .abacus + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.abacusCookieSource + _ = settings.abacusCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .abacus(context.settings.abacusSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.abacusCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.abacusCookieSource != .manual { + settings.abacusCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.abacusCookieSource.rawValue }, + set: { raw in + context.settings.abacusCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.abacusCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from the Abacus AI dashboard.", + off: "Abacus AI cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "abacus-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.load(provider: .abacus) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "abacus-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: \u{2026}\n\nor paste a cURL capture from the Abacus AI dashboard", + binding: context.stringBinding(\.abacusCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "abacus-open-dashboard", + title: "Open Dashboard", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://apps.abacus.ai/chatllm/admin/compute-points-usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.abacusCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift b/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift new file mode 100644 index 0000000000..d5e5c3e30c --- /dev/null +++ b/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift @@ -0,0 +1,61 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var abacusCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .abacus)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .abacus) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .abacus, field: "cookieHeader", value: newValue) + } + } + + var abacusCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .abacus, fallback: .auto) } + set { + self.updateProviderConfig(provider: .abacus) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .abacus, field: "cookieSource", value: newValue.rawValue) + } + } +} + +extension SettingsStore { + func abacusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .AbacusProviderSettings { + ProviderSettingsSnapshot.AbacusProviderSettings( + cookieSource: self.abacusSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.abacusSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func abacusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.abacusCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .abacus), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .abacus, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func abacusSnapshotCookieSource(tokenOverride _: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.abacusCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .abacus), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .abacus).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index c2b008592d..6dd8c45c41 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -38,6 +38,7 @@ enum ProviderImplementationRegistry { case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() case .perplexity: PerplexityProviderImplementation() + case .abacus: AbacusProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-abacus.svg b/Sources/CodexBar/Resources/ProviderIcon-abacus.svg new file mode 100644 index 0000000000..468bb3dfe4 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-abacus.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 468f2d5666..4392686e35 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -559,7 +559,10 @@ extension StatusItemController { case .percent: pace = nil case .pace, .both: - let weeklyWindow = codexProjection?.rateWindow(for: .weekly) ?? snapshot?.secondary + let weeklyWindow = codexProjection?.rateWindow(for: .weekly) + ?? snapshot?.secondary + // Abacus has no secondary window; pace is computed on primary monthly credits + ?? (provider == .abacus ? snapshot?.primary : nil) pace = weeklyWindow.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 88055ad39e..55ff40b7ad 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1339,12 +1339,14 @@ extension StatusItemController { let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto + // Abacus uses primary for monthly credits (no secondary window) + let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary let weeklyPace = if let codexProjection, let weekly = codexProjection.rateWindow(for: .weekly) { self.store.weeklyPace(provider: target, window: weekly, now: now) } else { - snapshot?.secondary.flatMap { window in + paceWindow.flatMap { window in self.store.weeklyPace(provider: target, window: window, now: now) } } diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index 08b38a5b5f..cc26cd0bf5 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -5,7 +5,7 @@ import Foundation extension UsageStore { func supportsWeeklyPace(for provider: UsageProvider) -> Bool { switch provider { - case .codex, .claude, .opencode: + case .codex, .claude, .opencode, .abacus: true default: false diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 354147954b..d2af04b217 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -876,7 +876,7 @@ extension UsageStore { let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains, .perplexity: + .kimik2, .jetbrains, .perplexity, .abacus: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index d639019cfa..e43073e820 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -186,6 +186,13 @@ struct TokenAccountCLIContext { perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) + case .abacus: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) + return self.makeSnapshot( + abacus: ProviderSettingsSnapshot.AbacusProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: return nil } @@ -207,7 +214,8 @@ struct TokenAccountCLIContext { amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil, ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil, - perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil, + abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -225,7 +233,8 @@ struct TokenAccountCLIContext { amp: amp, ollama: ollama, jetbrains: jetbrains, - perplexity: perplexity) + perplexity: perplexity, + abacus: abacus) } private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) -> diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 5f6cf92178..90a7175d23 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -1,4 +1,6 @@ public enum LogCategories { + public static let abacusCookie = "abacus-cookie" + public static let abacusUsage = "abacus-usage" public static let amp = "amp" public static let antigravity = "antigravity" public static let app = "app" diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusCookieImporter.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusCookieImporter.swift new file mode 100644 index 0000000000..c32c8bbb39 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusCookieImporter.swift @@ -0,0 +1,134 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +// MARK: - Abacus Cookie Importer + +public enum AbacusCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.abacusCookie) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["abacus.ai", "apps.abacus.ai"] + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.abacus]?.browserCookieOrder ?? Browser.defaultImportOrder + + /// Exact cookie names known to carry Abacus session state. + /// CSRF tokens are deliberately excluded — they are present in anonymous + /// jars and do not indicate an authenticated session. + private static let knownSessionCookieNames: Set = [ + "sessionid", "session_id", "session_token", + "auth_token", "access_token", + ] + + /// Substrings that indicate a session or auth cookie (applied only when + /// no exact-name match is found). Deliberately excludes overly broad + /// patterns like "id" and "token" that match analytics/CSRF cookies. + private static let sessionCookieSubstrings = ["session", "auth", "sid", "jwt"] + + /// Cookie name prefixes that indicate a non-session cookie even when a + /// substring match would otherwise accept it (e.g. "csrftoken"). + private static let excludedCookiePrefixes = ["csrf", "_ga", "_gid", "tracking", "analytics"] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + } + + /// Returns all candidate sessions across browsers/profiles, ordered by + /// import priority. Callers should try each in turn so that a stale + /// session in the first source doesn't block a valid one further down. + /// + /// Defaults to Chrome-only per AGENTS.md guideline. Pass an empty + /// `preferredBrowsers` list to fall back to the full descriptor-defined + /// import order (Safari, Firefox, etc.) when Chrome has no cookies. + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + var candidates: [SessionInfo] = [] + let installedBrowsers = preferredBrowsers.isEmpty + ? self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + : preferredBrowsers.cookieImportCandidates(using: browserDetection) + + for browserSource in installedBrowsers { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: { msg in self.emit(msg, logger: logger) }) + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + + guard Self.containsSessionCookie(httpCookies) else { + self.emit( + "Skipping \(source.label): no session cookie found", + logger: logger) + continue + } + + self.emit( + "Found \(httpCookies.count) session cookies in \(source.label)", + logger: logger) + candidates.append(SessionInfo(cookies: httpCookies, sourceLabel: source.label)) + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !candidates.isEmpty else { + throw AbacusUsageError.noSessionCookie + } + return candidates + } + + /// Cheap check for whether any browser has an Abacus session cookie, + /// used by the fetch strategy's `isAvailable()`. + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) -> Bool + { + do { + return try !self.importSessions( + browserDetection: browserDetection, + preferredBrowsers: preferredBrowsers, + logger: logger).isEmpty + } catch { + return false + } + } + + /// Returns `true` if the cookie set contains at least one cookie whose name + /// indicates session or authentication state. Checks exact known names + /// first, then falls back to conservative substring matching. + private static func containsSessionCookie(_ cookies: [HTTPCookie]) -> Bool { + cookies.contains { cookie in + let lower = cookie.name.lowercased() + if self.knownSessionCookieNames.contains(lower) { return true } + if self.excludedCookiePrefixes.contains(where: { lower.hasPrefix($0) }) { return false } + return self.sessionCookieSubstrings.contains { lower.contains($0) } + } + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[abacus-cookie] \(message)") + self.log.debug(message) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift new file mode 100644 index 0000000000..35f5b63bf3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift @@ -0,0 +1,92 @@ +import CodexBarMacroSupport +import Foundation + +#if os(macOS) +import SweetCookieKit +#endif + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum AbacusProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .abacus, + metadata: ProviderMetadata( + id: .abacus, + displayName: "Abacus AI", + sessionLabel: "Credits", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Abacus AI usage", + cliName: "abacusai", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://apps.abacus.ai/chatllm/admin/compute-points-usage", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .abacus, + iconResourceName: "ProviderIcon-abacus", + color: ProviderColor(red: 56 / 255, green: 189 / 255, blue: 248 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Abacus AI cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [AbacusWebFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "abacusai", + aliases: ["abacus-ai"], + versionDetector: nil)) + } +} + +// MARK: - Fetch Strategy + +struct AbacusWebFetchStrategy: ProviderFetchStrategy { + let id: String = "abacus.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.settings?.abacus?.cookieSource != .off + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let manual: String? + if context.settings?.abacus?.cookieSource == .manual { + guard let header = Self.manualCookieHeader(from: context) else { + throw AbacusUsageError.noSessionCookie + } + manual = header + } else { + manual = nil + } + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.abacusUsage).verbose(msg) } + : nil + let snap = try await AbacusUsageFetcher.fetchUsage( + cookieHeaderOverride: manual, + browserDetection: context.browserDetection, + timeout: context.webTimeout, + logger: logger) + return self.makeResult( + usage: snap.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.abacus?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(context.settings?.abacus?.manualCookieHeader) + } +} diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageError.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageError.swift new file mode 100644 index 0000000000..0f5c6cc532 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageError.swift @@ -0,0 +1,67 @@ +import Foundation + +// MARK: - Abacus Usage Error + +public enum AbacusUsageError: LocalizedError, Sendable, Equatable { + case noSessionCookie + case sessionExpired + case networkError(String) + case parseFailed(String) + case unauthorized + + /// Whether this error indicates an authentication/session problem that + /// should trigger cache eviction. + public var isRecoverable: Bool { + switch self { + case .unauthorized, .sessionExpired: true + default: false + } + } + + public var isAuthRelated: Bool { + self.isRecoverable + } + + /// Whether browser-import scanning should continue to later sessions after + /// this failure. Imported sessions can differ by profile/browser, so we keep + /// scanning on per-session fetch failures and surface the first one only if + /// every candidate is exhausted. + var shouldTryNextImportedSession: Bool { + switch self { + case .unauthorized, .sessionExpired, .networkError, .parseFailed: true + case .noSessionCookie: false + } + } + + /// Whether a cached cookie header should be evicted before falling back to + /// a fresh browser import. Parse/auth failures usually indicate that the + /// cached session is stale or no longer accepted. + var shouldClearCachedCookie: Bool { + switch self { + case .unauthorized, .sessionExpired, .parseFailed: true + case .networkError, .noSessionCookie: false + } + } + + public var errorDescription: String? { + switch self { + case .noSessionCookie: + "No Abacus AI session found. Please log in to apps.abacus.ai in your browser " + + "or paste a Cookie header in manual mode." + case .sessionExpired: + "Abacus AI session expired. Please log in again." + case let .networkError(msg): + "Abacus AI API error: \(msg)" + case let .parseFailed(msg): + "Could not parse Abacus AI usage: \(msg)" + case .unauthorized: + "Unauthorized. Please log in to Abacus AI." + } + } +} + +#if !os(macOS) +extension AbacusUsageError { + public static let notSupported = AbacusUsageError.networkError("Abacus AI is only supported on macOS.") +} +#endif diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift new file mode 100644 index 0000000000..1042d9f60c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift @@ -0,0 +1,338 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(macOS) +import SweetCookieKit + +// MARK: - Abacus Usage Fetcher + +public enum AbacusUsageFetcher { + private struct BrowserFetchRequest { + let browserDetection: BrowserDetection + let preferredBrowsers: [Browser] + let label: String + let timeout: TimeInterval + let logger: ((String) -> Void)? + } + + /// Parsed JSON dictionaries are treated as immutable snapshots here and are + /// only moved between sibling fetch tasks before being consumed locally. + private struct JSONDictionaryBox: @unchecked Sendable { + let value: [String: Any] + } + + private static let log = CodexBarLog.logger(LogCategories.abacusUsage) + private static let computePointsURL = + URL(string: "https://apps.abacus.ai/api/_getOrganizationComputePoints")! + private static let billingInfoURL = + URL(string: "https://apps.abacus.ai/api/_getBillingInfo")! + + public static func fetchUsage( + cookieHeaderOverride: String? = nil, + browserDetection: BrowserDetection = BrowserDetection(), + timeout: TimeInterval = 15.0, + logger: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot + { + // Manual cookie header — no fallback, errors propagate directly + if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { + self.emit("Using manual cookie header", logger: logger) + return try await self.fetchWithCookieHeader(override, timeout: timeout, logger: logger) + } + + // Cached cookie header — fall back to a fresh browser import when the + // cached session is rejected or looks stale. + if let cached = CookieHeaderCache.load(provider: .abacus), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + self.emit("Using cached cookie header from \(cached.sourceLabel)", logger: logger) + do { + return try await self.fetchWithCookieHeader( + cached.cookieHeader, timeout: timeout, logger: logger) + } catch let error as AbacusUsageError where error.shouldTryNextImportedSession { + if error.shouldClearCachedCookie { + CookieHeaderCache.clear(provider: .abacus) + self.emit( + "Cached cookie failed (\(error.localizedDescription)); cleared, trying fresh import", + logger: logger) + } else { + self.emit( + "Cached cookie failed (\(error.localizedDescription)); trying fresh import", + logger: logger) + } + } + } + + // Fresh browser import — try Chrome first (AGENTS.md default), then broaden + // to all browsers if Chrome has no sessions OR if every imported Chrome + // session is exhausted without a successful fetch. + var lastError: AbacusUsageError = .noSessionCookie + if let snapshot = try await self.tryFetchFromBrowsers( + BrowserFetchRequest( + browserDetection: browserDetection, + preferredBrowsers: [.chrome], + label: "Chrome", + timeout: timeout, + logger: logger), + lastError: &lastError) + { + return snapshot + } + + self.emit("Chrome sessions exhausted; falling back to all browsers", logger: logger) + if let snapshot = try await self.tryFetchFromBrowsers( + BrowserFetchRequest( + browserDetection: browserDetection, + preferredBrowsers: [], + label: "all browsers", + timeout: timeout, + logger: logger), + lastError: &lastError) + { + return snapshot + } + + throw lastError + } + + /// Tries to import sessions from `preferredBrowsers` and fetch usage. Returns + /// the snapshot on success, nil if no sessions were available or every + /// imported session was exhausted without success. + private static func tryFetchFromBrowsers( + _ request: BrowserFetchRequest, + lastError: inout AbacusUsageError) async throws -> AbacusUsageSnapshot? + { + let sessions: [AbacusCookieImporter.SessionInfo] + do { + sessions = try AbacusCookieImporter.importSessions( + browserDetection: request.browserDetection, + preferredBrowsers: request.preferredBrowsers, + logger: request.logger) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(request.label) cookie import failed: \(error.localizedDescription)", + logger: request.logger) + return nil + } + + for session in sessions { + self.emit("Trying cookies from \(session.sourceLabel)", logger: request.logger) + do { + let snapshot = try await self.fetchWithCookieHeader( + session.cookieHeader, + timeout: request.timeout, + logger: request.logger) + CookieHeaderCache.store( + provider: .abacus, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return snapshot + } catch let error as AbacusUsageError where error.shouldTryNextImportedSession { + self.emit( + "\(session.sourceLabel): \(error.localizedDescription), trying next source", + logger: request.logger) + lastError = error + continue + } catch { + self.emit( + "\(session.sourceLabel): \(error.localizedDescription), trying next source", + logger: request.logger) + lastError = .networkError(error.localizedDescription) + continue + } + } + return nil + } + + // MARK: - API Requests + + private static func fetchWithCookieHeader( + _ cookieHeader: String, + timeout: TimeInterval, + logger: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot + { + enum FetchPart: Sendable { + case computePoints(JSONDictionaryBox) + case billingInfoSuccess(JSONDictionaryBox) + case billingInfoFailure(String) + } + + // Fetch compute points (required, full timeout) and billing info + // (optional, shorter budget) concurrently. Billing is bounded so a + // slow/flaky billing endpoint can't delay credit rendering. + let billingBudget = min(timeout, 5.0) + + var computePointsResult: [String: Any]? + var billingInfoResult: [String: Any] = [:] + + try await withThrowingTaskGroup(of: FetchPart.self) { group in + group.addTask { + let result = try await self.fetchJSON( + url: self.computePointsURL, + method: "GET", + cookieHeader: cookieHeader, + timeout: timeout) + return .computePoints(JSONDictionaryBox(value: result)) + } + group.addTask { + do { + let result = try await self.fetchJSON( + url: self.billingInfoURL, + method: "POST", + cookieHeader: cookieHeader, + timeout: billingBudget) + return .billingInfoSuccess(JSONDictionaryBox(value: result)) + } catch { + return .billingInfoFailure(error.localizedDescription) + } + } + + while let result = try await group.next() { + switch result { + case let .computePoints(value): + computePointsResult = value.value + case let .billingInfoSuccess(value): + billingInfoResult = value.value + case let .billingInfoFailure(message): + self.emit( + "Billing info fetch failed: \(message); credits shown without plan/reset", + logger: logger) + } + } + } + + guard let computePointsResult else { + throw AbacusUsageError.networkError("Abacus compute points fetch did not complete") + } + + return try self.parseResults(computePoints: computePointsResult, billingInfo: billingInfoResult) + } + + private static func fetchJSON( + url: URL, method: String, cookieHeader: String, timeout: TimeInterval) async throws -> [String: Any] + { + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + if method == "POST" { + request.httpBody = Data("{}".utf8) + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AbacusUsageError.networkError("Invalid response from \(url.lastPathComponent)") + } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw AbacusUsageError.unauthorized + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + throw AbacusUsageError.networkError("HTTP \(httpResponse.statusCode): \(body)") + } + + let parsed: Any + do { + parsed = try JSONSerialization.jsonObject(with: data) + } catch { + let preview = String(data: data.prefix(200), encoding: .utf8) ?? "" + throw AbacusUsageError.parseFailed( + "\(url.lastPathComponent): \(error.localizedDescription) — preview: \(preview)") + } + + guard let root = parsed as? [String: Any] else { + throw AbacusUsageError.parseFailed("\(url.lastPathComponent): top-level JSON is not a dictionary") + } + + guard root["success"] as? Bool == true, + let result = root["result"] as? [String: Any] + else { + let errorMsg = (root["error"] as? String ?? "Unknown error").lowercased() + if errorMsg.contains("expired") || errorMsg.contains("session") + || errorMsg.contains("login") || errorMsg.contains("authenticate") + || errorMsg.contains("unauthorized") || errorMsg.contains("unauthenticated") + || errorMsg.contains("forbidden") + { + throw AbacusUsageError.unauthorized + } + throw AbacusUsageError.parseFailed("\(url.lastPathComponent): \(errorMsg)") + } + + return result + } + + // MARK: - Parsing + + private static func parseResults( + computePoints: [String: Any], billingInfo: [String: Any]) throws -> AbacusUsageSnapshot + { + let totalCredits = self.double(from: computePoints["totalComputePoints"]) + let creditsLeft = self.double(from: computePoints["computePointsLeft"]) + + guard let totalCredits, let creditsLeft else { + let keys = computePoints.keys.sorted().joined(separator: ", ") + throw AbacusUsageError.parseFailed( + "Missing credit fields in compute points response. Keys: [\(keys)]") + } + + let creditsUsed = totalCredits - creditsLeft + + let nextBillingDate = billingInfo["nextBillingDate"] as? String + let currentTier = billingInfo["currentTier"] as? String + let resetsAt = self.parseDate(nextBillingDate) + + return AbacusUsageSnapshot( + creditsUsed: creditsUsed, + creditsTotal: totalCredits, + resetsAt: resetsAt, + planName: currentTier) + } + + private static func double(from value: Any?) -> Double? { + if let d = value as? Double { return d } + if let i = value as? Int { return Double(i) } + if let n = value as? NSNumber { return n.doubleValue } + return nil + } + + private static func parseDate(_ isoString: String?) -> Date? { + guard let isoString else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: isoString) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: isoString) + } + + // MARK: - Logging + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[abacus] \(message)") + self.log.debug(message) + } +} + +#else + +// MARK: - Abacus (Unsupported) + +public enum AbacusUsageFetcher { + public static func fetchUsage( + cookieHeaderOverride _: String? = nil, + browserDetection _: BrowserDetection = BrowserDetection(), + timeout _: TimeInterval = 15.0, + logger _: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot + { + throw AbacusUsageError.notSupported + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageSnapshot.swift new file mode 100644 index 0000000000..018684487a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageSnapshot.swift @@ -0,0 +1,77 @@ +import Foundation + +// MARK: - Abacus Usage Snapshot + +public struct AbacusUsageSnapshot: Sendable { + public let creditsUsed: Double? + public let creditsTotal: Double? + public let resetsAt: Date? + public let planName: String? + + public init( + creditsUsed: Double? = nil, + creditsTotal: Double? = nil, + resetsAt: Date? = nil, + planName: String? = nil) + { + self.creditsUsed = creditsUsed + self.creditsTotal = creditsTotal + self.resetsAt = resetsAt + self.planName = planName + } + + public func toUsageSnapshot() -> UsageSnapshot { + let percentUsed: Double = if let used = self.creditsUsed, let total = self.creditsTotal, total > 0 { + (used / total) * 100.0 + } else { + 0 + } + + let resetDesc: String? = if let used = self.creditsUsed, let total = self.creditsTotal { + "\(Self.formatCredits(used)) / \(Self.formatCredits(total)) credits" + } else { + nil + } + + // Derive window from actual billing cycle when possible. + // Assume the cycle started one calendar month before resetsAt. + let windowMinutes: Int = if let resetDate = self.resetsAt, + let cycleStart = Calendar.current.date(byAdding: .month, value: -1, to: resetDate) + { + max(1, Int(resetDate.timeIntervalSince(cycleStart) / 60)) + } else { + 30 * 24 * 60 + } + + let primary = RateWindow( + usedPercent: percentUsed, + windowMinutes: windowMinutes, + resetsAt: self.resetsAt, + resetDescription: resetDesc) + + let identity = ProviderIdentitySnapshot( + providerID: .abacus, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.planName) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + } + + // MARK: - Formatting + + /// Thread-safe credit formatting — allocates per call to avoid shared mutable state. + private static func formatCredits(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: "en_US") + formatter.maximumFractionDigits = value >= 1000 ? 0 : 1 + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index c55d0a1945..6fb994efcc 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -78,6 +78,7 @@ public enum ProviderDescriptorRegistry { .openrouter: OpenRouterProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, .perplexity: PerplexityProviderDescriptor.descriptor, + .abacus: AbacusProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index d0e6be9406..63aa6221c1 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -20,7 +20,8 @@ public struct ProviderSettingsSnapshot: Sendable { amp: AmpProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, - perplexity: PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot + perplexity: PerplexityProviderSettings? = nil, + abacus: AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -41,7 +42,8 @@ public struct ProviderSettingsSnapshot: Sendable { amp: amp, ollama: ollama, jetbrains: jetbrains, - perplexity: perplexity) + perplexity: perplexity, + abacus: abacus) } public struct CodexProviderSettings: Sendable { @@ -232,6 +234,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct AbacusProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -251,6 +263,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? public let perplexity: PerplexityProviderSettings? + public let abacus: AbacusProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -275,7 +288,8 @@ public struct ProviderSettingsSnapshot: Sendable { amp: AmpProviderSettings?, ollama: OllamaProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil, - perplexity: PerplexityProviderSettings? = nil) + perplexity: PerplexityProviderSettings? = nil, + abacus: AbacusProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -296,6 +310,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.ollama = ollama self.jetbrains = jetbrains self.perplexity = perplexity + self.abacus = abacus } } @@ -317,6 +332,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) + case abacus(ProviderSettingsSnapshot.AbacusProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -339,6 +355,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? + public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled @@ -364,6 +381,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value case let .perplexity(value): self.perplexity = value + case let .abacus(value): self.abacus = value } } @@ -387,6 +405,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { amp: self.amp, ollama: self.ollama, jetbrains: self.jetbrains, - perplexity: self.perplexity) + perplexity: self.perplexity, + abacus: self.abacus) } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index eb9f08e929..83dade0549 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -28,6 +28,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case warp case openrouter case perplexity + case abacus } // swiftformat:enable sortDeclarations @@ -58,6 +59,7 @@ public enum IconStyle: Sendable, CaseIterable { case warp case openrouter case perplexity + case abacus case combined } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 36308cdf0c..a13d28a80b 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -65,5 +65,12 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .abacus: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Abacus AI Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 91de4e1cac..17ddf1dba4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -235,7 +235,7 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 7e4a7ddb05..73055305f4 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -76,6 +76,7 @@ enum ProviderChoice: String, AppEnum { case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets case .perplexity: return nil // Perplexity not yet supported in widgets + case .abacus: return nil // Abacus AI not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 4ccc2b57e2..4e03801b32 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -282,6 +282,7 @@ private struct ProviderSwitchChip: View { case .openrouter: "OpenRouter" case .warp: "Warp" case .perplexity: "Pplx" + case .abacus: "Abacus" } } } @@ -641,6 +642,8 @@ enum WidgetColors { Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) case .perplexity: Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal + case .abacus: + Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) } } } diff --git a/Tests/CodexBarTests/AbacusProviderTests.swift b/Tests/CodexBarTests/AbacusProviderTests.swift new file mode 100644 index 0000000000..8aca07d2b9 --- /dev/null +++ b/Tests/CodexBarTests/AbacusProviderTests.swift @@ -0,0 +1,289 @@ +import Foundation +import Testing +@testable import CodexBarCore + +// MARK: - Descriptor Tests + +struct AbacusDescriptorTests { + @Test + func `descriptor has correct identity`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.id == .abacus) + #expect(descriptor.metadata.displayName == "Abacus AI") + #expect(descriptor.metadata.cliName == "abacusai") + } + + @Test + func `descriptor does not expose a separate credits panel`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.supportsCredits == false) + #expect(meta.supportsOpus == false) + } + + @Test + func `descriptor is not primary provider`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.isPrimaryProvider == false) + #expect(meta.defaultEnabled == false) + } + + @Test + func `descriptor supports auto and web source modes`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.fetchPlan.sourceModes.contains(.auto)) + #expect(descriptor.fetchPlan.sourceModes.contains(.web)) + } + + @Test + func `descriptor has no version detector`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.cli.versionDetector == nil) + } + + @Test + func `descriptor does not support token cost`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.tokenCost.supportsTokenCost == false) + } + + @Test + func `cli aliases include abacus-ai`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.cli.aliases.contains("abacus-ai")) + } + + @Test + func `dashboard url points to compute points page`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.dashboardURL?.contains("compute-points") == true) + } +} + +// MARK: - Usage Snapshot Conversion Tests + +struct AbacusUsageSnapshotTests { + @Test + func `converts full snapshot to usage snapshot`() throws { + let resetDate = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AbacusUsageSnapshot( + creditsUsed: 250, + creditsTotal: 1000, + resetsAt: resetDate, + planName: "Pro") + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary != nil) + #expect(abs((usage.primary?.usedPercent ?? 0) - 25.0) < 0.01) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits") + #expect(usage.primary?.resetsAt == resetDate) + // Window derived from actual billing cycle (1 calendar month before resetDate) + let cycleStart = try #require(Calendar.current.date(byAdding: .month, value: -1, to: resetDate)) + let expectedMinutes = Int(resetDate.timeIntervalSince(cycleStart) / 60) + #expect(usage.primary?.windowMinutes == expectedMinutes) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.identity?.providerID == .abacus) + #expect(usage.identity?.loginMethod == "Pro") + } + + @Test + func `handles zero usage`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 500, + resetsAt: nil, + planName: "Basic") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + #expect(usage.primary?.resetDescription == "0 / 500 credits") + } + + @Test + func `handles full usage`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 1000, + creditsTotal: 1000, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 100.0) < 0.01) + #expect(usage.primary?.resetDescription == "1,000 / 1,000 credits") + } + + @Test + func `handles nil credits gracefully`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: nil, + creditsTotal: nil, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + #expect(usage.primary?.resetDescription == nil) + } + + @Test + func `handles nil total with non-nil used`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 100, + creditsTotal: nil, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + } + + @Test + func `handles zero total credits`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 0, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + } + + @Test + func `formats large credit values with comma grouping`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 12345, + creditsTotal: 50000, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "12,345 / 50,000 credits") + } + + @Test + func `formats fractional credit values`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 42.5, + creditsTotal: 100, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "42.5 / 100 credits") + } + + @Test + func `window minutes represents monthly cycle`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 100, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + // 30 days * 24 hours * 60 minutes = 43200 + #expect(usage.primary?.windowMinutes == 43200) + } + + @Test + func `identity has no email or organization`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 100, + resetsAt: nil, + planName: "Pro") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.accountOrganization == nil) + } +} + +// MARK: - Error Description Tests + +struct AbacusErrorTests { + @Test + func `noSessionCookie error mentions login`() { + let error = AbacusUsageError.noSessionCookie + #expect(error.errorDescription?.contains("log in") == true) + } + + @Test + func `sessionExpired error mentions expired`() { + let error = AbacusUsageError.sessionExpired + #expect(error.errorDescription?.contains("expired") == true) + } + + @Test + func `networkError includes message`() { + let error = AbacusUsageError.networkError("HTTP 500") + #expect(error.errorDescription?.contains("HTTP 500") == true) + } + + @Test + func `parseFailed includes message`() { + let error = AbacusUsageError.parseFailed("Invalid JSON") + #expect(error.errorDescription?.contains("Invalid JSON") == true) + } + + @Test + func `unauthorized error mentions login`() { + let error = AbacusUsageError.unauthorized + #expect(error.errorDescription?.contains("log in") == true) + } +} + +// MARK: - Error Classification Tests + +struct AbacusErrorClassificationTests { + @Test + func `unauthorized is recoverable and auth related`() { + let error = AbacusUsageError.unauthorized + #expect(error.isRecoverable == true) + #expect(error.isAuthRelated == true) + } + + @Test + func `sessionExpired is recoverable and auth related`() { + let error = AbacusUsageError.sessionExpired + #expect(error.isRecoverable == true) + #expect(error.isAuthRelated == true) + } + + @Test + func `parseFailed is not recoverable`() { + let error = AbacusUsageError.parseFailed("bad json") + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == true) + #expect(error.shouldClearCachedCookie == true) + } + + @Test + func `networkError is not recoverable`() { + let error = AbacusUsageError.networkError("timeout") + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == true) + #expect(error.shouldClearCachedCookie == false) + } + + @Test + func `noSessionCookie is not recoverable`() { + let error = AbacusUsageError.noSessionCookie + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == false) + #expect(error.shouldClearCachedCookie == false) + } + + @Test + func `auth failures continue imported session scanning`() { + #expect(AbacusUsageError.unauthorized.shouldTryNextImportedSession == true) + #expect(AbacusUsageError.sessionExpired.shouldTryNextImportedSession == true) + #expect(AbacusUsageError.unauthorized.shouldClearCachedCookie == true) + #expect(AbacusUsageError.sessionExpired.shouldClearCachedCookie == true) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index a2f6742717..53e67413cf 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -911,6 +911,7 @@ struct SettingsStoreTests { .warp, .openrouter, .perplexity, + .abacus, ]) // Move one provider; ensure it's persisted across instances. diff --git a/docs/abacus.md b/docs/abacus.md new file mode 100644 index 0000000000..c4f9893aec --- /dev/null +++ b/docs/abacus.md @@ -0,0 +1,67 @@ +--- +summary: "Abacus AI provider: browser cookie auth for ChatLLM/RouteLLM compute credit tracking." +read_when: + - Adding or modifying the Abacus AI provider + - Debugging Abacus cookie imports or API responses + - Adjusting Abacus usage display or credit formatting +--- + +# Abacus AI Provider + +The Abacus AI provider tracks ChatLLM/RouteLLM compute credit usage via browser cookie authentication. + +## Features + +- **Monthly credit gauge**: Shows credits used vs. plan total with pace tick indicator. +- **Reserve/deficit estimate**: Projected credit usage through the billing cycle. +- **Reset timing**: Displays the next billing date from the Abacus billing API. +- **Subscription tiers**: Detects Basic and Pro plans. +- **Cookie auth**: Automatic browser cookie import (Safari, Chrome, Firefox) or manual cookie header. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Abacus AI** +3. Log in to [apps.abacus.ai](https://apps.abacus.ai) in your browser +4. Cookie import happens automatically on the next refresh + +### Manual cookie mode + +1. In **Settings → Providers → Abacus AI**, set Cookie source to **Manual** +2. Open your browser DevTools on `apps.abacus.ai`, copy the `Cookie:` header from any API request +3. Paste the header into the cookie field in CodexBar + +## How it works + +Two API endpoints are fetched concurrently using browser session cookies: + +- `GET https://apps.abacus.ai/api/_getOrganizationComputePoints` — returns `totalComputePoints` and `computePointsLeft` (values are in credit units, no conversion needed). +- `POST https://apps.abacus.ai/api/_getBillingInfo` — returns `nextBillingDate` (ISO 8601) and `currentTier` (plan name). + +Cookie domains: `abacus.ai`, `apps.abacus.ai`. Session cookies are validated before use (anonymous/marketing-only cookie sets are skipped). Valid cookies are cached in Keychain and reused until the session expires. + +The billing cycle window is set to 30 days for pace calculation. + +## CLI + +```bash +codexbar usage --provider abacusai --verbose +``` + +## Troubleshooting + +### "No Abacus AI session found" + +Log in to [apps.abacus.ai](https://apps.abacus.ai) in a supported browser (Safari, Chrome, Firefox), then refresh CodexBar. + +### "Abacus AI session expired" + +Re-login to Abacus AI. The cached cookie will be cleared automatically and a fresh one imported on the next refresh. + +### "Unauthorized" + +Your session cookies may be invalid. Log out and back in to Abacus AI, or paste a fresh `Cookie:` header in manual mode. + +### Credits show 0 + +Verify that your Abacus AI account has an active subscription with compute credits allocated. diff --git a/docs/providers.md b/docs/providers.md index e6f0516bc4..a82898e782 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -39,6 +39,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Warp | API token (config/env) → GraphQL request limits (`api`). | | Ollama | Web settings page via browser cookies (`web`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | +| Abacus AI | Browser cookies → compute points + billing API (`web`). | ## Codex - Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -183,4 +184,12 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: `https://status.openrouter.ai` (link only, no auto-polling yet). - Details: `docs/openrouter.md`. +## Abacus AI +- Browser cookies (`abacus.ai`, `apps.abacus.ai`) via automatic import or manual header. +- `GET https://apps.abacus.ai/api/_getOrganizationComputePoints` (credits used/total). +- `POST https://apps.abacus.ai/api/_getBillingInfo` (next billing date, subscription tier). +- Shows monthly credit gauge with pace tick and reserve/deficit estimate. +- Status: none yet. +- Details: `docs/abacus.md`. + See also: `docs/provider.md` for architecture notes. From 95b468c4267df3b5bb8b4adf2418a24c5dc7655b Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 16 Apr 2026 13:24:12 +0530 Subject: [PATCH 0179/1239] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d07f9e9726..9407a758ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.21 — Unreleased ### Highlights +- Abacus AI: add a new provider for ChatLLM and RouteLLM credit tracking with browser-cookie import, manual-cookie support, and monthly pace rendering. Thanks @ChrisGVE! - Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! - z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. - Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF! @@ -14,6 +15,7 @@ - Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! ### Providers & Usage +- Abacus AI: add provider support for ChatLLM and RouteLLM monthly compute-credit tracking with cookie import, manual cookie headers, timeout/browser-detection threading, optional billing fallback, and hardened cached-session retry behavior. Thanks @ChrisGVE! - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). - Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! From 0b04e347ef2f96d5d9cf681d31e7847e04ffdd43 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 16 Apr 2026 13:25:26 +0530 Subject: [PATCH 0180/1239] Stabilize OpenAI dashboard delegate tests --- ...enAIDashboardNavigationDelegateTests.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 22e1938f53..13c31e625c 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -4,6 +4,10 @@ import WebKit @testable import CodexBarCore struct OpenAIDashboardNavigationDelegateTests { + final class DelegateBox: @unchecked Sendable { + var delegate: NavigationDelegate? + } + @Test func `ignores NSURLErrorCancelled`() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) @@ -46,12 +50,14 @@ struct OpenAIDashboardNavigationDelegateTests { func `commit completes navigation successfully after grace period`() async { let webView = WKWebView() var result: Result? - let delegate = NavigationDelegate { result = $0 } + let box = DelegateBox() + box.delegate = NavigationDelegate { result = $0 } - delegate.webView(webView, didCommit: nil) + box.delegate?.webView(webView, didCommit: nil) #expect(result == nil) try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + box.delegate = nil switch result { case .success?: @@ -66,13 +72,15 @@ struct OpenAIDashboardNavigationDelegateTests { func `post commit failure wins before delayed success`() async { let webView = WKWebView() var result: Result? - let delegate = NavigationDelegate { result = $0 } + let box = DelegateBox() + box.delegate = NavigationDelegate { result = $0 } - delegate.webView(webView, didCommit: nil) + box.delegate?.webView(webView, didCommit: nil) let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) - delegate.webView(webView, didFail: nil, withError: timeout) + box.delegate?.webView(webView, didFail: nil, withError: timeout) try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + box.delegate = nil switch result { case let .failure(error as NSError)?: @@ -133,10 +141,6 @@ struct OpenAIDashboardNavigationDelegateTests { @Test func `navigation timeout fails with timed out error`() async { - final class DelegateBox: @unchecked Sendable { - var delegate: NavigationDelegate? - } - let result = await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in Task { @MainActor in let box = DelegateBox() From 1abce8739e03e05ba74d287ce84eb8142345a827 Mon Sep 17 00:00:00 2001 From: Anirudh Venkatachalam <50367124+anirudhvee@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:26:40 -0700 Subject: [PATCH 0181/1239] fix: follow overview order for merged icon (#724) * fix: follow overview order for merged icon * fix: keep merged overview icon on loading provider --- .../StatusItemController+Animation.swift | 9 +++ .../StatusItemAnimationSignatureTests.swift | 58 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 4392686e35..564dde609d 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -599,6 +599,15 @@ extension StatusItemController { { return highest.provider } + if self.shouldMergeIcons, self.settings.mergedMenuLastSelectedWasOverview { + let enabledProviders = self.store.enabledProvidersForDisplay() + let overviewProviders = self.settings.resolvedMergedOverviewProviders( + activeProviders: enabledProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + if let provider = overviewProviders.first(where: { self.store.isEnabled($0) }) { + return provider + } + } if self.shouldMergeIcons, let selected = self.selectedMenuProvider, self.store.isEnabled(selected) diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index d2950ca09f..f7cb6047af 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -70,4 +70,62 @@ struct StatusItemAnimationSignatureTests { #expect(combinedSignature != codexSignature) #expect(codexSignature?.contains("style=codex") == true) } + + @Test + func `merged icon follows overview provider order when first overview provider is loading`() { + let suite = "StatusItemAnimationSignatureTests-merged-overview-provider-order" + let defaults = UserDefaults(suiteName: suite) + defaults?.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults ?? .standard, + configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-overview-provider-order"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + settings.menuBarShowsBrandIconWithPercent = false + settings.setProviderOrder([.cursor, .codex, .claude]) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .claude) + + #expect(store.enabledProvidersForDisplay().prefix(3) == [.cursor, .codex, .claude]) + #expect(settings.resolvedMergedOverviewProviders(activeProviders: store.enabledProvidersForDisplay()) == [ + .cursor, + .codex, + .claude, + ]) + + controller.applyIcon(phase: nil) + + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=cursor") == true) + } } From 2a027dff648ab0057407d9963a2a71373e9e692e Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 18 Apr 2026 13:07:17 +0530 Subject: [PATCH 0182/1239] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9407a758ab..5c1476d2d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm! - z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662). - Menu bar: avoid redundant merged-icon redraws and make hosted chart submenus load lazily without losing provider context (#708). +- Merged menu: when Overview is selected, keep the merged menu bar icon aligned with the first Overview provider in configured order, even while that provider is still loading (#724). Thanks @anirudhvee! - Codex: add an OpenAI web battery-saver toggle, keep manual refresh available when battery saver is on, and hide OpenAI web submenus when web extras are disabled. ### Development & Tooling From e69eb93172f74f347ff71f14b2938a19c215f540 Mon Sep 17 00:00:00 2001 From: knivram <129607867+knivram@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:20:10 +0200 Subject: [PATCH 0183/1239] Add Claude Opus 4.7 pricing --- .../Vendored/CostUsage/CostUsagePricing.swift | 10 ++++++++++ Tests/CodexBarTests/CostUsagePricingTests.swift | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 3f39d83526..1055e104af 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -175,6 +175,16 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: nil, cacheCreationInputCostPerTokenAboveThreshold: nil, cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-7": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index ce795f068a..4917dc120f 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -90,6 +90,17 @@ struct CostUsagePricingTests { #expect(cost != nil) } + @Test + func `claude cost supports opus47`() { + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-opus-4-7", + inputTokens: 10, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 5) + #expect(cost == 10 * 5e-6 + 5 * 2.5e-5) + } + @Test func `claude cost returns nil for unknown models`() { let cost = CostUsagePricing.claudeCostUSD( From d431b1e735ac22a172425b9000a336706bed192d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 18 Apr 2026 13:15:39 +0530 Subject: [PATCH 0184/1239] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1476d2d2..8ffe474072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Abacus AI: add a new provider for ChatLLM and RouteLLM credit tracking with browser-cookie import, manual-cookie support, and monthly pace rendering. Thanks @ChrisGVE! - Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! - z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. +- Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram! - Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF! - Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205! - Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! @@ -18,6 +19,7 @@ - Abacus AI: add provider support for ChatLLM and RouteLLM monthly compute-credit tracking with cookie import, manual cookie headers, timeout/browser-detection threading, optional billing fallback, and hardened cached-session retry behavior. Thanks @ChrisGVE! - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). +- Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram! - Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! - Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709). - Ollama: recognize `__Secure-session` cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee! From b5f7e73c7c5578e483557c898eba171712e0a89a Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 18 Apr 2026 13:54:38 +0530 Subject: [PATCH 0185/1239] fix: broaden CLI binary lookup to native installer paths for Claude (#731) Co-Authored-By: dingtang <650383+dingtang2008@users.noreply.github.com> --- CHANGELOG.md | 3 +- Sources/CodexBarCore/PathEnvironment.swift | 6 +++- Tests/CodexBarTests/PathBuilderTests.swift | 35 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ffe474072..8bc9bd773d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ - Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! - Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! - Battery / refresh: cut menu redraw churn, skip background work for unavailable providers, and reuse cached OpenAI web views more efficiently (#708). -- Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps still prefer user-managed and native Homebrew binaries when multiple installs exist. - Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! ### Providers & Usage @@ -26,7 +25,7 @@ - OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary! - Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! - Refresh pipeline: skip background work for unavailable providers, clear stale cached state, and show explicit unavailable messages (#708). -- Claude: preserve normal CLI fallback precedence across well-known install paths so Finder-launched apps prefer `~/.claude/bin/claude`, then Homebrew, before the bundled `cmux.app` binary when shell-based resolution is unavailable. +- Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008! - Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han! - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. - Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index e845f1a862..1e0881efcb 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -58,9 +58,13 @@ public enum BinaryLocator { } /// Well-known installation paths for the Claude CLI binary. - /// Covers the macOS Terminal installer (cmux.app), ~/.claude/bin, and Homebrew. + /// Covers Anthropic's native installer (`~/.local/bin`), the `claude migrate-installer` + /// self-updating location (`~/.claude/local`), the legacy per-user installer + /// (`~/.claude/bin`), Homebrew, and the macOS Terminal installer (cmux.app). static func claudeWellKnownPaths(home: String) -> [String] { [ + "\(home)/.local/bin/claude", + "\(home)/.claude/local/claude", "\(home)/.claude/bin/claude", "/opt/homebrew/bin/claude", "/usr/local/bin/claude", diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index d39baa0aba..2c9963f12d 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -244,6 +244,41 @@ struct PathBuilderTests { #expect(resolved == homePath) } + @Test + func `resolves claude from native installer path`() { + let nativePath = "/Users/test/.local/bin/claude" + let fm = MockFileManager(executables: [nativePath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == nativePath) + } + + @Test + func `prefers migrated local claude path over legacy home dir path`() { + let migratedPath = "/Users/test/.claude/local/claude" + let legacyPath = "/Users/test/.claude/bin/claude" + let fm = MockFileManager(executables: [migratedPath, legacyPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == migratedPath) + } + @Test func `prefers user managed well-known path over cmux path`() { let homePath = "/Users/test/.claude/bin/claude" From 1068d3fef400ff4f616c8e1393296b9c42ca3744 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 18 Apr 2026 14:51:11 +0530 Subject: [PATCH 0186/1239] fix(antigravity): display userTier.name instead of planInfo for plan display Co-Authored-By: withwiz <203689896+zacklavin11@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Antigravity/AntigravityStatusProbe.swift | 15 ++++- .../AntigravityStatusProbeTests.swift | 64 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc9bd773d..bddd111e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). - Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram! - Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! +- Antigravity: prefer `userTier.name` over generic plan info when rendering the account plan so Google AI Ultra and similar tiers show their real subscription name, while still falling back cleanly when the tier label is absent or blank (#303). Thanks @zacklavin11! - Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709). - Ollama: recognize `__Secure-session` cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee! - OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary! diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index cec06f3669..18055db9f4 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -335,7 +335,8 @@ public struct AntigravityStatusProbe: Sendable { let modelConfigs = userStatus.cascadeModelConfigData?.clientModelConfigs ?? [] let models = modelConfigs.compactMap(Self.quotaFromConfig(_:)) let email = userStatus.email - let planName = userStatus.planStatus?.planInfo?.preferredName + // Prefer userTier.name (actual subscription tier) over planInfo (shows "Pro" for Ultra users) + let planName = userStatus.userTier?.preferredName ?? userStatus.planStatus?.planInfo?.preferredName return AntigravityStatusSnapshot( modelQuotas: models, @@ -775,6 +776,18 @@ private struct UserStatus: Decodable { let email: String? let planStatus: PlanStatus? let cascadeModelConfigData: ModelConfigData? + let userTier: UserTier? +} + +private struct UserTier: Decodable { + let id: String? + let name: String? + let description: String? + + var preferredName: String? { + guard let value = name?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } + return value.isEmpty ? nil : value + } } private struct PlanStatus: Decodable { diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index 33c1bb7e57..3f35e8929a 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -83,6 +83,70 @@ struct AntigravityStatusProbeTests { #expect(usage.tertiary?.remainingPercent.rounded() == 20) } + @Test + func `prefers user tier name over generic plan info`() throws { + let json = """ + { + "code": 0, + "userStatus": { + "email": "ultra@example.com", + "userTier": { + "id": "google_ai_ultra", + "name": "Google AI Ultra", + "description": "Ultra tier" + }, + "planStatus": { + "planInfo": { + "planName": "Pro" + } + }, + "cascadeModelConfigData": { + "clientModelConfigs": [] + } + } + } + """ + + let data = Data(json.utf8) + let snapshot = try AntigravityStatusProbe.parseUserStatusResponse(data) + + #expect(snapshot.accountEmail == "ultra@example.com") + #expect(snapshot.accountPlan == "Google AI Ultra") + #expect(snapshot.modelQuotas.isEmpty) + } + + @Test + func `falls back to plan info when user tier name is blank`() throws { + let json = """ + { + "code": 0, + "userStatus": { + "email": "fallback@example.com", + "userTier": { + "id": "google_ai_ultra", + "name": " ", + "description": "Ultra tier" + }, + "planStatus": { + "planInfo": { + "planName": "Pro" + } + }, + "cascadeModelConfigData": { + "clientModelConfigs": [] + } + } + } + """ + + let data = Data(json.utf8) + let snapshot = try AntigravityStatusProbe.parseUserStatusResponse(data) + + #expect(snapshot.accountEmail == "fallback@example.com") + #expect(snapshot.accountPlan == "Pro") + #expect(snapshot.modelQuotas.isEmpty) + } + @Test func `claude bar can use thinking variants`() throws { let snapshot = AntigravityStatusSnapshot( From 367bf9b2d91b24c14d0d6abb8f450cb279a99d03 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 18 Apr 2026 16:39:18 +0530 Subject: [PATCH 0187/1239] Refine Antigravity endpoint routing --- .../Antigravity/AntigravityStatusProbe.swift | 301 ++++++++++++----- .../AntigravityStatusProbeTests.swift | 312 ++++++++++++++++++ 2 files changed, 539 insertions(+), 74 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 18055db9f4..c8cf215541 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -265,14 +265,20 @@ public struct AntigravityStatusProbe: Sendable { public func fetch() async throws -> AntigravityStatusSnapshot { let processInfo = try await Self.detectProcessInfo(timeout: self.timeout) let ports = try await Self.listeningPorts(pid: processInfo.pid, timeout: self.timeout) - let connectPort = try await Self.findWorkingPort( - ports: ports, - csrfToken: processInfo.csrfToken, + let endpoint = try await Self.resolveWorkingEndpoint( + candidateEndpoints: Self.connectionCandidates( + listeningPorts: ports, + languageServerCSRFToken: processInfo.csrfToken, + extensionServerPort: processInfo.extensionPort, + extensionServerCSRFToken: processInfo.extensionServerCSRFToken), timeout: self.timeout) let context = RequestContext( - httpsPort: connectPort, - httpPort: processInfo.extensionPort, - csrfToken: processInfo.csrfToken, + endpoints: Self.requestEndpoints( + resolvedEndpoint: endpoint, + listeningPorts: ports, + languageServerCSRFToken: processInfo.csrfToken, + extensionServerPort: processInfo.extensionPort, + extensionServerCSRFToken: processInfo.extensionServerCSRFToken), timeout: self.timeout) do { @@ -295,18 +301,24 @@ public struct AntigravityStatusProbe: Sendable { public func fetchPlanInfoSummary() async throws -> AntigravityPlanInfoSummary? { let processInfo = try await Self.detectProcessInfo(timeout: self.timeout) let ports = try await Self.listeningPorts(pid: processInfo.pid, timeout: self.timeout) - let connectPort = try await Self.findWorkingPort( - ports: ports, - csrfToken: processInfo.csrfToken, + let endpoint = try await Self.resolveWorkingEndpoint( + candidateEndpoints: Self.connectionCandidates( + listeningPorts: ports, + languageServerCSRFToken: processInfo.csrfToken, + extensionServerPort: processInfo.extensionPort, + extensionServerCSRFToken: processInfo.extensionServerCSRFToken), timeout: self.timeout) let response = try await Self.makeRequest( payload: RequestPayload( path: Self.getUserStatusPath, body: Self.defaultRequestBody()), context: RequestContext( - httpsPort: connectPort, - httpPort: processInfo.extensionPort, - csrfToken: processInfo.csrfToken, + endpoints: Self.requestEndpoints( + resolvedEndpoint: endpoint, + listeningPorts: ports, + languageServerCSRFToken: processInfo.csrfToken, + extensionServerPort: processInfo.extensionPort, + extensionServerCSRFToken: processInfo.extensionServerCSRFToken), timeout: self.timeout)) return try Self.parsePlanInfoSummary(response) } @@ -405,10 +417,27 @@ public struct AntigravityStatusProbe: Sendable { private struct ProcessInfoResult { let pid: Int let extensionPort: Int? + let extensionServerCSRFToken: String? let csrfToken: String let commandLine: String } + struct AntigravityConnectionEndpoint: Sendable, Equatable { + enum Source: String, Sendable { + case languageServer = "language-server" + case extensionServer = "extension-server" + } + + let scheme: String + let port: Int + let csrfToken: String + let source: Source + + func matchesRequestTarget(_ other: Self) -> Bool { + self.scheme == other.scheme && self.port == other.port && self.csrfToken == other.csrfToken + } + } + private static func detectProcessInfo(timeout: TimeInterval) async throws -> ProcessInfoResult { let env = ProcessInfo.processInfo.environment let result = try await SubprocessRunner.run( @@ -429,7 +458,13 @@ public struct AntigravityStatusProbe: Sendable { sawAntigravity = true guard let token = Self.extractFlag("--csrf_token", from: match.command) else { continue } let port = Self.extractPort("--extension_server_port", from: match.command) - return ProcessInfoResult(pid: match.pid, extensionPort: port, csrfToken: token, commandLine: match.command) + let extensionServerCSRFToken = Self.extractFlag("--extension_server_csrf_token", from: match.command) + return ProcessInfoResult( + pid: match.pid, + extensionPort: port, + extensionServerCSRFToken: extensionServerCSRFToken, + csrfToken: token, + commandLine: match.command) } if sawAntigravity { @@ -508,21 +543,133 @@ public struct AntigravityStatusProbe: Sendable { return ports.sorted() } - private static func findWorkingPort( - ports: [Int], - csrfToken: String, - timeout: TimeInterval) async throws -> Int + static func connectionCandidates( + listeningPorts: [Int], + languageServerCSRFToken: String, + extensionServerPort: Int?, + extensionServerCSRFToken: String?) -> [AntigravityConnectionEndpoint] + { + var endpoints = Self.languageServerEndpoints( + listeningPorts: listeningPorts, + languageServerCSRFToken: languageServerCSRFToken) + + for endpoint in Self.extensionServerEndpoints( + extensionServerPort: extensionServerPort, + languageServerCSRFToken: languageServerCSRFToken, + extensionServerCSRFToken: extensionServerCSRFToken) + { + guard !endpoints.contains(where: { $0.matchesRequestTarget(endpoint) }) else { continue } + endpoints.append(endpoint) + } + + return endpoints + } + + static func requestEndpoints( + resolvedEndpoint: AntigravityConnectionEndpoint, + listeningPorts: [Int], + languageServerCSRFToken: String, + extensionServerPort: Int?, + extensionServerCSRFToken: String?) -> [AntigravityConnectionEndpoint] + { + var endpoints = [resolvedEndpoint] + + if resolvedEndpoint.source == .extensionServer { + Self.appendUniqueRequestTargets( + from: Self.extensionServerEndpoints( + extensionServerPort: extensionServerPort, + languageServerCSRFToken: languageServerCSRFToken, + extensionServerCSRFToken: extensionServerCSRFToken), + to: &endpoints) + Self.appendUniqueRequestTargets( + from: Self.languageServerEndpoints( + listeningPorts: listeningPorts, + languageServerCSRFToken: languageServerCSRFToken), + to: &endpoints) + } else { + Self.appendUniqueRequestTargets( + from: Self.languageServerEndpoints( + listeningPorts: listeningPorts, + languageServerCSRFToken: languageServerCSRFToken), + to: &endpoints) + Self.appendUniqueRequestTargets( + from: Self.extensionServerEndpoints( + extensionServerPort: extensionServerPort, + languageServerCSRFToken: languageServerCSRFToken, + extensionServerCSRFToken: extensionServerCSRFToken), + to: &endpoints) + } + + return endpoints + } + + private static func languageServerEndpoints( + listeningPorts: [Int], + languageServerCSRFToken: String) -> [AntigravityConnectionEndpoint] + { + listeningPorts.map { + AntigravityConnectionEndpoint( + scheme: "https", + port: $0, + csrfToken: languageServerCSRFToken, + source: .languageServer) + } + } + + private static func extensionServerEndpoints( + extensionServerPort: Int?, + languageServerCSRFToken: String, + extensionServerCSRFToken: String?) -> [AntigravityConnectionEndpoint] + { + guard let extensionServerPort else { return [] } + + var endpoints: [AntigravityConnectionEndpoint] = [] + if let extensionServerCSRFToken { + endpoints.append( + AntigravityConnectionEndpoint( + scheme: "http", + port: extensionServerPort, + csrfToken: extensionServerCSRFToken, + source: .extensionServer)) + } + + if extensionServerCSRFToken != languageServerCSRFToken { + endpoints.append( + AntigravityConnectionEndpoint( + scheme: "http", + port: extensionServerPort, + csrfToken: languageServerCSRFToken, + source: .extensionServer)) + } + + return endpoints + } + + private static func appendUniqueRequestTargets( + from candidates: [AntigravityConnectionEndpoint], + to endpoints: inout [AntigravityConnectionEndpoint]) + { + for endpoint in candidates { + guard !endpoints.contains(where: { $0.matchesRequestTarget(endpoint) }) else { continue } + endpoints.append(endpoint) + } + } + + static func resolveWorkingEndpoint( + candidateEndpoints: [AntigravityConnectionEndpoint], + timeout: TimeInterval, + testConnectivity: @escaping @Sendable (AntigravityConnectionEndpoint, TimeInterval) async -> Bool = Self + .testEndpointConnectivity) async throws -> AntigravityConnectionEndpoint { - for port in ports { - let ok = await Self.testPortConnectivity(port: port, csrfToken: csrfToken, timeout: timeout) - if ok { return port } + for endpoint in candidateEndpoints { + let ok = await testConnectivity(endpoint, timeout) + if ok { return endpoint } } throw AntigravityStatusProbeError.portDetectionFailed("no working API port found") } - private static func testPortConnectivity( - port: Int, - csrfToken: String, + private static func testEndpointConnectivity( + _ endpoint: AntigravityConnectionEndpoint, timeout: TimeInterval) async -> Bool { do { @@ -530,15 +677,13 @@ public struct AntigravityStatusProbe: Sendable { payload: RequestPayload( path: self.unleashPath, body: self.unleashRequestBody()), - context: RequestContext( - httpsPort: port, - httpPort: nil, - csrfToken: csrfToken, - timeout: timeout)) + context: RequestContext(endpoints: [endpoint], timeout: timeout)) return true } catch { self.log.debug("Port probe failed", metadata: [ - "port": "\(port)", + "source": endpoint.source.rawValue, + "scheme": endpoint.scheme, + "port": "\(endpoint.port)", "error": error.localizedDescription, ]) return false @@ -553,9 +698,7 @@ public struct AntigravityStatusProbe: Sendable { } private struct RequestContext { - let httpsPort: Int - let httpPort: Int? - let csrfToken: String + let endpoints: [AntigravityConnectionEndpoint] let timeout: TimeInterval } @@ -592,29 +735,39 @@ public struct AntigravityStatusProbe: Sendable { payload: RequestPayload, context: RequestContext) async throws -> Data { - do { - return try await self.sendRequest( - scheme: "https", - port: context.httpsPort, - payload: payload, - context: context) - } catch { - guard let httpPort = context.httpPort, httpPort != context.httpsPort else { throw error } - return try await Self.sendRequest( - scheme: "http", - port: httpPort, - payload: payload, - context: context) - } + try await self.sendRequest(payload: payload, context: context) } private static func sendRequest( - scheme: String, - port: Int, payload: RequestPayload, context: RequestContext) async throws -> Data { - guard let url = URL(string: "\(scheme)://127.0.0.1:\(port)\(payload.path)") else { + var lastError: Error? + + for endpoint in context.endpoints { + do { + return try await Self.sendRequest(payload: payload, endpoint: endpoint, timeout: context.timeout) + } catch { + lastError = error + Self.log.debug("Antigravity request attempt failed", metadata: [ + "path": payload.path, + "source": endpoint.source.rawValue, + "scheme": endpoint.scheme, + "port": "\(endpoint.port)", + "error": error.localizedDescription, + ]) + } + } + + throw lastError ?? AntigravityStatusProbeError.apiError("Invalid URL") + } + + private static func sendRequest( + payload: RequestPayload, + endpoint: AntigravityConnectionEndpoint, + timeout: TimeInterval) async throws -> Data + { + guard let url = URL(string: "\(endpoint.scheme)://127.0.0.1:\(endpoint.port)\(payload.path)") else { throw AntigravityStatusProbeError.apiError("Invalid URL") } @@ -622,15 +775,15 @@ public struct AntigravityStatusProbe: Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = body - request.timeoutInterval = context.timeout + request.timeoutInterval = timeout request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(String(body.count), forHTTPHeaderField: "Content-Length") request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") - request.setValue(context.csrfToken, forHTTPHeaderField: "X-Codeium-Csrf-Token") + request.setValue(endpoint.csrfToken, forHTTPHeaderField: "X-Codeium-Csrf-Token") let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = context.timeout - config.timeoutIntervalForResource = context.timeout + config.timeoutIntervalForRequest = timeout + config.timeoutIntervalForResource = timeout #if !os(Linux) config.waitsForConnectivity = false #endif @@ -689,6 +842,27 @@ private final class LocalhostSessionDelegate: NSObject { state.cancel() } } + + private func challengeResult(_ challenge: URLAuthenticationChallenge) -> ( + disposition: URLSession.AuthChallengeDisposition, + credential: URLCredential?) + { + #if os(Linux) + return (.performDefaultHandling, nil) + #else + let protectionSpace = challenge.protectionSpace + let trust = protectionSpace.serverTrust + guard LocalhostTrustPolicy.shouldAcceptServerTrust( + host: protectionSpace.host, + authenticationMethod: protectionSpace.authenticationMethod, + hasServerTrust: trust != nil), + let trust + else { + return (.performDefaultHandling, nil) + } + return (.useCredential, URLCredential(trust: trust)) + #endif + } } extension LocalhostSessionDelegate: URLSessionDelegate { @@ -712,27 +886,6 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { let result = self.challengeResult(challenge) completionHandler(result.disposition, result.credential) } - - private func challengeResult(_ challenge: URLAuthenticationChallenge) -> ( - disposition: URLSession.AuthChallengeDisposition, - credential: URLCredential?) - { - #if os(Linux) - return (.performDefaultHandling, nil) - #else - let protectionSpace = challenge.protectionSpace - let trust = protectionSpace.serverTrust - guard LocalhostTrustPolicy.shouldAcceptServerTrust( - host: protectionSpace.host, - authenticationMethod: protectionSpace.authenticationMethod, - hasServerTrust: trust != nil), - let trust - else { - return (.performDefaultHandling, nil) - } - return (.useCredential, URLCredential(trust: trust)) - #endif - } } private final class LocalhostSessionTaskState: @unchecked Sendable { diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index 3f35e8929a..429769b569 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -2,6 +2,24 @@ import Foundation import Testing @testable import CodexBarCore +private final class AntigravityAttemptRecorder: @unchecked Sendable { + private let lock = NSLock() + private var endpoints: [AntigravityStatusProbe.AntigravityConnectionEndpoint] = [] + + func append(_ endpoint: AntigravityStatusProbe.AntigravityConnectionEndpoint) { + self.lock.lock() + self.endpoints.append(endpoint) + self.lock.unlock() + } + + func snapshot() -> [AntigravityStatusProbe.AntigravityConnectionEndpoint] { + self.lock.lock() + let snapshot = self.endpoints + self.lock.unlock() + return snapshot + } +} + struct AntigravityStatusProbeTests { @Test func `localhost trust policy only accepts local server trust challenges`() { @@ -33,6 +51,300 @@ struct AntigravityStatusProbeTests { hasServerTrust: false)) } + @Test + func `localhost trust policy rejects non loopback hostnames that contain localhost`() { + #expect( + !LocalhostTrustPolicy.shouldAcceptServerTrust( + host: "localhost.example.com", + authenticationMethod: NSURLAuthenticationMethodServerTrust, + hasServerTrust: true)) + } + + @Test + func `connection candidates preserve scheme order and endpoint tokens`() { + let candidates = AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: "extension-token") + + #expect( + candidates == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "extension-token", + source: .extensionServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "language-token", + source: .extensionServer), + ]) + } + + @Test + func `connection candidates restrict plain http probing to the declared extension port`() { + let candidates = AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64440, 64441], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: nil) + + #expect( + candidates == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64441, + csrfToken: "language-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "language-token", + source: .extensionServer), + ]) + } + + @Test + func `connection candidates preserve extension fallback when extension token is unavailable`() { + let candidates = AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: nil) + + #expect( + candidates == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "language-token", + source: .extensionServer), + ]) + } + + @Test + func `connection candidates do not duplicate the same http target when ports overlap`() { + let candidates = AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64432], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: nil) + + #expect( + candidates == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64432, + csrfToken: "language-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "language-token", + source: .extensionServer), + ]) + } + + @Test + func `request endpoints retry extension server after language server success`() { + let resolvedEndpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer) + + let endpoints = AntigravityStatusProbe.requestEndpoints( + resolvedEndpoint: resolvedEndpoint, + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: "extension-token") + + #expect( + endpoints == [ + resolvedEndpoint, + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "extension-token", + source: .extensionServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "language-token", + source: .extensionServer), + ]) + } + + @Test + func `request endpoints preserve extension fallback when extension token is unavailable`() { + let resolvedEndpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer) + + let endpoints = AntigravityStatusProbe.requestEndpoints( + resolvedEndpoint: resolvedEndpoint, + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: nil) + + #expect( + endpoints == [ + resolvedEndpoint, + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "language-token", + source: .extensionServer), + ]) + } + + @Test + func `request endpoints retry alternate token after extension server wins discovery`() { + let resolvedEndpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "extension-token", + source: .extensionServer) + + let endpoints = AntigravityStatusProbe.requestEndpoints( + resolvedEndpoint: resolvedEndpoint, + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: "extension-token") + + #expect( + endpoints == [ + resolvedEndpoint, + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "language-token", + source: .extensionServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + ]) + } + + @Test + func `request endpoints keep https language server fallback after extension probe wins`() { + let resolvedEndpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "language-token", + source: .extensionServer) + + let endpoints = AntigravityStatusProbe.requestEndpoints( + resolvedEndpoint: resolvedEndpoint, + listeningPorts: [64432, 64440], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: nil) + + #expect( + endpoints == [ + resolvedEndpoint, + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64432, + csrfToken: "language-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + ]) + } + + @Test + func `endpoint resolver prefers successful https language server candidate`() async throws { + let candidates = AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: "extension-token") + let attempted = AntigravityAttemptRecorder() + + let endpoint = try await AntigravityStatusProbe.resolveWorkingEndpoint( + candidateEndpoints: candidates, + timeout: 1) + { endpoint, _ in + attempted.append(endpoint) + return endpoint.scheme == "https" && endpoint.port == 64440 + } + + #expect(endpoint == candidates[0]) + #expect(attempted.snapshot() == [candidates[0]]) + } + + @Test + func `endpoint resolver falls back to extension server after https language server candidates`() async throws { + let candidates = AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64440, 64441], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: "extension-token") + let attempted = AntigravityAttemptRecorder() + + let endpoint = try await AntigravityStatusProbe.resolveWorkingEndpoint( + candidateEndpoints: candidates, + timeout: 1) + { endpoint, _ in + attempted.append(endpoint) + return endpoint.scheme == "http" && endpoint.port == 64432 && endpoint.source == .extensionServer + } + + #expect(endpoint == candidates[2]) + #expect(attempted.snapshot() == Array(candidates.prefix(3))) + } + + @Test + func `endpoint resolver falls back to alternate extension token after primary token fails`() async throws { + let candidates = AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: 64432, + extensionServerCSRFToken: "extension-token") + let attempted = AntigravityAttemptRecorder() + + let endpoint = try await AntigravityStatusProbe.resolveWorkingEndpoint( + candidateEndpoints: candidates, + timeout: 1) + { endpoint, _ in + attempted.append(endpoint) + return endpoint.source == .extensionServer && endpoint.csrfToken == "language-token" + } + + #expect(endpoint == candidates[2]) + #expect(attempted.snapshot() == candidates) + #expect(endpoint.csrfToken == "language-token") + } + @Test func `parses user status response`() throws { let json = """ From 8e6364338621e1440ff9d2a1060ede14ef200912 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 18 Apr 2026 17:20:40 +0530 Subject: [PATCH 0188/1239] Fix Antigravity localhost TLS delegate --- .../Antigravity/AntigravityStatusProbe.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index c8cf215541..031294597d 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -869,10 +869,12 @@ extension LocalhostSessionDelegate: URLSessionDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - completionHandler(result.disposition, result.credential) + Task { @MainActor in + completionHandler(result.disposition, result.credential) + } } } @@ -881,10 +883,12 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - completionHandler(result.disposition, result.credential) + Task { @MainActor in + completionHandler(result.disposition, result.credential) + } } } From 07a6591a41cec639410648454467492444a652d2 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 18 Apr 2026 17:46:22 +0530 Subject: [PATCH 0189/1239] Retry Antigravity endpoints after API errors --- .../Antigravity/AntigravityStatusProbe.swift | 50 +++++++++++++++---- .../AntigravityStatusProbeTests.swift | 47 +++++++++++++++++ 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 031294597d..359029d91e 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -282,19 +282,19 @@ public struct AntigravityStatusProbe: Sendable { timeout: self.timeout) do { - let response = try await Self.makeRequest( + return try await Self.makeParsedRequest( payload: RequestPayload( path: Self.getUserStatusPath, body: Self.defaultRequestBody()), - context: context) - return try Self.parseUserStatusResponse(response) + context: context, + parse: Self.parseUserStatusResponse) } catch { - let response = try await Self.makeRequest( + return try await Self.makeParsedRequest( payload: RequestPayload( path: Self.commandModelConfigPath, body: Self.defaultRequestBody()), - context: context) - return try Self.parseCommandModelResponse(response) + context: context, + parse: Self.parseCommandModelResponse) } } @@ -308,7 +308,7 @@ public struct AntigravityStatusProbe: Sendable { extensionServerPort: processInfo.extensionPort, extensionServerCSRFToken: processInfo.extensionServerCSRFToken), timeout: self.timeout) - let response = try await Self.makeRequest( + return try await Self.makeParsedRequest( payload: RequestPayload( path: Self.getUserStatusPath, body: Self.defaultRequestBody()), @@ -319,8 +319,8 @@ public struct AntigravityStatusProbe: Sendable { languageServerCSRFToken: processInfo.csrfToken, extensionServerPort: processInfo.extensionPort, extensionServerCSRFToken: processInfo.extensionServerCSRFToken), - timeout: self.timeout)) - return try Self.parsePlanInfoSummary(response) + timeout: self.timeout), + parse: Self.parsePlanInfoSummary) } public static func isRunning(timeout: TimeInterval = 4.0) async -> Bool { @@ -692,12 +692,12 @@ public struct AntigravityStatusProbe: Sendable { // MARK: - HTTP - private struct RequestPayload { + struct RequestPayload { let path: String let body: [String: Any] } - private struct RequestContext { + struct RequestContext { let endpoints: [AntigravityConnectionEndpoint] let timeout: TimeInterval } @@ -738,6 +738,34 @@ public struct AntigravityStatusProbe: Sendable { try await self.sendRequest(payload: payload, context: context) } + static func makeParsedRequest( + payload: RequestPayload, + context: RequestContext, + send: @escaping @Sendable (RequestPayload, AntigravityConnectionEndpoint, TimeInterval) async throws -> Data = + sendRequest, + parse: @escaping @Sendable (Data) throws -> T) async throws -> T + { + var lastError: Error? + + for endpoint in context.endpoints { + do { + let data = try await send(payload, endpoint, context.timeout) + return try parse(data) + } catch { + lastError = error + Self.log.debug("Antigravity request/parse attempt failed", metadata: [ + "path": payload.path, + "source": endpoint.source.rawValue, + "scheme": endpoint.scheme, + "port": "\(endpoint.port)", + "error": error.localizedDescription, + ]) + } + } + + throw lastError ?? AntigravityStatusProbeError.apiError("Invalid response") + } + private static func sendRequest( payload: RequestPayload, context: RequestContext) async throws -> Data diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index 429769b569..f1d0e75b5c 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -281,6 +281,53 @@ struct AntigravityStatusProbeTests { ]) } + @Test + func `parsed request retries later endpoints after api level error payload`() async throws { + let endpoints = [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "bad-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "good-token", + source: .extensionServer), + ] + let attempted = AntigravityAttemptRecorder() + + let snapshot = try await AntigravityStatusProbe.makeParsedRequest( + payload: AntigravityStatusProbe.RequestPayload( + path: "/exa.language_server_pb.LanguageServerService/GetUserStatus", + body: ["metadata": [:]]), + context: AntigravityStatusProbe.RequestContext( + endpoints: endpoints, + timeout: 1), + send: { _, endpoint, _ in + attempted.append(endpoint) + if endpoint.csrfToken == "bad-token" { + return Data(#"{"code":16}"#.utf8) + } + return Data( + #""" + { + "code": 0, + "userStatus": { + "email": "test@example.com", + "cascadeModelConfigData": { + "clientModelConfigs": [] + } + } + } + """#.utf8) + }, + parse: AntigravityStatusProbe.parseUserStatusResponse) + + #expect(snapshot.accountEmail == "test@example.com") + #expect(attempted.snapshot() == endpoints) + } + @Test func `endpoint resolver prefers successful https language server candidate`() async throws { let candidates = AntigravityStatusProbe.connectionCandidates( From 989a9aee4d183aa6d135aaba2b9548aefbd506d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:06:15 +0100 Subject: [PATCH 0190/1239] build: update package dependencies --- Package.resolved | 10 +++---- Package.swift | 4 +-- .../Logging/CompositeLogHandler.swift | 29 ++----------------- .../CodexBarCore/Logging/FileLogHandler.swift | 25 +++++----------- .../Logging/JSONStderrLogHandler.swift | 25 +++++----------- .../Logging/OSLogLogHandler.swift | 17 +++-------- .../Antigravity/AntigravityStatusProbe.swift | 16 ++++------ 7 files changed, 36 insertions(+), 90 deletions(-) diff --git a/Package.resolved b/Package.resolved index f84c0c2172..4249056442 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "74bd6f3ab6e0b0cb0c2cddb00f2167c2ab0a1c00cd54ffc1a2899c7ef8c56367", + "originHash" : "fc2d77d3435ccf0f5a2d2a8f782cbb3c38264c7542f33838f2409544695bce6e", "pins" : [ { "identity" : "commander", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", - "version" : "2.8.1" + "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", + "version" : "2.9.1" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { diff --git a/Package.swift b/Package.swift index 0ea2821a07..208b857ee6 100644 --- a/Package.swift +++ b/Package.swift @@ -17,9 +17,9 @@ let package = Package( .macOS(.v14), ], dependencies: [ - .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"), .package(url: "https://github.com/steipete/Commander", from: "0.2.1"), - .package(url: "https://github.com/apple/swift-log", from: "1.9.1"), + .package(url: "https://github.com/apple/swift-log", from: "1.12.0"), .package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"), .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"), sweetCookieKitDependency, diff --git a/Sources/CodexBarCore/Logging/CompositeLogHandler.swift b/Sources/CodexBarCore/Logging/CompositeLogHandler.swift index f86aebc1c6..51f989ea1c 100644 --- a/Sources/CodexBarCore/Logging/CompositeLogHandler.swift +++ b/Sources/CodexBarCore/Logging/CompositeLogHandler.swift @@ -33,31 +33,8 @@ struct CompositeLogHandler: LogHandler { } } - // swiftlint:disable:next function_parameter_count - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { - self.primary.log( - level: level, - message: message, - metadata: metadata, - source: source, - file: file, - function: function, - line: line) - self.secondary.log( - level: level, - message: message, - metadata: metadata, - source: source, - file: file, - function: function, - line: line) + func log(event: LogEvent) { + self.primary.log(event: event) + self.secondary.log(event: event) } } diff --git a/Sources/CodexBarCore/Logging/FileLogHandler.swift b/Sources/CodexBarCore/Logging/FileLogHandler.swift index effe9ff5cc..e432116268 100644 --- a/Sources/CodexBarCore/Logging/FileLogHandler.swift +++ b/Sources/CodexBarCore/Logging/FileLogHandler.swift @@ -103,19 +103,10 @@ struct FileLogHandler: LogHandler { set { self.metadata[metadataKey] = newValue } } - // swiftlint:disable:next function_parameter_count - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { + func log(event: LogEvent) { let ts = Self.timestamp() var combined = self.metadata - if let metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) } + if let metadata = event.metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) } var metaText = "" if !combined.isEmpty { let pairs = combined @@ -128,12 +119,12 @@ struct FileLogHandler: LogHandler { .joined(separator: " ") metaText = " \(pairs)" } - let safeMessage = LogRedactor.redact("\(message)") - let lineText = "[\(ts)] [\(level.rawValue.uppercased())] \(self.label): \(safeMessage)\(metaText)\n" - _ = source - _ = file - _ = function - _ = line + let safeMessage = LogRedactor.redact("\(event.message)") + let lineText = "[\(ts)] [\(event.level.rawValue.uppercased())] \(self.label): \(safeMessage)\(metaText)\n" + _ = event.source + _ = event.file + _ = event.function + _ = event.line self.sink.write(lineText) } diff --git a/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift b/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift index 6c02e745e3..4d5c5d3945 100644 --- a/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift +++ b/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift @@ -25,29 +25,20 @@ struct JSONStderrLogHandler: LogHandler { set { self.metadata[metadataKey] = newValue } } - // swiftlint:disable:next function_parameter_count - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { + func log(event: LogEvent) { let ts = Date() var combined = self.metadata - if let metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) } + if let metadata = event.metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) } let payload = JSONLogLine( timestamp: ts, - level: level.rawValue, + level: event.level.rawValue, label: self.label, - message: message.description, - source: source, - file: file, - function: function, - line: line, + message: event.message.description, + source: event.source, + file: event.file, + function: event.function, + line: event.line, metadata: combined.isEmpty ? nil : combined.mapValues(\.description)) guard let data = try? self.encoder.encode(payload), diff --git a/Sources/CodexBarCore/Logging/OSLogLogHandler.swift b/Sources/CodexBarCore/Logging/OSLogLogHandler.swift index 3a1b28754e..be0dc25094 100644 --- a/Sources/CodexBarCore/Logging/OSLogLogHandler.swift +++ b/Sources/CodexBarCore/Logging/OSLogLogHandler.swift @@ -23,24 +23,15 @@ struct OSLogLogHandler: LogHandler { set { self.metadata[metadataKey] = newValue } } - // swiftlint:disable:next function_parameter_count - func log( - level: Logging.Logger.Level, - message: Logging.Logger.Message, - metadata: Logging.Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { + func log(event: LogEvent) { let msg = Self.decorate( - message: message.description, + message: event.message.description, label: self.label, subsystem: self.subsystem, metadata: self.metadata, - extraMetadata: metadata) + extraMetadata: event.metadata) - switch level { + switch event.level { case .trace: self.logger.debug("\(msg, privacy: .public)") case .debug: diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 359029d91e..5b34859721 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -422,8 +422,8 @@ public struct AntigravityStatusProbe: Sendable { let commandLine: String } - struct AntigravityConnectionEndpoint: Sendable, Equatable { - enum Source: String, Sendable { + struct AntigravityConnectionEndpoint: Equatable { + enum Source: String { case languageServer = "language-server" case extensionServer = "extension-server" } @@ -897,12 +897,10 @@ extension LocalhostSessionDelegate: URLSessionDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + completionHandler(result.disposition, result.credential) } } @@ -911,12 +909,10 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + completionHandler(result.disposition, result.credential) } } From fb3553d059686183c110973fbe3f112b469e6400 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:06:20 +0100 Subject: [PATCH 0191/1239] test: stabilize provider state coverage --- Sources/CodexBar/ProviderRegistry.swift | 5 +- .../StatusItemController+Animation.swift | 5 +- Sources/CodexBar/UsageStore+Accessors.swift | 2 +- .../CodexBar/UsageStore+TokenAccounts.swift | 2 +- Sources/CodexBar/UsageStore.swift | 14 +-- ...AuthCredentialsStoreSecurityCLITests.swift | 86 ++++++++++--------- .../CursorStatusProbeTests.swift | 4 +- ...enAIDashboardNavigationDelegateTests.swift | 22 ++++- .../OpenAIDashboardWebViewCacheTests.swift | 4 +- .../OpenAIWebRefreshGateTests.swift | 36 ++++---- .../ProviderSettingsDescriptorTests.swift | 2 +- Tests/CodexBarTests/SettingsStoreTests.swift | 10 +-- .../StatusItemAnimationSignatureTests.swift | 14 ++- Tests/CodexBarTests/StatusMenuTests.swift | 4 +- .../UsageStoreCoverageTests.swift | 19 ++-- 15 files changed, 133 insertions(+), 96 deletions(-) diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 9f0613c339..764e418c49 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -23,7 +23,8 @@ struct ProviderRegistry { metadata: [UsageProvider: ProviderMetadata], codexFetcher: UsageFetcher, claudeFetcher: any ClaudeUsageFetching, - browserDetection: BrowserDetection) -> [UsageProvider: ProviderSpec] + browserDetection: BrowserDetection, + environmentBase: [String: String] = ProcessInfo.processInfo.environment) -> [UsageProvider: ProviderSpec] { var specs: [UsageProvider: ProviderSpec] = [:] specs.reserveCapacity(UsageProvider.allCases.count) @@ -41,7 +42,7 @@ struct ProviderRegistry { ?? .auto let snapshot = Self.makeSettingsSnapshot(settings: settings, tokenOverride: nil) let env = Self.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: environmentBase, provider: provider, settings: settings, tokenOverride: nil) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 564dde609d..bed72a824f 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -405,7 +405,10 @@ extension StatusItemController { // swiftlint:enable function_body_length private func shouldSkipMergedIconRender(_ signature: String) -> Bool { - guard self.shouldMergeIcons else { return false } + guard self.shouldMergeIcons else { + self.lastAppliedMergedIconRenderSignature = signature + return false + } if self.lastAppliedMergedIconRenderSignature == signature { return true } diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 743f405e2a..9c3c759e9e 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -81,7 +81,7 @@ extension UsageStore { return self.codexFetcher.loadAccountInfo() } let env = ProviderRegistry.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: self.environmentBase, provider: .codex, settings: self.settings, tokenOverride: nil) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4940eeec59..7a00c12360 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -99,7 +99,7 @@ extension UsageStore { let sourceMode = self.sourceMode(for: provider) let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: override) let env = ProviderRegistry.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: self.environmentBase, provider: provider, settings: self.settings, tokenOverride: override) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d2af04b217..36cc0bf2a6 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -179,6 +179,7 @@ final class UsageStore { @ObservationIgnored let browserDetection: BrowserDetection @ObservationIgnored private let registry: ProviderRegistry @ObservationIgnored let settings: SettingsStore + @ObservationIgnored let environmentBase: [String: String] @ObservationIgnored private let sessionQuotaNotifier: any SessionQuotaNotifying @ObservationIgnored private let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota) @ObservationIgnored let openAIWebLogger = CodexBarLog.logger(LogCategories.openAIWeb) @@ -220,7 +221,8 @@ final class UsageStore { historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(), sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), - startupBehavior: StartupBehavior = .automatic) + startupBehavior: StartupBehavior = .automatic, + environmentBase: [String: String] = ProcessInfo.processInfo.environment) { self.codexFetcher = fetcher self.browserDetection = browserDetection @@ -228,6 +230,7 @@ final class UsageStore { self.costUsageFetcher = costUsageFetcher self.settings = settings self.registry = registry + self.environmentBase = environmentBase self.historicalUsageHistoryStore = historicalUsageHistoryStore self.planUtilizationHistoryStore = planUtilizationHistoryStore self.sessionQuotaNotifier = sessionQuotaNotifier @@ -247,7 +250,8 @@ final class UsageStore { metadata: self.providerMetadata, codexFetcher: fetcher, claudeFetcher: self.claudeFetcher, - browserDetection: browserDetection) + browserDetection: browserDetection, + environmentBase: environmentBase) self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in implementation.makeRuntime().map { (implementation.id, $0) } }) @@ -419,7 +423,7 @@ final class UsageStore { // 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: ProcessInfo.processInfo.environment, + base: self.environmentBase, provider: provider, settings: self.settings, tokenOverride: nil) @@ -777,7 +781,7 @@ extension UsageStore { let ampCookieHeader = self.settings.ampCookieHeader let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader - let processEnvironment = ProcessInfo.processInfo.environment + let processEnvironment = self.environmentBase let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) @@ -898,7 +902,7 @@ extension UsageStore { let sourceMode = self.sourceMode(for: .claude) let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) let environment = ProviderRegistry.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: self.environmentBase, provider: .claude, settings: self.settings, tokenOverride: nil) diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift index 1dc85d7c78..1e49708b0c 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -693,50 +693,56 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let securityData = self.makeCredentialsData( - accessToken: "security-repair-no-fingerprint-probe", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() - let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 456, - createdAt: 455, - persistentRefHash: "sentinel") - - let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental, - operation: { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - try ProviderInteractionContext.$current.withValue(.background) { - try ClaudeOAuthCredentialsStore - .withClaudeKeychainFingerprintStoreOverrideForTesting( - fingerprintStore) - { - try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: nil, - fingerprint: sentinelFingerprint) - { - try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .data(securityData)) + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-repair-no-fingerprint-probe", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 456, + createdAt: 455, + persistentRefHash: "sentinel") + + let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) { - try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) + try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + } } - } } - } - } - }) + } + }) - #expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe") - #expect(record.source == .claudeKeychain) - #expect(fingerprintStore.fingerprint == nil) + #expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe") + #expect(record.source == .claudeKeychain) + #expect(fingerprintStore.fingerprint == nil) + } + } } } } diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index 2b02bbcbb6..b7ba51eae2 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -1045,7 +1045,9 @@ final class CursorStatusProbeStubURLProtocol: URLProtocol { Self.lock.unlock() do { - let handler = try #require(handler) + guard let handler else { + throw URLError(.cancelled) + } let (response, data) = try handler(self.request) self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) self.client?.urlProtocol(self, didLoad: data) diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 13c31e625c..0ee8e0e70c 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -3,11 +3,25 @@ import Testing import WebKit @testable import CodexBarCore +@Suite(.serialized) struct OpenAIDashboardNavigationDelegateTests { final class DelegateBox: @unchecked Sendable { var delegate: NavigationDelegate? } + @MainActor + private func waitForResult( + _ result: @escaping () -> Result?, + timeout: TimeInterval = NavigationDelegate.postCommitSuccessDelay + 10.0) async -> Result? + { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let result = result() { return result } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return result() + } + @Test func `ignores NSURLErrorCancelled`() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) @@ -56,10 +70,10 @@ struct OpenAIDashboardNavigationDelegateTests { box.delegate?.webView(webView, didCommit: nil) #expect(result == nil) - try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + let completed = await self.waitForResult { result } box.delegate = nil - switch result { + switch completed { case .success?: #expect(Bool(true)) default: @@ -79,10 +93,10 @@ struct OpenAIDashboardNavigationDelegateTests { let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) box.delegate?.webView(webView, didFail: nil, withError: timeout) - try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + let completed = await self.waitForResult { result } box.delegate = nil - switch result { + switch completed { case let .failure(error as NSError)?: #expect(error.domain == NSURLErrorDomain) #expect(error.code == NSURLErrorTimedOut) diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index 84d500fb2d..f6558b8107 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -188,8 +188,8 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } - @Test("Evict all should remove every cached WebView") - func evictAllRemovesAllEntries() async throws { + @Test + func `Evict all should remove every cached WebView`() async throws { if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index 67c7282cc4..eead8bb841 100644 --- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -3,8 +3,8 @@ import Testing @testable import CodexBar struct OpenAIWebRefreshGateTests { - @Test("Battery saver keeps background OpenAI web refreshes off") - func batterySaverDisablesBackgroundRefresh() { + @Test + func `Battery saver keeps background OpenAI web refreshes off`() { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, @@ -13,8 +13,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldRun == false) } - @Test("Disabling battery saver restores normal OpenAI web refreshes") - func disabledBatterySaverAllowsBackgroundRefresh() { + @Test + func `Disabling battery saver restores normal OpenAI web refreshes`() { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: false, @@ -23,8 +23,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldRun == true) } - @Test("Manual refresh still forces OpenAI web refreshes with battery saver enabled") - func manualRefreshBypassesBatterySaver() { + @Test + func `Manual refresh still forces OpenAI web refreshes with battery saver enabled`() { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, @@ -33,22 +33,22 @@ struct OpenAIWebRefreshGateTests { #expect(shouldRun == true) } - @Test("Battery saver stale-submenu refresh respects the cooldown") - func batterySaverStaleRefreshDoesNotForce() { + @Test + func `Battery saver stale-submenu refresh respects the cooldown`() { let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: true) #expect(shouldForce == false) } - @Test("Normal stale-submenu refresh still forces when battery saver is off") - func nonBatterySaverStaleRefreshForces() { + @Test + func `Normal stale-submenu refresh still forces when battery saver is off`() { let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: false) #expect(shouldForce == true) } - @Test("Recent successful dashboard refresh stays throttled") - func recentSuccessSkipsRefresh() { + @Test + func `Recent successful dashboard refresh stays throttled`() { let now = Date() let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( @@ -63,8 +63,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == true) } - @Test("Recent failed dashboard refresh also stays throttled") - func recentFailureSkipsRefresh() { + @Test + func `Recent failed dashboard refresh also stays throttled`() { let now = Date() let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( @@ -79,8 +79,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == true) } - @Test("Force refresh bypasses throttle after failures") - func forceRefreshBypassesCooldown() { + @Test + func `Force refresh bypasses throttle after failures`() { let now = Date() let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( @@ -95,8 +95,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == false) } - @Test("Account switches bypass the prior-attempt cooldown") - func accountChangeBypassesCooldown() { + @Test + func `Account switches bypass the prior-attempt cooldown`() { let now = Date() let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 99f8a9837f..81a159b8e2 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -125,7 +125,7 @@ struct ProviderSettingsDescriptorTests { } @Test - func codexExposesOpenAIWebExtrasToggleAsDefaultOffOptIn() throws { + func `codex exposes open AI web extras toggle as default off opt in`() throws { let suite = "ProviderSettingsDescriptorTests-codex-openai-toggle" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 53e67413cf..0a50a51662 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -649,7 +649,7 @@ struct SettingsStoreTests { } @Test - func defaultsOpenAIWebAccessToDisabled() throws { + func `defaults open AI web access to disabled`() throws { let suite = "SettingsStoreTests-openai-web" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -670,7 +670,7 @@ struct SettingsStoreTests { } @Test - func infersOpenAIWebAccessEnabledForLegacyConfiguredCodexCookies() throws { + func `infers open AI web access enabled for legacy configured codex cookies`() throws { let suite = "SettingsStoreTests-openai-web-legacy" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -695,7 +695,7 @@ struct SettingsStoreTests { } @Test - func infersOpenAIWebAccessEnabledForLegacyCodexConfigWithImplicitAutoCookies() throws { + func `infers open AI web access enabled for legacy codex config with implicit auto cookies`() throws { let suite = "SettingsStoreTests-openai-web-legacy-implicit-auto" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -720,7 +720,7 @@ struct SettingsStoreTests { } @Test - func disablingOpenAIWebAccessTurnsCodexCookieSourceOff() throws { + func `disabling open AI web access turns codex cookie source off`() throws { let suite = "SettingsStoreTests-openai-web-toggle" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -746,7 +746,7 @@ struct SettingsStoreTests { } @Test - func openAIWebBatterySaverPersistsSeparatelyFromExtrasAvailability() throws { + func `open AI web battery saver persists separately from extras availability`() throws { let suite = "SettingsStoreTests-openai-web-battery-saver" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index f7cb6047af..ef85a9715d 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -14,9 +14,13 @@ struct StatusItemAnimationSignatureTests { } @Test - func `merged render signature changes when unified icon style changes`() { + func `merged render signature changes when unified icon style changes`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-style-signature" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) let settings = SettingsStore( - configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-style-signature"), + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.statusChecksEnabled = false @@ -57,9 +61,11 @@ struct StatusItemAnimationSignatureTests { controller.applyIcon(phase: nil) let combinedSignature = controller.lastAppliedMergedIconRenderSignature - settings.syntheticAPIToken = "" + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: false) + } - #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProvidersForDisplay() == [.codex]) #expect(store.enabledProviders() == [.codex]) #expect(store.iconStyle == .codex) controller.applyIcon(phase: nil) diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index c74ecf69cd..c401344675 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -653,7 +653,7 @@ extension StatusMenuTests { } @Test - func hidesOpenAIWebSubmenusWhenOpenAIWebExtrasDisabled() { + func `hides open AI web submenus when open AI web extras disabled`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -702,7 +702,7 @@ extension StatusMenuTests { } @Test - func showsOpenAIWebSubmenusWhenHistoryExists() throws { + func `shows open AI web submenus when history exists`() throws { self.disableMenuCardsForTesting() let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusMenuTests-history"), diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 9e9258447e..b5710878a5 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -134,7 +134,7 @@ struct UsageStoreCoverageTests { } @Test - func backgroundRefreshOnlyTracksEnabledProviders() throws { + func `background refresh only tracks enabled providers`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-refresh") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -167,7 +167,7 @@ struct UsageStoreCoverageTests { } @Test - func cleanupPreservesEnabledButUnavailableProviderState() throws { + func `cleanup preserves enabled but unavailable provider state`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-preserve-unavailable") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -204,7 +204,7 @@ struct UsageStoreCoverageTests { } @Test - func backgroundWorkExcludesEnabledButUnavailableProviders() throws { + func `background work excludes enabled but unavailable providers`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-unavailable") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -229,7 +229,7 @@ struct UsageStoreCoverageTests { } @Test - func visibleUnavailableProviderGetsExplicitUserFacingState() throws { + func `visible unavailable provider gets explicit user facing state`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-unavailable-message") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -256,7 +256,7 @@ struct UsageStoreCoverageTests { } @Test - func refreshClearsEnabledButUnavailableCachedState() async throws { + func `refresh clears enabled but unavailable cached state`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -305,7 +305,7 @@ struct UsageStoreCoverageTests { } @Test - func refreshClearsEnabledButUnavailableFailureState() async throws { + func `refresh clears enabled but unavailable failure state`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-failure-cleanup") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -340,7 +340,7 @@ struct UsageStoreCoverageTests { } @Test - func unavailableProviderWithOnlyCachedStatusGetsSingleCleanupPass() async throws { + func `unavailable provider with only cached status gets single cleanup pass`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup") settings.refreshFrequency = .manual settings.statusChecksEnabled = true @@ -372,7 +372,7 @@ struct UsageStoreCoverageTests { } @Test - func statusIndicatorsAndFailureGate() { + func `status indicators and failure gate`() { #expect(!ProviderStatusIndicator.none.hasIssue) #expect(ProviderStatusIndicator.maintenance.hasIssue) #expect(ProviderStatusIndicator.unknown.label == "Status unknown") @@ -423,7 +423,8 @@ struct UsageStoreCoverageTests { UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + settings: settings, + environmentBase: [:]) } } From 624bdc33c8e61ec6c3f2b6decafa5d4945cede87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:06:25 +0100 Subject: [PATCH 0192/1239] docs: update 0.21 changelog --- CHANGELOG.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bddd111e3e..ec428c9c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,36 +1,37 @@ # Changelog -## 0.21 — Unreleased +## 0.21 — 2026-04-18 ### Highlights - Abacus AI: add a new provider for ChatLLM and RouteLLM credit tracking with browser-cookie import, manual-cookie support, and monthly pace rendering. Thanks @ChrisGVE! -- Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! -- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. -- Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram! - Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF! +- Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! - Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205! -- Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! +- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. +- Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! +- Antigravity: restore account and quota probing across newer localhost endpoint/token layouts and API-level retry failures (#693, fixes #692). Thanks @anirudhvee! - Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! - Battery / refresh: cut menu redraw churn, skip background work for unavailable providers, and reuse cached OpenAI web views more efficiently (#708). -- Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! +- Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram! +- Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! ### Providers & Usage - Abacus AI: add provider support for ChatLLM and RouteLLM monthly compute-credit tracking with cookie import, manual cookie headers, timeout/browser-detection threading, optional billing fallback, and hardened cached-session retry behavior. Thanks @ChrisGVE! +- Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709). +- Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. +- Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). -- Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram! -- Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! +- Antigravity: try both language-server and extension-server endpoint/token combinations, retry after API-level errors, scope insecure localhost trust handling to loopback hosts, and restore local quota/account probing on newer Antigravity builds (#693, fixes #692). Thanks @anirudhvee! - Antigravity: prefer `userTier.name` over generic plan info when rendering the account plan so Google AI Ultra and similar tiers show their real subscription name, while still falling back cleanly when the tier label is absent or blank (#303). Thanks @zacklavin11! -- Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709). - Ollama: recognize `__Secure-session` cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee! - OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary! -- Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! - Refresh pipeline: skip background work for unavailable providers, clear stale cached state, and show explicit unavailable messages (#708). -- Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008! - Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han! - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. -- Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. - OpenAI web: keep cached WebViews across same-account refreshes and clean them up only when accounts or providers go stale (#708). +- Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram! +- Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008! ### Menu & Settings - Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm! From 9aefd871820832d0d4b20df48af58f4df456f1ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:49:53 +0100 Subject: [PATCH 0193/1239] docs: update appcast for 0.21 --- appcast.xml | 99 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 41 deletions(-) diff --git a/appcast.xml b/appcast.xml index 4a55cde5f3..0585c6e371 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,64 @@ CodexBar + + 0.21 + Sat, 18 Apr 2026 19:49:47 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 56 + 0.21 + 14.0 + CodexBar 0.21 +

Highlights

+
    +
  • Abacus AI: add a new provider for ChatLLM and RouteLLM credit tracking with browser-cookie import, manual-cookie support, and monthly pace rendering. Thanks @ChrisGVE!
  • +
  • Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF!
  • +
  • Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane!
  • +
  • Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205!
  • +
  • z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation.
  • +
  • Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation!
  • +
  • Antigravity: restore account and quota probing across newer localhost endpoint/token layouts and API-level retry failures (#693, fixes #692). Thanks @anirudhvee!
  • +
  • Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm!
  • +
  • Battery / refresh: cut menu redraw churn, skip background work for unavailable providers, and reuse cached OpenAI web views more efficiently (#708).
  • +
  • Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram!
  • +
  • Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han!
  • +
+

Providers & Usage

+
    +
  • Abacus AI: add provider support for ChatLLM and RouteLLM monthly compute-credit tracking with cookie import, manual cookie headers, timeout/browser-detection threading, optional billing fallback, and hardened cached-session retry behavior. Thanks @ChrisGVE!
  • +
  • Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like prolite, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709).
  • +
  • Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable.
  • +
  • Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205!
  • +
  • z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation.
  • +
  • Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663).
  • +
  • Antigravity: try both language-server and extension-server endpoint/token combinations, retry after API-level errors, scope insecure localhost trust handling to loopback hosts, and restore local quota/account probing on newer Antigravity builds (#693, fixes #692). Thanks @anirudhvee!
  • +
  • Antigravity: prefer userTier.name over generic plan info when rendering the account plan so Google AI Ultra and similar tiers show their real subscription name, while still falling back cleanly when the tier label is absent or blank (#303). Thanks @zacklavin11!
  • +
  • Ollama: recognize __Secure-session cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee!
  • +
  • OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary!
  • +
  • Refresh pipeline: skip background work for unavailable providers, clear stale cached state, and show explicit unavailable messages (#708).
  • +
  • Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han!
  • +
  • OpenCode / OpenCode Go: treat serialized _server auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500.
  • +
  • OpenAI web: keep cached WebViews across same-account refreshes and clean them up only when accounts or providers go stale (#708).
  • +
  • Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram!
  • +
  • Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008!
  • +
+

Menu & Settings

+
    +
  • Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm!
  • +
  • z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662).
  • +
  • Menu bar: avoid redundant merged-icon redraws and make hosted chart submenus load lazily without losing provider context (#708).
  • +
  • Merged menu: when Overview is selected, keep the merged menu bar icon aligned with the first Overview provider in configured order, even while that provider is still loading (#724). Thanks @anirudhvee!
  • +
  • Codex: add an OpenAI web battery-saver toggle, keep manual refresh available when battery saver is on, and hide OpenAI web submenus when web extras are disabled.
  • +
+

Development & Tooling

+
    +
  • Diagnostics: add lightweight battery instrumentation for menu updates and refresh work (#708).
  • +
  • Build script: make CodexBar-owned ad-hoc keychain cleanup opt-in with --clear-adhoc-keychain, and extend the explicit reset path to clear both com.steipete.CodexBar and com.steipete.codexbar.cache. Thanks @magnaprog!
  • +
+

View full changelog

+]]>
+ +
0.20 Wed, 08 Apr 2026 04:42:18 +0100 @@ -67,47 +125,6 @@ ]]> - - 0.19.0 - Mon, 23 Mar 2026 17:44:57 -0700 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 53 - 0.19.0 - 14.0 - CodexBar 0.19.0 -

Highlights

-
    -
  • Add Alibaba Coding Plan provider with region-aware quota fetching, widget integration, and browser-cookie import defaults (#574).
  • -
  • Align Cursor usage with the dashboard's Total/Auto/API lanes. (#587). Thanks @Rag30!
  • -
  • Add subscription utilization history chart to the menu with DST-safe data point identification (#589). Thanks @maxceem!
  • -
  • Refactor the Claude provider end to end into clearer, better-tested components while preserving behavior (#494). @ratulsarna
  • -
  • Add reset time display for Codex code review limits (#581). Thanks @Q1CHENL!
  • -
  • Add per-model token counts to cost history (#546). Thanks @iam-brain!
  • -
  • Fix Antigravity model selection to use stable model-family matching for Claude, Gemini Pro, and Gemini Flash, and preserve fallback lane visibility in the menu bar and icon (#590). Thanks @skainguyen1412!
  • -
  • Add GPT-5.4 mini and nano pricing (#561). Thanks @iam-brain!
  • -
-

Providers & Usage

-
    -
  • Alibaba: add Coding Plan provider support with region-aware web/API quota fetching, widget integration, and browser-cookie import defaults (#574).
  • -
  • Cursor: trust dashboard percent fields for Total/Auto/API usage, preserve on-demand remaining fallback views, and keep scanning imported browser-cookie candidates until a working Cursor session is found (#587, supersedes #579). Thanks @Rag30!
  • -
  • Claude: refactor the provider end to end into clearer components, with baseline docs and expanded tests to lock down behavior (#494).
  • -
  • Codex: show reset times for code review limits, including Core review reset parsing support (#581). Thanks @Q1CHENL!
  • -
  • Cost history: add per-model token counts so token usage is broken out by model (#546). Thanks @iam-brain!
  • -
  • Antigravity: replace label-order guessing with stable model-family selection for Claude, Gemini Pro, and Gemini Flash; fix mapping for Claude thinking models and placeholder model IDs; preserve fallback lane visibility in the menu bar and icon when only fallback lanes exist (#590). Thanks @skainguyen1412!
  • -
  • Kimi: tolerate API responses without resetTime so usage decoding no longer fails on sparse payloads.
  • -
  • Codex: add GPT-5.4 mini and nano pricing (#561). Thanks @iam-brain!
  • -
-

Menu & Settings

-
    -
  • Menu: add subscription utilization history chart with DST-safe chart point identifiers and per-provider plan utilization tracking (#589). Thanks @maxceem!
  • -
  • Menu bar: in Both display mode, fall back to percent when pace data is unavailable so text stays visible for providers without pace metrics (#527). Thanks @Astro-Han!
  • -
  • Settings: persist the resolved refresh cadence default to UserDefaults on first launch and repair invalid stored values so the setting stays normalized across relaunches (#519). Thanks @Astro-Han!
  • -
  • Menu: wrap long status blurbs and preserve wrapped titles for multiline entries (#543). Thanks @zkforge!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From ab3b0b0e81c369d04e1c68b1e7cf0a324aeee3ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 20:31:38 +0100 Subject: [PATCH 0194/1239] chore: start 0.22 development --- CHANGELOG.md | 2 ++ version.env | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec428c9c9b..fc9abc84cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.22 — Unreleased + ## 0.21 — 2026-04-18 ### Highlights diff --git a/version.env b/version.env index b28d35c74a..f2f277da72 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.21 +MARKETING_VERSION=0.22 BUILD_NUMBER=56 From 390a8b9866e2a3ab0fe9dbbd618196a79889d7b1 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 15 Apr 2026 21:18:51 +0800 Subject: [PATCH 0195/1239] Handle fnm Gemini OAuth config discovery Gemini token refresh failed when the CLI was installed via fnm because CodexBar only looked for OAuth client credentials in legacy oauth2.js paths under node_modules. fnm installs can expose Gemini through fnm_multishells symlinks and bundle the CLI into bundle/gemini.js plus hashed chunk files, so neither the Node version directory nor the chunk filename is stable enough to hardcode. Update Gemini OAuth credential discovery to: - keep the cheap legacy oauth2.js path reads as the primary lookup - detect fnm-managed paths and resolve the active Gemini package root via fnm - support bundled CLI layouts by following bundle imports instead of assuming fixed chunk names - bound the directory walk-up so an unrelated Gemini install elsewhere on the host cannot contaminate discovery started from the actual binary path Also add API tests covering the legacy layouts and the fnm bundle layout so expired-token refresh works across both installation styles. --- .../Providers/Gemini/GeminiStatusProbe.swift | 288 +++++++++++++++++- .../GeminiStatusProbeAPITests.swift | 124 ++++++++ .../CodexBarTests/GeminiTestEnvironment.swift | 172 ++++++++++- 3 files changed, 555 insertions(+), 29 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index d978630aa9..7ec634f59a 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -475,20 +475,280 @@ public struct GeminiStatusProbe: Sendable { } // Resolve symlinks to find the actual installation - let fm = FileManager.default - var realPath = geminiPath - if let resolved = try? fm.destinationOfSymbolicLink(atPath: geminiPath) { - if resolved.hasPrefix("/") { - realPath = resolved - } else { - realPath = (geminiPath as NSString).deletingLastPathComponent + "/" + resolved + let resolvedGeminiPath = URL(fileURLWithPath: geminiPath).resolvingSymlinksInPath().path + + // Try the legacy layouts first — they're cheap file reads and cover the common cases + // (Homebrew, npm/bun sibling, Nix) without spawning subprocesses or walking the tree. + if let credentials = Self.extractOAuthCredentialsFromLegacyPaths(realGeminiPath: resolvedGeminiPath) { + return credentials + } + + // For fnm-managed installs, ask fnm where the package lives + if Self.isLikelyFnmManagedPath(geminiPath) || Self.isLikelyFnmManagedPath(resolvedGeminiPath), + let fnmPath = TTYCommandRunner.which("fnm"), + let packageRoot = Self.resolveGeminiPackageRootViaFnm(fnmPath: fnmPath, environment: env), + let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) + { + return credentials + } + + // Fall back to walking up the directory tree from the binary + if let packageRoot = Self.findGeminiPackageRoot(startingAt: resolvedGeminiPath), + let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) + { + return credentials + } + + return nil + } + + private static func isLikelyFnmManagedPath(_ path: String) -> Bool { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + return normalized.contains("/fnm_multishells/") + || (normalized.contains("/node-versions/") && normalized.contains("/fnm/")) + } + + private static func resolveGeminiPackageRootViaFnm( + fnmPath: String, + environment: [String: String]) -> String? + { + guard let currentVersion = runProcess( + executable: fnmPath, + arguments: ["current"], + environment: environment, + timeout: 2.0), + !currentVersion.isEmpty + else { + return nil + } + + // Prefer npm root -g because require.resolve searches from the current + // working directory and often fails for globally-installed packages. + if let npmRoot = runProcess( + executable: fnmPath, + arguments: [ + "exec", + "--using", + currentVersion, + "npm", + "root", + "-g", + ], + environment: environment, + timeout: 4.0), + !npmRoot.isEmpty + { + let packageRoot = "\(npmRoot)/@google/gemini-cli" + let packageJSONPath = "\(packageRoot)/package.json" + if FileManager.default.fileExists(atPath: packageJSONPath) { + return packageRoot + } + } + + // Fallback for non-npm global installations. + if let packageJSONPath = runProcess( + executable: fnmPath, + arguments: [ + "exec", + "--using", + currentVersion, + "node", + "-p", + "require.resolve('@google/gemini-cli/package.json')", + ], + environment: environment, + timeout: 4.0), + !packageJSONPath.isEmpty + { + return (packageJSONPath as NSString).deletingLastPathComponent + } + + return nil + } + + private static func runProcess( + executable: String, + arguments: [String], + environment: [String: String], + timeout: TimeInterval) -> String? + { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + + var mergedEnvironment = environment + mergedEnvironment["PATH"] = PathBuilder.effectivePATH( + purposes: [.tty, .nodeTooling], + env: environment, + loginPATH: LoginShellPathCache.shared.current) + process.environment = mergedEnvironment + + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + process.standardInput = nil + + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSemaphore.signal() + } + + do { + try process.run() + } catch { + return nil + } + + let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success + if !didExit { + if process.isRunning { + process.terminate() + _ = exitSemaphore.wait(timeout: .now() + 0.5) + } + return nil + } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0, + let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty + else { + return nil + } + + return output.components(separatedBy: .newlines).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func findGeminiPackageRoot(startingAt path: String) -> String? { + let fileManager = FileManager.default + var currentURL = URL(fileURLWithPath: path).standardizedFileURL + + var isDirectory: ObjCBool = false + if !fileManager.fileExists(atPath: currentURL.path, isDirectory: &isDirectory) || !isDirectory.boolValue { + currentURL.deleteLastPathComponent() + } + + // Bound the walk so an unrelated Gemini install elsewhere on the host + // (e.g. a global npm/brew install unrelated to the resolved binary) can't + // contaminate discovery started from the actual binary path. + let maxAscents = 8 + for _ in 0...maxAscents { + let packageJSONURL = currentURL.appendingPathComponent("package.json") + if let data = try? Data(contentsOf: packageJSONURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + json["name"] as? String == "@google/gemini-cli" + { + return currentURL.path + } + + // Also check for a global Node installation layout: + // /lib/node_modules/@google/gemini-cli/package.json + let globalPackageJSONURL = currentURL + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("package.json") + if let data = try? Data(contentsOf: globalPackageJSONURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + json["name"] as? String == "@google/gemini-cli" + { + return globalPackageJSONURL.deletingLastPathComponent().path + } + + let parentURL = currentURL.deletingLastPathComponent() + if parentURL.path == currentURL.path { + return nil + } + currentURL = parentURL + } + + return nil + } + + private static func extractOAuthCredentials(fromGeminiPackageRoot packageRoot: String) -> OAuthClientCredentials? { + // Check the standard distributed file first, then any sibling core package + let oauthFile = "dist/src/code_assist/oauth2.js" + let candidatePaths = [ + "\(packageRoot)/\(oauthFile)", + "\(packageRoot)/node_modules/@google/gemini-cli-core/\(oauthFile)", + ] + + for path in candidatePaths { + if let content = try? String(contentsOfFile: path, encoding: .utf8), + let credentials = Self.parseOAuthCredentials(from: content) + { + return credentials + } + } + + return Self.extractOAuthCredentialsFromBundle(packageRoot: packageRoot) + } + + private static func extractOAuthCredentialsFromBundle(packageRoot: String) -> OAuthClientCredentials? { + let bundleRoot = URL(fileURLWithPath: packageRoot).appendingPathComponent("bundle", isDirectory: true) + let entryURL = bundleRoot.appendingPathComponent("gemini.js") + + guard FileManager.default.fileExists(atPath: entryURL.path) else { + return nil + } + + var pendingURLs = [entryURL] + var visitedPaths = Set() + + while !pendingURLs.isEmpty { + let currentURL = pendingURLs.removeFirst() + let standardizedPath = currentURL.standardizedFileURL.path + guard visitedPaths.insert(standardizedPath).inserted, + let content = try? String(contentsOf: currentURL, encoding: .utf8) + else { + continue + } + + if let credentials = Self.parseOAuthCredentials(from: content) { + return credentials + } + + let imports = Self.extractRelativeJavaScriptImports(from: content) + for importPath in imports { + let nextURL = URL(fileURLWithPath: importPath, relativeTo: currentURL.deletingLastPathComponent()) + .standardizedFileURL + guard nextURL.path.hasPrefix(bundleRoot.path) else { continue } + pendingURLs.append(nextURL) } } - // Navigate from bin/gemini to the oauth2.js file - // Homebrew path: .../libexec/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js - // Bun/npm path: .../node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js (sibling package) - let binDir = (realPath as NSString).deletingLastPathComponent + return nil + } + + private static func extractRelativeJavaScriptImports(from content: String) -> [String] { + let patterns = [ + #"(?:import|export)\s+(?:[^;]*?\s+from\s+)?[\"'](\./[^\"']+\.js)[\"']"#, + #"import\(\s*[\"'](\./[^\"']+\.js)[\"']\s*\)"#, + ] + + var discoveredPaths: [String] = [] + var seen = Set() + let fullRange = NSRange(content.startIndex..., in: content) + + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } + for match in regex.matches(in: content, range: fullRange) { + guard let range = Range(match.range(at: 1), in: content) else { continue } + let path = String(content[range]) + if seen.insert(path).inserted { + discoveredPaths.append(path) + } + } + } + + return discoveredPaths + } + + private static func extractOAuthCredentialsFromLegacyPaths(realGeminiPath: String) -> OAuthClientCredentials? { + let binDir = (realGeminiPath as NSString).deletingLastPathComponent let baseDir = (binDir as NSString).deletingLastPathComponent let oauthSubpath = @@ -509,8 +769,10 @@ public struct GeminiStatusProbe: Sendable { ] for path in possiblePaths { - if let content = try? String(contentsOfFile: path, encoding: .utf8) { - return self.parseOAuthCredentials(from: content) + if let content = try? String(contentsOfFile: path, encoding: .utf8), + let credentials = Self.parseOAuthCredentials(from: content) + { + return credentials } } diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index f4a457fa85..61cb8b0f5c 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -67,6 +67,13 @@ struct GeminiStatusProbeAPITests { switch host { case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } let json = GeminiAPITestHelpers.jsonData([ "access_token": "new-token", "expires_in": 3600, @@ -139,6 +146,120 @@ struct GeminiStatusProbeAPITests { switch host { case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + let json = GeminiAPITestHelpers.jsonData(["projects": []]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + let auth = request.value(forHTTPHeaderField: "Authorization") + if auth != "Bearer new-token" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + let auth = request.value(forHTTPHeaderField: "Authorization") + if auth != "Bearer new-token" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Paid") + } + + @Test + func `refreshes expired token with fnm bundle layout`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + let binURL = try env.writeFakeGeminiCLI(layout: .fnmBundle) + // Match the real fnm layout: package root is inside the same multishell + // dir as the bin symlink target, under lib/node_modules/@google/gemini-cli. + let multishellRoot = binURL.deletingLastPathComponent().deletingLastPathComponent() + let packageJSONPath = multishellRoot + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("package.json") + let npmRoot = packageJSONPath + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + _ = try env.writeFakeFnm(npmRoot: npmRoot, geminiPackageJSONPath: packageJSONPath.path) + + let previousPath = ProcessInfo.processInfo.environment["PATH"] + let fakeBinDir = env.homeURL.appendingPathComponent("bin").path + let pathValue = if let previousPath, !previousPath.isEmpty { + "\(fakeBinDir):\(binURL.deletingLastPathComponent().path):\(previousPath)" + } else { + "\(fakeBinDir):\(binURL.deletingLastPathComponent().path)" + } + setenv("PATH", pathValue, 1) + + let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + unsetenv("GEMINI_CLI_PATH") + defer { + if let previousPath { + setenv("PATH", previousPath, 1) + } else { + unsetenv("PATH") + } + + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } let json = GeminiAPITestHelpers.jsonData([ "access_token": "new-token", "expires_in": 3600, @@ -178,6 +299,9 @@ struct GeminiStatusProbeAPITests { let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) let snapshot = try await probe.fetch() #expect(snapshot.accountPlan == "Paid") + + let updated = try env.readCredentials() + #expect(updated["access_token"] as? String == "new-token") } @Test diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift index 3d6b0b4bb6..0a371003e7 100644 --- a/Tests/CodexBarTests/GeminiTestEnvironment.swift +++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift @@ -4,6 +4,7 @@ struct GeminiTestEnvironment { enum GeminiCLILayout { case npmNested case nixShare + case fnmBundle } let homeURL: URL @@ -57,9 +58,9 @@ struct GeminiTestEnvironment { let binDir = base.appendingPathComponent("bin") try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) - let oauthPath: URL = switch layout { + switch layout { case .npmNested: - base + let oauthPath = base .appendingPathComponent("lib") .appendingPathComponent("node_modules") .appendingPathComponent("@google") @@ -71,8 +72,28 @@ struct GeminiTestEnvironment { .appendingPathComponent("src") .appendingPathComponent("code_assist") .appendingPathComponent("oauth2.js") + + if includeOAuth { + try FileManager.default.createDirectory( + at: oauthPath.deletingLastPathComponent(), + withIntermediateDirectories: true) + + let oauthContent = """ + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + } + + let geminiBinary = binDir.appendingPathComponent("gemini") + try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiBinary.path) + return geminiBinary + case .nixShare: - base + let oauthPath = base .appendingPathComponent("share") .appendingPathComponent("gemini-cli") .appendingPathComponent("node_modules") @@ -82,25 +103,144 @@ struct GeminiTestEnvironment { .appendingPathComponent("src") .appendingPathComponent("code_assist") .appendingPathComponent("oauth2.js") - } - if includeOAuth { - try FileManager.default.createDirectory( - at: oauthPath.deletingLastPathComponent(), - withIntermediateDirectories: true) + if includeOAuth { + try FileManager.default.createDirectory( + at: oauthPath.deletingLastPathComponent(), + withIntermediateDirectories: true) + + let oauthContent = """ + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + } - let oauthContent = """ - const OAUTH_CLIENT_ID = 'test-client-id'; - const OAUTH_CLIENT_SECRET = 'test-client-secret'; + let geminiBinary = binDir.appendingPathComponent("gemini") + try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiBinary.path) + return geminiBinary + + case .fnmBundle: + // Mirror a real fnm multishell layout: bin/gemini is a single relative + // symlink into the same multishell's lib/node_modules/@google/gemini-cli, + // which is a plain directory with the real package.json + bundle/*.js. + let multishellRoot = self.homeURL + .appendingPathComponent("Library") + .appendingPathComponent("Caches") + .appendingPathComponent("fnm_multishells") + .appendingPathComponent("12345_67890") + let binDir = multishellRoot.appendingPathComponent("bin") + let packageRoot = multishellRoot + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + let bundleDir = packageRoot.appendingPathComponent("bundle") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) + + let packageJSON = """ + { + "name": "@google/gemini-cli" + } """ - try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + try packageJSON.write( + to: packageRoot.appendingPathComponent("package.json"), + atomically: true, + encoding: .utf8) + + let chunkName = "chunk-TEST123.js" + let geminiEntry = bundleDir.appendingPathComponent("gemini.js") + let geminiContent = """ + #!/usr/bin/env node + import { start } from "./\(chunkName)"; + start(); + """ + try geminiContent.write(to: geminiEntry, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiEntry.path) + + let chunkContent = if includeOAuth { + """ + export const start = () => {}; + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + } else { + "export const start = () => {};\n" + } + try chunkContent.write( + to: bundleDir.appendingPathComponent(chunkName), + atomically: true, + encoding: .utf8) + + // Relative symlink matching what fnm actually creates: + // fnm_multishells/XXX/bin/gemini -> ../lib/node_modules/@google/gemini-cli/bundle/gemini.js + // Use the path-based API so the target is stored as a literal relative + // string; the URL-based API resolves URL(fileURLWithPath: "../...") against + // the process CWD, which produces a bogus absolute target. + let geminiBinary = binDir.appendingPathComponent("gemini") + try FileManager.default.createSymbolicLink( + atPath: geminiBinary.path, + withDestinationPath: "../lib/node_modules/@google/gemini-cli/bundle/gemini.js") + + return geminiBinary } + } + + func writeFakeFnm( + currentVersion: String = "v24.6.0", + npmRoot: String? = nil, + geminiPackageJSONPath: String) throws -> URL + { + let binDir = self.homeURL.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + + let fnmPath = binDir.appendingPathComponent("fnm") + let script = if let npmRoot { + """ + #!/bin/bash + if [ "$1" = "current" ]; then + printf '%s\n' "\(currentVersion)" + exit 0 + fi + + if [ "$1" = "exec" ] && [ "$4" = "npm" ] && [ "$5" = "root" ] && [ "$6" = "-g" ]; then + printf '%s\n' "\(npmRoot)" + exit 0 + fi - let geminiBinary = binDir.appendingPathComponent("gemini") - try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + if [ "$1" = "exec" ] && [ "$4" = "node" ]; then + printf '%s\n' "\(geminiPackageJSONPath)" + exit 0 + fi + + exit 1 + """ + } else { + """ + #!/bin/bash + if [ "$1" = "current" ]; then + printf '%s\n' "\(currentVersion)" + exit 0 + fi + + if [ "$1" = "exec" ]; then + printf '%s\n' "\(geminiPackageJSONPath)" + exit 0 + fi + + exit 1 + """ + } + try script.write(to: fnmPath, atomically: true, encoding: .utf8) try FileManager.default.setAttributes( [.posixPermissions: 0o755], - ofItemAtPath: geminiBinary.path) - return geminiBinary + ofItemAtPath: fnmPath.path) + return fnmPath } } From ad0aada26a9b12bbfdb96f03c5ed2c0c542cb07f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:28:27 +0100 Subject: [PATCH 0196/1239] docs: credit Gemini OAuth discovery fix (#723) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9abc84cf..ba7796f87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.22 — Unreleased +### Providers & Usage +- Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! + ## 0.21 — 2026-04-18 ### Highlights From 64f0bac2bb722471858a477439a6bd3a18f82e20 Mon Sep 17 00:00:00 2001 From: Erik Josephson Date: Tue, 24 Mar 2026 05:50:55 -0400 Subject: [PATCH 0197/1239] fix: handle errSecInteractionNotAllowed in KeychainCacheStore to prevent cache self-destruction on wake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the keychain is temporarily locked (e.g. immediately after wake from sleep), SecItemCopyMatching returns errSecInteractionNotAllowed (-25308). Previously this fell into the default case, returned .invalid, and the caller deleted the cache entry — causing every wake from sleep to require a fresh read of "Claude Code-credentials", which triggers a keychain prompt. Two changes: 1. Apply KeychainNoUIQuery to the cache load query so the call never blocks waiting for UI interaction (consistent with how other no-UI reads are done elsewhere in the codebase). 2. Add an explicit case for errSecInteractionNotAllowed that returns .missing instead of .invalid — the entry is valid, just temporarily inaccessible, so it should not be deleted. --- Sources/CodexBarCore/KeychainCacheStore.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index ebe5c45ae0..2fbd1df2af 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -46,13 +46,14 @@ public enum KeychainCacheStore { return testResult } #if os(macOS) - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] + KeychainNoUIQuery.apply(to: &query) var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -70,6 +71,12 @@ public enum KeychainCacheStore { return .found(decoded) case errSecItemNotFound: return .missing + case errSecInteractionNotAllowed: + // Keychain is temporarily locked (e.g. immediately after wake from sleep). + // The cache entry is valid — treat as missing so the caller falls through + // gracefully rather than deleting a perfectly good cache entry. + self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") + return .missing default: self.log.error("Keychain cache read failed (\(key.account)): \(status)") return .invalid From aa26573b35fb354d16647bb1d8e92804050f9f8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:34:40 +0100 Subject: [PATCH 0198/1239] fix: preserve keychain cache after wake (#594) --- CHANGELOG.md | 3 ++ Sources/CodexBarCore/KeychainCacheStore.swift | 30 ++++++++++++------- .../KeychainCacheStoreTests.swift | 17 +++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7796f87d..9c137ae332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Providers & Usage - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! +### Fixes +- Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98! + ## 0.21 — 2026-04-18 ### Highlights diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 2fbd1df2af..03060d9c26 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -69,17 +69,8 @@ public enum KeychainCacheStore { return .invalid } return .found(decoded) - case errSecItemNotFound: - return .missing - case errSecInteractionNotAllowed: - // Keychain is temporarily locked (e.g. immediately after wake from sleep). - // The cache entry is valid — treat as missing so the caller falls through - // gracefully rather than deleting a perfectly good cache entry. - self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") - return .missing default: - self.log.error("Keychain cache read failed (\(key.account)): \(status)") - return .invalid + return self.loadResultForKeychainReadFailure(status: status, key: key) } #else return .missing @@ -211,6 +202,25 @@ public enum KeychainCacheStore { return decoder } + #if os(macOS) + static func loadResultForKeychainReadFailure( + status: OSStatus, + key: Key) -> LoadResult + { + switch status { + case errSecItemNotFound: + return .missing + case errSecInteractionNotAllowed: + // Keychain is temporarily locked, e.g. immediately after wake from sleep. + self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") + return .missing + default: + self.log.error("Keychain cache read failed (\(key.account)): \(status)") + return .invalid + } + } + #endif + private static func loadFromTestStore( key: Key, as type: Entry.Type) -> LoadResult? diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 0eb90d6405..41bd827283 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -68,4 +68,21 @@ struct KeychainCacheStoreTests { #expect(Bool(false), "Expected keychain cache entry to be cleared") } } + + #if os(macOS) + @Test + func `interaction not allowed is treated as temporarily missing`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let result: KeychainCacheStore.LoadResult = KeychainCacheStore.loadResultForKeychainReadFailure( + status: errSecInteractionNotAllowed, + key: key) + + switch result { + case .missing: + #expect(true) + case .found, .invalid: + #expect(Bool(false), "Expected temporary keychain lock to preserve cache") + } + } + #endif } From cad7f8385a552530886f8443c75f6485c78b75dc Mon Sep 17 00:00:00 2001 From: skhe Date: Sat, 18 Apr 2026 17:38:54 +0800 Subject: [PATCH 0199/1239] Prefer verification_uri_complete for Copilot login --- .../Providers/Copilot/CopilotLoginFlow.swift | 2 +- .../Providers/Copilot/CopilotDeviceFlow.swift | 6 +++ .../CopilotDeviceFlowTests.swift | 42 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/CopilotDeviceFlowTests.swift diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 55275ae613..1170f2236f 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -30,7 +30,7 @@ struct CopilotLoginFlow { return // Cancelled } - if let url = URL(string: code.verificationUri) { + if let url = URL(string: code.verificationURLToOpen) { NSWorkspace.shared.open(url) } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift index 094ad9f2e7..ff778adf85 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift @@ -11,13 +11,19 @@ public struct CopilotDeviceFlow: Sendable { public let deviceCode: String public let userCode: String public let verificationUri: String + public let verificationUriComplete: String? public let expiresIn: Int public let interval: Int + public var verificationURLToOpen: String { + self.verificationUriComplete ?? self.verificationUri + } + enum CodingKeys: String, CodingKey { case deviceCode = "device_code" case userCode = "user_code" case verificationUri = "verification_uri" + case verificationUriComplete = "verification_uri_complete" case expiresIn = "expires_in" case interval } diff --git a/Tests/CodexBarTests/CopilotDeviceFlowTests.swift b/Tests/CodexBarTests/CopilotDeviceFlowTests.swift new file mode 100644 index 0000000000..7e0b1f20e4 --- /dev/null +++ b/Tests/CodexBarTests/CopilotDeviceFlowTests.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Foundation +import Testing + +struct CopilotDeviceFlowTests { + @Test + func `prefers verification uri complete when available`() throws { + let response = try JSONDecoder().decode( + CopilotDeviceFlow.DeviceCodeResponse.self, + from: Data( + """ + { + "device_code": "device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "verification_uri_complete": "https://github.com/login/device?user_code=ABCD-EFGH", + "expires_in": 900, + "interval": 5 + } + """.utf8)) + + #expect(response.verificationURLToOpen == "https://github.com/login/device?user_code=ABCD-EFGH") + } + + @Test + func `falls back to verification uri when complete url missing`() throws { + let response = try JSONDecoder().decode( + CopilotDeviceFlow.DeviceCodeResponse.self, + from: Data( + """ + { + "device_code": "device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5 + } + """.utf8)) + + #expect(response.verificationURLToOpen == "https://github.com/login/device") + } +} From d968dd9696125fad5e2dd4eb016eb520f3bb6262 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:38:52 +0100 Subject: [PATCH 0200/1239] docs: credit Copilot verification URL fix (#739) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c137ae332..d50ab29d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Providers & Usage - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! +- Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! ### Fixes - Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98! From ce27570243310d3f31bccee47c83196f96d9f419 Mon Sep 17 00:00:00 2001 From: hezhongtang Date: Mon, 13 Apr 2026 16:54:28 +0800 Subject: [PATCH 0201/1239] fix(alibaba): update China mainland RPC endpoint to bailian-cs.console.aliyun.com - Change consoleRPCBaseURLString from bailian-beijing-cs.aliyuncs.com to bailian-cs.console.aliyun.com (the old domain causes TLS errors) - Update consoleSite from BAILIAN_CONSOLE to BAILIAN_ALIYUN to match actual browser request payload - Add new domain to cookie importer allowlist and test stub --- .../Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift | 4 ++-- .../Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift | 1 + Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift index 1e7e5b7727..c3d94e2ec9 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift @@ -47,7 +47,7 @@ public enum AlibabaCodingPlanAPIRegion: String, CaseIterable, Sendable { case .international: "MODELSTUDIO_ALIBABACLOUD" case .chinaMainland: - "BAILIAN_CONSOLE" + "BAILIAN_ALIYUN" } } @@ -79,7 +79,7 @@ public enum AlibabaCodingPlanAPIRegion: String, CaseIterable, Sendable { case .international: "https://bailian-singapore-cs.alibabacloud.com" case .chinaMainland: - "https://bailian-beijing-cs.aliyuncs.com" + "https://bailian-cs.console.aliyun.com" } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift index ea354697a1..9d8f9e27b7 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift @@ -13,6 +13,7 @@ public enum AlibabaCodingPlanCookieImporter { private static let cookieClient = BrowserCookieClient() private static let cookieDomains = [ "bailian-singapore-cs.alibabacloud.com", + "bailian-cs.console.aliyun.com", "bailian-beijing-cs.aliyuncs.com", "modelstudio.console.alibabacloud.com", "bailian.console.aliyun.com", diff --git a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift index 413b9661fa..a013df8f45 100644 --- a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift @@ -945,6 +945,7 @@ final class AlibabaConsoleSECTokenStubURLProtocol: URLProtocol { "modelstudio.console.alibabacloud.com", "bailian-singapore-cs.alibabacloud.com", "bailian.console.aliyun.com", + "bailian-cs.console.aliyun.com", "bailian-beijing-cs.aliyuncs.com", ].contains(host) } From aae3003dbb7e8b14599462c12d9a6255eceb1f64 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:43:01 +0100 Subject: [PATCH 0202/1239] docs: credit Alibaba endpoint fix (#712) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d50ab29d79..98007322cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Providers & Usage - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! +- Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! ### Fixes - Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98! From ea02e5fad770001f86d4105fe15c034dda10e596 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 17 Apr 2026 03:26:38 -0400 Subject: [PATCH 0203/1239] Handle Synthetic rolling, weekly, and search quota shape Parse Synthetic's current quota response (rollingFiveHourLimit, weeklyTokenLimit, search.hourly) in addition to the legacy pack format, and surface weekly credit regeneration pacing in the menu card. Keep slot identity stable so a missing lane never promotes another lane into the wrong UI label, and rebuild the countdown at render time so it doesn't freeze between snapshot refreshes. --- Sources/CodexBar/MenuCardView.swift | 45 +++- .../CodexBarCore/ProviderCostSnapshot.swift | 4 + .../SyntheticProviderDescriptor.swift | 10 +- .../Synthetic/SyntheticUsageStats.swift | 241 ++++++++++++++++-- .../SyntheticProviderTests.swift | 113 ++++++++ 5 files changed, 386 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 4d9c05127b..e63301d466 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1105,7 +1105,7 @@ extension UsageMenuCardView.Model { percentStyle: PercentStyle, zaiTimeDetail: String?) -> Metric { - let paceDetail = Self.weeklyPaceDetail( + var paceDetail = Self.weeklyPaceDetail( window: weekly, now: input.now, pace: input.weeklyPace, @@ -1141,6 +1141,16 @@ extension UsageMenuCardView.Model { { weeklyResetText = detail } + if input.provider == .synthetic, + let regen = Self.syntheticRegenDetail( + weekly: weekly, + cost: input.snapshot?.providerCost, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + weeklyResetText = regen.resetText + paceDetail = regen.pace + } return Metric( id: "secondary", title: input.metadata.weeklyLabel, @@ -1314,6 +1324,38 @@ extension UsageMenuCardView.Model { paceOnTop: paceOnTop) } + private static func syntheticRegenDetail( + weekly: RateWindow, + cost: ProviderCostSnapshot?, + now: Date, + showUsed: Bool) -> (resetText: String, pace: PaceDetail)? + { + guard let cost, + cost.limit > 0, + let nextRegenAmount = cost.nextRegenAmount, + nextRegenAmount > 0, + let resetsAt = weekly.resetsAt + else { return nil } + + let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) + let resetText = "Regenerates \(countdown)" + + let nextRegenPercent = (nextRegenAmount / cost.limit) * 100 + let afterNextRegenRemaining = min(100, weekly.remainingPercent + nextRegenPercent) + let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining + let suffix = showUsed ? "used after next regen" : "after next regen" + let ticksToFull = max(0, cost.used) / nextRegenAmount + let left = String(format: "%.0f%% %@", afterNextRegen, suffix) + let right = if ticksToFull <= 0.1 { + "Near full" + } else if ticksToFull < 1.5 { + "Full in ~1 regen" + } else { + String(format: "Full in ~%.0f regens", ceil(ticksToFull)) + } + return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) + } + private static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, @@ -1378,6 +1420,7 @@ extension UsageMenuCardView.Model { { guard let cost else { return nil } guard cost.limit > 0 else { return nil } + guard provider != .synthetic else { return nil } let used: String let limit: String diff --git a/Sources/CodexBarCore/ProviderCostSnapshot.swift b/Sources/CodexBarCore/ProviderCostSnapshot.swift index c3d25af493..7941fb540d 100644 --- a/Sources/CodexBarCore/ProviderCostSnapshot.swift +++ b/Sources/CodexBarCore/ProviderCostSnapshot.swift @@ -9,6 +9,8 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { public let period: String? /// Optional renewal/reset timestamp for the period. public let resetsAt: Date? + /// Optional amount restored on the next regeneration tick for providers with rolling credit recovery. + public let nextRegenAmount: Double? public let updatedAt: Date public init( @@ -17,6 +19,7 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { currencyCode: String, period: String? = nil, resetsAt: Date? = nil, + nextRegenAmount: Double? = nil, updatedAt: Date) { self.used = used @@ -24,6 +27,7 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { self.currencyCode = currencyCode self.period = period self.resetsAt = resetsAt + self.nextRegenAmount = nextRegenAmount self.updatedAt = updatedAt } } diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift index 550ab9190a..b0501408df 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift @@ -10,12 +10,12 @@ public enum SyntheticProviderDescriptor { metadata: ProviderMetadata( id: .synthetic, displayName: "Synthetic", - sessionLabel: "Quota", - weeklyLabel: "Usage", - opusLabel: nil, - supportsOpus: false, + sessionLabel: "Five-hour quota", + weeklyLabel: "Weekly tokens", + opusLabel: "Search hourly", + supportsOpus: true, supportsCredits: false, - creditsHint: "", + creditsHint: "Weekly token quota regenerates continuously.", toggleTitle: "Show Synthetic usage", cliName: "synthetic", defaultEnabled: false, diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index 198c42c252..ea444f5b9c 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -9,29 +9,42 @@ public struct SyntheticQuotaEntry: Sendable { public let windowMinutes: Int? public let resetsAt: Date? public let resetDescription: String? + public let cost: ProviderCostSnapshot? public init( label: String?, usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, - resetDescription: String?) + resetDescription: String?, + cost: ProviderCostSnapshot? = nil) { self.label = label self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.cost = cost } } public struct SyntheticUsageSnapshot: Sendable { public let quotas: [SyntheticQuotaEntry] + /// Slot-identified lanes for the known Synthetic response shape: [rolling-5h, weekly, search-hourly]. + /// When set, `toUsageSnapshot` maps slot 0 → primary, slot 1 → secondary, slot 2 → tertiary, + /// so a missing lane stays nil instead of promoting the next lane into the wrong UI label. + public let slottedQuotas: [SyntheticQuotaEntry?]? public let planName: String? public let updatedAt: Date - public init(quotas: [SyntheticQuotaEntry], planName: String?, updatedAt: Date) { + public init( + quotas: [SyntheticQuotaEntry], + slottedQuotas: [SyntheticQuotaEntry?]? = nil, + planName: String?, + updatedAt: Date) + { self.quotas = quotas + self.slottedQuotas = slottedQuotas self.planName = planName self.updatedAt = updatedAt } @@ -39,11 +52,13 @@ public struct SyntheticUsageSnapshot: Sendable { extension SyntheticUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let primaryEntry = self.quotas.first - let secondaryEntry = self.quotas.dropFirst().first + let slots = self.slottedQuotas + ?? [self.quotas.first, self.quotas.dropFirst().first, self.quotas.dropFirst(2).first] + let entries: [SyntheticQuotaEntry?] = (0..<3).map { slots.indices.contains($0) ? slots[$0] : nil } - let primary = primaryEntry.map(Self.rateWindow(for:)) - let secondary = secondaryEntry.map(Self.rateWindow(for:)) + let primary = entries[0].map(Self.rateWindow(for:)) + let secondary = entries[1].map(Self.rateWindow(for:)) + let tertiary = entries[2].map(Self.rateWindow(for:)) let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -56,8 +71,8 @@ extension SyntheticUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, - providerCost: nil, + tertiary: tertiary, + providerCost: self.quotas.first(where: { $0.cost != nil })?.cost, updatedAt: self.updatedAt, identity: identity) } @@ -147,20 +162,46 @@ enum SyntheticUsageParser { }() let planName = self.planName(from: root) - let quotaObjects = self.quotaObjects(from: root) - let quotas = quotaObjects.compactMap { self.parseQuota($0) } + if let slots = self.prioritizedQuotaSlots(from: root) { + let slotted: [SyntheticQuotaEntry?] = slots.map { $0.flatMap(self.parseQuota) } + let flat = slotted.compactMap(\.self) + guard !flat.isEmpty else { + throw SyntheticUsageError.parseFailed("Missing quota data.") + } + return SyntheticUsageSnapshot( + quotas: flat, + slottedQuotas: slotted, + planName: planName, + updatedAt: now) + } + + let quotas = self.fallbackQuotaObjects(from: root).compactMap(self.parseQuota) guard !quotas.isEmpty else { throw SyntheticUsageError.parseFailed("Missing quota data.") } - return SyntheticUsageSnapshot( quotas: quotas, planName: planName, updatedAt: now) } - private static func quotaObjects(from root: [String: Any]) -> [[String: Any]] { + /// Returns slot-positional quota payloads `[rolling-5h, weekly, search-hourly]` when the known Synthetic + /// response shape is detected. Missing lanes stay nil in their slot so downstream code doesn't shift + /// labels. Returns nil if none of the known keys appear, so the fallback path runs. + private static func prioritizedQuotaSlots(from root: [String: Any]) -> [[String: Any]?]? { + let dataDict = root["data"] as? [String: Any] + let rolling = self.namedQuota(root["rollingFiveHourLimit"], label: "Rolling five-hour limit") + ?? self.namedQuota(dataDict?["rollingFiveHourLimit"], label: "Rolling five-hour limit") + let weekly = self.namedQuota(root["weeklyTokenLimit"], label: "Weekly token limit") + ?? self.namedQuota(dataDict?["weeklyTokenLimit"], label: "Weekly token limit") + let searchHourly = self.namedQuota((root["search"] as? [String: Any])?["hourly"], label: "Search hourly") + ?? self.namedQuota((dataDict?["search"] as? [String: Any])?["hourly"], label: "Search hourly") + let slots: [[String: Any]?] = [rolling, weekly, searchHourly] + return slots.contains(where: { $0 != nil }) ? slots : nil + } + + private static func fallbackQuotaObjects(from root: [String: Any]) -> [[String: Any]] { let dataDict = root["data"] as? [String: Any] let candidates: [Any?] = [ root["quotas"], @@ -179,14 +220,8 @@ enum SyntheticUsageParser { ] for candidate in candidates { - if let array = candidate as? [[String: Any]] { return array } - if let array = candidate as? [Any] { - let dicts = array.compactMap { $0 as? [String: Any] } - if !dicts.isEmpty { return dicts } - } - if let dict = candidate as? [String: Any], self.isQuotaPayload(dict) { - return [dict] - } + let quotas = self.extractQuotaObjects(from: candidate) + if !quotas.isEmpty { return quotas } } return [] } @@ -239,14 +274,19 @@ enum SyntheticUsageParser { let windowMinutes = windowMinutes(from: payload) let resetsAt = self.firstDate(in: payload, keys: self.resetKeys) + // Leave resetDescription nil when resetsAt is set so the UI rebuilds the countdown each render + // against the current clock instead of freezing a stale "in Xm" string at parse time. let resetDescription = resetsAt == nil ? self.windowDescription(minutes: windowMinutes) : nil + let cost = self.providerCost(from: payload, usedPercent: clamped, resetsAt: resetsAt) + return SyntheticQuotaEntry( label: label, usedPercent: clamped, windowMinutes: windowMinutes, resetsAt: resetsAt, - resetDescription: resetDescription) + resetDescription: resetDescription, + cost: cost) } private static func isQuotaPayload(_ payload: [String: Any]) -> Bool { @@ -271,6 +311,70 @@ enum SyntheticUsageParser { if let seconds = self.firstDouble(in: payload, keys: windowSecondsKeys) { return Int((seconds / 60).rounded()) } + if let text = self.firstString(in: payload, keys: windowStringKeys) { + return self.windowMinutes(from: text) + } + return nil + } + + private static func namedQuota(_ candidate: Any?, label: String) -> [String: Any]? { + guard var payload = candidate as? [String: Any], self.isQuotaPayload(payload) else { return nil } + if payload["label"] == nil, payload["name"] == nil { + payload["label"] = label + } + return payload + } + + private static func extractQuotaObjects(from candidate: Any?) -> [[String: Any]] { + switch candidate { + case let array as [[String: Any]]: + var nestedQuotas: [[String: Any]] = [] + for entry in array { + if self.isQuotaPayload(entry) { + nestedQuotas.append(entry) + } else { + nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: entry)) + } + } + return nestedQuotas + case let array as [Any]: + return array.flatMap { self.extractQuotaObjects(from: $0) } + case let dict as [String: Any]: + if self.isQuotaPayload(dict) { + return [dict] + } + var nestedQuotas: [[String: Any]] = [] + for key in dict.keys.sorted() { + nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: dict[key])) + } + return nestedQuotas + default: + return [] + } + } + + private static func windowMinutes(from text: String) -> Int? { + let normalized = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "") + guard !normalized.isEmpty else { return nil } + + let unitMappings: [(suffixes: [String], multiplier: Double)] = [ + (["minutes", "minute", "mins", "min", "m"], 1), + (["hours", "hour", "hrs", "hr", "h"], 60), + (["days", "day", "d"], 24 * 60), + ] + + for mapping in unitMappings { + for suffix in mapping.suffixes { + guard normalized.hasSuffix(suffix) else { continue } + let valueText = String(normalized.dropLast(suffix.count)) + guard let value = Double(valueText), value > 0 else { return nil } + return Int((value * mapping.multiplier).rounded()) + } + } + return nil } @@ -288,6 +392,57 @@ enum SyntheticUsageParser { return "\(minutes) minute\(minutes == 1 ? "" : "s") window" } + private static func providerCost( + from payload: [String: Any], + usedPercent: Double, + resetsAt: Date?) -> ProviderCostSnapshot? + { + guard let limit = self.firstCurrency(in: payload, keys: self.costLimitKeys) else { return nil } + + let remaining = self.firstCurrency(in: payload, keys: self.costRemainingKeys) + let usedFromPayload = self.firstCurrency(in: payload, keys: self.costUsedKeys) + let nextRegenAmount = self.firstCurrency(in: payload, keys: self.regenAmountKeys) + let used = if let usedFromPayload { + usedFromPayload + } else if let remaining { + max(0, limit - remaining) + } else { + (usedPercent.clamped(to: 0...100) / 100) * limit + } + + return ProviderCostSnapshot( + used: used, + limit: limit, + currencyCode: "USD", + period: "Weekly", + resetsAt: resetsAt, + nextRegenAmount: nextRegenAmount, + updatedAt: Date()) + } + + private static func firstCurrency(in payload: [String: Any], keys: [String]) -> Double? { + for key in keys { + guard let value = payload[key] else { continue } + if let text = value as? String, + let parsed = self.parseCurrency(text) + { + return parsed + } + if let number = self.doubleValue(value) { + return number + } + } + return nil + } + + private static func parseCurrency(_ text: String) -> Double? { + let cleaned = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "$", with: "") + .replacingOccurrences(of: ",", with: "") + return Double(cleaned) + } + private static func normalizedPercent(_ value: Double?) -> Double? { guard let value else { return nil } if value <= 1 { return value * 100 } @@ -423,6 +578,13 @@ enum SyntheticUsageParser { private static let limitKeys = [ "limit", + "messageLimit", + "message_limit", + "messages", + "maxRequests", + "max_requests", + "requestLimit", + "request_limit", "quota", "max", "total", @@ -433,6 +595,10 @@ enum SyntheticUsageParser { private static let usedKeys = [ "used", "usage", + "usedMessages", + "used_messages", + "messagesUsed", + "messages_used", "requests", "requestCount", "request_count", @@ -456,6 +622,10 @@ enum SyntheticUsageParser { "renew_at", "renewsAt", "renews_at", + "nextTickAt", + "next_tick_at", + "nextRegenAt", + "next_regen_at", "periodEnd", "period_end", "expiresAt", @@ -464,6 +634,26 @@ enum SyntheticUsageParser { "end_at", ] + private static let regenAmountKeys = [ + "nextRegenCredits", + "next_regen_credits", + ] + + private static let costLimitKeys = [ + "maxCredits", + "max_credits", + ] + + private static let costRemainingKeys = [ + "remainingCredits", + "remaining_credits", + ] + + private static let costUsedKeys = [ + "usedCredits", + "used_credits", + ] + private static let windowMinutesKeys = [ "windowMinutes", "window_minutes", @@ -491,6 +681,15 @@ enum SyntheticUsageParser { "periodSeconds", "period_seconds", ] + + private static let windowStringKeys = [ + "window", + "windowLabel", + "window_label", + "period", + "periodLabel", + "period_label", + ] } public enum SyntheticUsageError: LocalizedError, Sendable { diff --git a/Tests/CodexBarTests/SyntheticProviderTests.swift b/Tests/CodexBarTests/SyntheticProviderTests.swift index 32b63e4816..08a0ad4b4f 100644 --- a/Tests/CodexBarTests/SyntheticProviderTests.swift +++ b/Tests/CodexBarTests/SyntheticProviderTests.swift @@ -61,4 +61,117 @@ struct SyntheticUsageSnapshotTests { #expect(usage.primary?.resetsAt == expectedReset) #expect(usage.loginMethod(for: .synthetic) == nil) } + + @Test + func `parses nested subscription pack quota`() throws { + let json = """ + { + "subscription": { + "packs": 2, + "rateLimit": { + "messages": 1000, + "requests": 250, + "period": "5hr", + "resetsAt": "2026-04-16T18:00:00Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expectedReset = try #require(formatter.date(from: "2026-04-16T18:00:00Z")) + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == expectedReset) + } + + @Test + func `parses live root level rolling and weekly quotas`() throws { + let json = """ + { + "subscription": { + "limit": 750, + "requests": 0, + "renewsAt": "2026-04-17T08:35:49.493Z" + }, + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.05884722222223, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 750, + "max": 750, + "limited": false + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expectedPrimaryReset = try #require(formatter.date(from: "2026-04-17T03:44:11Z")) + let expectedSecondaryReset = try #require(formatter.date(from: "2026-04-17T05:19:30Z")) + let expectedTertiaryReset = try #require(fractionalFormatter.date(from: "2026-04-17T04:30:01.494Z")) + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetsAt == expectedPrimaryReset) + #expect(usage.primary?.resetDescription == nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 1.9411527777777715) < 0.001) + #expect(usage.secondary?.resetsAt == expectedSecondaryReset) + #expect(usage.secondary?.resetDescription == nil) + #expect(usage.tertiary?.usedPercent == 0.8) + #expect(usage.tertiary?.resetsAt == expectedTertiaryReset) + #expect(usage.providerCost?.limit == 36) + #expect(abs((usage.providerCost?.used ?? 0) - 0.7) < 0.0001) + #expect(usage.providerCost?.nextRegenAmount == 0.72) + } + + @Test + func `preserves slot identity when rolling lane is missing`() throws { + let json = """ + { + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.0, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 2.0) < 0.001) + #expect(usage.tertiary?.usedPercent == 0.8) + #expect(usage.providerCost?.limit == 36) + } } From f67bfdaee823592d2e1025640c7d52480f0bb76a Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 17 Apr 2026 14:21:19 -0400 Subject: [PATCH 0204/1239] Show Synthetic rolling regen detail --- Sources/CodexBar/MenuCardView.swift | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index e63301d466..3672e7dc05 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1085,6 +1085,18 @@ extension UsageMenuCardView.Model { } } } + if input.provider == .synthetic, + let regen = Self.syntheticRollingRegenDetail( + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + primaryResetText = regen.resetText + primaryDetailLeft = regen.pace.leftLabel + primaryDetailRight = regen.pace.rightLabel + primaryPacePercent = regen.pace.pacePercent + primaryPaceOnTop = regen.pace.paceOnTop + } return Metric( id: "primary", title: input.metadata.sessionLabel, @@ -1356,6 +1368,35 @@ extension UsageMenuCardView.Model { return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) } + private static func syntheticRollingRegenDetail( + window: RateWindow, + now: Date, + showUsed: Bool) -> (resetText: String, pace: PaceDetail)? + { + guard let resetsAt = window.resetsAt else { return nil } + + let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) + let resetText = "Regenerates \(countdown)" + + let nextRegenPercent = 5.0 + let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent) + let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining + let suffix = showUsed ? "used after next regen" : "after next regen" + let left = String(format: "%.0f%% %@", afterNextRegen, suffix) + + let missingPercent = max(0, window.usedPercent) + let ticksToFull = missingPercent / nextRegenPercent + let right = if ticksToFull <= 0.1 { + "Near full" + } else if ticksToFull < 1.5 { + "Full in ~1 regen" + } else { + String(format: "Full in ~%.0f regens", ceil(ticksToFull)) + } + + return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) + } + private static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, From 45f040fb590260cbbc65eb1d90d8feac31c5f73c Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 17 Apr 2026 21:51:20 -0400 Subject: [PATCH 0205/1239] Parse Synthetic tickPercent for rolling regen detail Drop the hardcoded 5% rolling-tick assumption: thread the API's tickPercent through SyntheticQuotaEntry into RateWindow.nextRegenPercent and require it in syntheticRollingRegenDetail. Also flatten the string window-suffix matcher into a single longest-first list so future units can't shadow each other. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/CodexBar/MenuCardView.swift | 6 +- .../Synthetic/SyntheticUsageStats.swift | 53 ++++++--- Sources/CodexBarCore/UsageFetcher.swift | 11 +- .../SyntheticMenuCardTests.swift | 107 ++++++++++++++++++ .../SyntheticProviderTests.swift | 50 ++++++++ 5 files changed, 207 insertions(+), 20 deletions(-) create mode 100644 Tests/CodexBarTests/SyntheticMenuCardTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3672e7dc05..6356c51a6c 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1373,12 +1373,14 @@ extension UsageMenuCardView.Model { now: Date, showUsed: Bool) -> (resetText: String, pace: PaceDetail)? { - guard let resetsAt = window.resetsAt else { return nil } + guard let resetsAt = window.resetsAt, + let nextRegenPercent = window.nextRegenPercent, + nextRegenPercent > 0 + else { return nil } let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) let resetText = "Regenerates \(countdown)" - let nextRegenPercent = 5.0 let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent) let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining let suffix = showUsed ? "used after next regen" : "after next regen" diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index ea444f5b9c..f3e2edff80 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -9,6 +9,7 @@ public struct SyntheticQuotaEntry: Sendable { public let windowMinutes: Int? public let resetsAt: Date? public let resetDescription: String? + public let nextRegenPercent: Double? public let cost: ProviderCostSnapshot? public init( @@ -17,6 +18,7 @@ public struct SyntheticQuotaEntry: Sendable { windowMinutes: Int?, resetsAt: Date?, resetDescription: String?, + nextRegenPercent: Double? = nil, cost: ProviderCostSnapshot? = nil) { self.label = label @@ -24,6 +26,7 @@ public struct SyntheticQuotaEntry: Sendable { self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.nextRegenPercent = nextRegenPercent self.cost = cost } } @@ -82,7 +85,8 @@ extension SyntheticUsageSnapshot { usedPercent: quota.usedPercent, windowMinutes: quota.windowMinutes, resetsAt: quota.resetsAt, - resetDescription: quota.resetDescription) + resetDescription: quota.resetDescription, + nextRegenPercent: quota.nextRegenPercent) } } @@ -279,6 +283,8 @@ enum SyntheticUsageParser { let resetDescription = resetsAt == nil ? self.windowDescription(minutes: windowMinutes) : nil let cost = self.providerCost(from: payload, usedPercent: clamped, resetsAt: resetsAt) + let nextRegenPercent = self.normalizedPercent( + self.firstDouble(in: payload, keys: Self.tickPercentKeys)) return SyntheticQuotaEntry( label: label, @@ -286,6 +292,7 @@ enum SyntheticUsageParser { windowMinutes: windowMinutes, resetsAt: resetsAt, resetDescription: resetDescription, + nextRegenPercent: nextRegenPercent, cost: cost) } @@ -312,7 +319,7 @@ enum SyntheticUsageParser { return Int((seconds / 60).rounded()) } if let text = self.firstString(in: payload, keys: windowStringKeys) { - return self.windowMinutes(from: text) + return self.windowMinutes(fromText: text) } return nil } @@ -353,31 +360,36 @@ enum SyntheticUsageParser { } } - private static func windowMinutes(from text: String) -> Int? { + /// Parses durations like `"5hr"`, `"30min"`, `"2 days"`. Suffixes are sorted longest-first so + /// multi-letter units always win over their single-letter aliases — no ordering surprises if a + /// future unit shares a trailing letter with another. + static func windowMinutes(fromText text: String) -> Int? { let normalized = text .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .replacingOccurrences(of: " ", with: "") guard !normalized.isEmpty else { return nil } - let unitMappings: [(suffixes: [String], multiplier: Double)] = [ - (["minutes", "minute", "mins", "min", "m"], 1), - (["hours", "hour", "hrs", "hr", "h"], 60), - (["days", "day", "d"], 24 * 60), - ] - - for mapping in unitMappings { - for suffix in mapping.suffixes { - guard normalized.hasSuffix(suffix) else { continue } - let valueText = String(normalized.dropLast(suffix.count)) - guard let value = Double(valueText), value > 0 else { return nil } - return Int((value * mapping.multiplier).rounded()) - } + for (suffix, multiplier) in Self.windowSuffixMultipliers { + guard normalized.hasSuffix(suffix) else { continue } + let valueText = String(normalized.dropLast(suffix.count)) + guard let value = Double(valueText), value > 0 else { return nil } + return Int((value * multiplier).rounded()) } - return nil } + private static let windowSuffixMultipliers: [(suffix: String, multiplier: Double)] = { + let raw: [(String, Double)] = [ + ("minutes", 1), ("minute", 1), ("mins", 1), ("min", 1), ("m", 1), + ("hours", 60), ("hour", 60), ("hrs", 60), ("hr", 60), ("h", 60), + ("days", 24 * 60), ("day", 24 * 60), ("d", 24 * 60), + ] + return raw + .sorted { $0.0.count > $1.0.count } + .map { (suffix: $0.0, multiplier: $0.1) } + }() + private static func windowDescription(minutes: Int?) -> String? { guard let minutes, minutes > 0 else { return nil } let dayMinutes = 24 * 60 @@ -639,6 +651,13 @@ enum SyntheticUsageParser { "next_regen_credits", ] + private static let tickPercentKeys = [ + "tickPercent", + "tick_percent", + "nextTickPercent", + "next_tick_percent", + ] + private static let costLimitKeys = [ "maxCredits", "max_credits", diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 90360ca602..24d413717c 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -6,12 +6,21 @@ public struct RateWindow: Codable, Equatable, Sendable { public let resetsAt: Date? /// Optional textual reset description (used by Claude CLI UI scrape). public let resetDescription: String? + /// Optional percent restored on the next regeneration tick for providers with rolling recovery. + public let nextRegenPercent: Double? - public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?) { + public init( + usedPercent: Double, + windowMinutes: Int?, + resetsAt: Date?, + resetDescription: String?, + nextRegenPercent: Double? = nil) + { self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.nextRegenPercent = nextRegenPercent } public var remainingPercent: Double { diff --git a/Tests/CodexBarTests/SyntheticMenuCardTests.swift b/Tests/CodexBarTests/SyntheticMenuCardTests.swift new file mode 100644 index 0000000000..df7d00951a --- /dev/null +++ b/Tests/CodexBarTests/SyntheticMenuCardTests.swift @@ -0,0 +1,107 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct SyntheticMenuCardTests { + private static func makeModel( + primary: RateWindow?, + secondary: RateWindow? = nil, + providerCost: ProviderCostSnapshot? = nil, + now: Date) throws -> UsageMenuCardView.Model + { + let identity = ProviderIdentitySnapshot( + providerID: .synthetic, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: providerCost, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.synthetic]) + return UsageMenuCardView.Model.make(.init( + provider: .synthetic, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + @Test + func `rolling regen text uses parsed tickPercent not hardcoded fallback`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let primary = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil, + nextRegenPercent: 2) + let model = try Self.makeModel(primary: primary, now: now) + let metric = try #require(model.metrics.first) + // 50% used / 2% per tick = 25 ticks to full. + #expect(metric.detailRightText == "Full in ~25 regens") + #expect(metric.detailLeftText == "52% after next regen") + } + + @Test + func `rolling regen omits Synthetic-specific text when tickPercent is missing`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let primary = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil, + nextRegenPercent: nil) + let model = try Self.makeModel(primary: primary, now: now) + let metric = try #require(model.metrics.first) + // Without nextRegenPercent we no longer assert a regen-specific label; + // the renderer must not fabricate ticks-to-full from a guessed rate. + #expect(metric.detailRightText?.contains("regen") != true) + } + + @Test + func `weekly regen text near full reports both labels consistently`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let secondary = RateWindow( + usedPercent: 1, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let cost = ProviderCostSnapshot( + used: 0.36, + limit: 36, + currencyCode: "USD", + period: "Weekly", + resetsAt: now.addingTimeInterval(3600), + nextRegenAmount: 0.72, + updatedAt: now) + let model = try Self.makeModel( + primary: nil, + secondary: secondary, + providerCost: cost, + now: now) + let weekly = try #require(model.metrics.first(where: { $0.id == "secondary" })) + // used=$0.36 / nextRegen=$0.72 = 0.5 ticks → between 0.1 and 1.5 → "Full in ~1 regen". + #expect(weekly.detailRightText == "Full in ~1 regen") + // remaining 99% + 2% next regen caps at 100% → "100% after next regen". + #expect(weekly.detailLeftText == "100% after next regen") + } +} diff --git a/Tests/CodexBarTests/SyntheticProviderTests.swift b/Tests/CodexBarTests/SyntheticProviderTests.swift index 08a0ad4b4f..abe9765b05 100644 --- a/Tests/CodexBarTests/SyntheticProviderTests.swift +++ b/Tests/CodexBarTests/SyntheticProviderTests.swift @@ -145,6 +145,56 @@ struct SyntheticUsageSnapshotTests { #expect(usage.providerCost?.nextRegenAmount == 0.72) } + @Test + func `parses rolling lane tickPercent into primary nextRegenPercent`() throws { + let json = """ + { + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 750, + "max": 750, + "limited": false + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.nextRegenPercent == 5.0) + } + + @Test + func `omits nextRegenPercent when rolling lane lacks tickPercent`() throws { + let json = """ + { + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "remaining": 750, + "max": 750 + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.nextRegenPercent == nil) + } + + @Test + func `parses time string suffixes covering minutes hours and days`() { + #expect(SyntheticUsageParser.windowMinutes(fromText: "5min") == 5) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5m") == 5) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5hr") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5h") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5hours") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "2days") == 2880) + #expect(SyntheticUsageParser.windowMinutes(fromText: "2d") == 2880) + #expect(SyntheticUsageParser.windowMinutes(fromText: "1 hour") == 60) + #expect(SyntheticUsageParser.windowMinutes(fromText: "junk") == nil) + #expect(SyntheticUsageParser.windowMinutes(fromText: "") == nil) + } + @Test func `preserves slot identity when rolling lane is missing`() throws { let json = """ From 5a1c06ec9d24fe8618960e301a56b815d52101a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:46:27 +0100 Subject: [PATCH 0206/1239] docs: credit Synthetic quota response fix (#732) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98007322cb..595e876c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.22 — Unreleased ### Providers & Usage +- Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish! - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! From 59d22aba846e85014e460ba7b0a14625b04699a8 Mon Sep 17 00:00:00 2001 From: icey-zhang Date: Thu, 16 Apr 2026 11:14:57 +0800 Subject: [PATCH 0207/1239] Fix Antigravity probe failures caused by URLSession delegate signature mismatch and port/token handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URLSessionDelegate TLS challenge methods used `@Sendable` completionHandler but Swift 6.2 SDK requires `@MainActor @Sendable`, causing the delegate to never be called. This made all HTTPS connections to Antigravity's self-signed localhost server fail silently, falling back to the HTTP extension port which uses a different CSRF token and doesn't serve the quota API — resulting in misleading "session expired" (403) or "HTTP 404" errors. Fixes: - Switch delegate methods to async variants to match the current SDK protocol signature - Extract `--extension_server_csrf_token` from process args and use it for HTTP extension port fallback - Rank port probe results (success > httpError > unreachable) so a 403 on the extension port doesn't shadow a working API port Co-Authored-By: Claude Opus 4.6 --- .../Antigravity/AntigravityStatusProbe.swift | 53 ++++++++++++++++--- .../AntigravityStatusProbeTests.swift | 30 +++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 5b34859721..22f2600a0b 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -665,9 +665,41 @@ public struct AntigravityStatusProbe: Sendable { let ok = await testConnectivity(endpoint, timeout) if ok { return endpoint } } + if let fallback = fallbackProbeEndpoint(candidateEndpoints) { + self.log.debug("Port probe fell back to best-effort endpoint", metadata: [ + "source": fallback.source.rawValue, + "scheme": fallback.scheme, + "port": "\(fallback.port)", + ]) + return fallback + } throw AntigravityStatusProbeError.portDetectionFailed("no working API port found") } + static func fallbackProbePort(ports: [Int], extensionPort: Int?) -> Int? { + if let nonExtension = ports.first(where: { $0 != extensionPort }) { + return nonExtension + } + if let extensionPort { + return extensionPort + } + return ports.first + } + + static func isReachableProbeError(_ error: Error) -> Bool { + guard case let AntigravityStatusProbeError.apiError(message) = error else { return false } + return message.hasPrefix("HTTP ") + } + + private static func fallbackProbeEndpoint( + _ endpoints: [AntigravityConnectionEndpoint]) -> AntigravityConnectionEndpoint? + { + if let languageServerEndpoint = endpoints.first(where: { $0.source == .languageServer }) { + return languageServerEndpoint + } + return endpoints.first + } + private static func testEndpointConnectivity( _ endpoint: AntigravityConnectionEndpoint, timeout: TimeInterval) async -> Bool @@ -680,6 +712,15 @@ public struct AntigravityStatusProbe: Sendable { context: RequestContext(endpoints: [endpoint], timeout: timeout)) return true } catch { + if self.isReachableProbeError(error) { + self.log.debug("Port probe received HTTP response; treating endpoint as reachable", metadata: [ + "source": endpoint.source.rawValue, + "scheme": endpoint.scheme, + "port": "\(endpoint.port)", + "error": error.localizedDescription, + ]) + return true + } self.log.debug("Port probe failed", metadata: [ "source": endpoint.source.rawValue, "scheme": endpoint.scheme, @@ -896,11 +937,9 @@ private final class LocalhostSessionDelegate: NSObject { extension LocalhostSessionDelegate: URLSessionDelegate { func urlSession( _ session: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let result = self.challengeResult(challenge) - completionHandler(result.disposition, result.credential) + self.challengeResult(challenge) } } @@ -908,11 +947,9 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { func urlSession( _ session: URLSession, task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let result = self.challengeResult(challenge) - completionHandler(result.disposition, result.credential) + self.challengeResult(challenge) } } diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index f1d0e75b5c..5e91fc46e0 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -745,4 +745,34 @@ struct AntigravityStatusProbeTests { #expect(usage.accountEmail(for: .antigravity) == "test@example.com") #expect(usage.loginMethod(for: .antigravity) == "Pro") } + + @Test + func `http probe errors still count as reachable`() { + #expect( + AntigravityStatusProbe.isReachableProbeError( + AntigravityStatusProbeError.apiError("HTTP 403: Forbidden"))) + #expect( + AntigravityStatusProbe.isReachableProbeError( + AntigravityStatusProbeError.apiError("HTTP 404: Not Found"))) + #expect( + !AntigravityStatusProbe.isReachableProbeError( + AntigravityStatusProbeError.apiError("Invalid response"))) + #expect(!AntigravityStatusProbe.isReachableProbeError(AntigravityStatusProbeError.notRunning)) + } + + @Test + func `fallback probe port prefers non extension candidate`() { + #expect( + AntigravityStatusProbe.fallbackProbePort( + ports: [51170, 61775], + extensionPort: 61775) == 51170) + #expect( + AntigravityStatusProbe.fallbackProbePort( + ports: [61775], + extensionPort: 61775) == 61775) + #expect( + AntigravityStatusProbe.fallbackProbePort( + ports: [51170, 61775], + extensionPort: nil) == 51170) + } } From 2be8ae213134f1710eac76a224f0ca68d46d09d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:54:26 +0100 Subject: [PATCH 0208/1239] docs: credit Antigravity probe fix (#727) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 595e876c6a..8161d73bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Providers & Usage - Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish! +- Antigravity: restore localhost probing with async TLS challenge handling, extension-token fallback, and best-effort port selection (#727). Thanks @icey-zhang! - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! From 1f3d5bd7ad3325a54282a610c8eaeb8923df5874 Mon Sep 17 00:00:00 2001 From: Chad Neal Date: Thu, 19 Mar 2026 23:29:20 -0600 Subject: [PATCH 0209/1239] Fix preferences sidebar clipping and window sizing on macOS Tahoe The Providers tab sidebar was clipping provider names, icons, and reorder handles on the left edge due to .listStyle(.sidebar) imposing internal leading insets that push content beyond the 240px frame on macOS Tahoe. - Replace List with ScrollView+VStack for full control over sidebar layout - Add direct NSWindow.setFrame resizing since SwiftUI's .windowResizability(.contentSize) doesn't propagate frame changes - Add PreferencesTab.title and CaseIterable to eliminate hardcoded tab title strings in the window finder - Use system selectedContentBackgroundColor for sidebar selection highlight - Add ensure_swift_version() to compile_and_run.sh for Xcode toolchain fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- Scripts/compile_and_run.sh | 22 ++++++++++ .../PreferencesProviderSidebarView.swift | 43 +++++++++++-------- Sources/CodexBar/PreferencesView.swift | 31 ++++++++++++- 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index 865f2a7973..909b41a663 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -29,6 +29,27 @@ delete_keychain_service_items() { done } +# Ensure Swift >= 5.5 (required for --arch flag in swift build) +ensure_swift_version() { + local swift_ver + swift_ver=$(swift --version 2>&1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1) + local major minor + major=$(echo "$swift_ver" | cut -d. -f1) + minor=$(echo "$swift_ver" | cut -d. -f2) + if [[ "${major:-0}" -ge 6 ]] || { [[ "${major:-0}" -eq 5 ]] && [[ "${minor:-0}" -ge 5 ]]; }; then + return 0 + fi + # Try Xcode toolchain + local xcrun_swift + xcrun_swift=$(xcrun --find swift 2>/dev/null || true) + if [[ -n "$xcrun_swift" && -x "$xcrun_swift" ]]; then + log "WARN: PATH swift is v${swift_ver}; switching to Xcode toolchain at $(dirname "$xcrun_swift")" + export PATH="$(dirname "$xcrun_swift"):$PATH" + return 0 + fi + fail "Swift >= 5.5 required (found ${swift_ver:-none}). Install Xcode or update swiftly." +} + has_signing_identity() { local identity="${1:-}" if [[ -z "${identity}" ]]; then @@ -173,6 +194,7 @@ for arg in "$@"; do esac done +ensure_swift_version resolve_signing_mode if [[ "${CLEAR_ADHOC_KEYCHAIN}" == "1" && "${SIGNING_MODE}" != "adhoc" ]]; then fail "--clear-adhoc-keychain is only supported when using adhoc signing." diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index 32a1b5b471..4e49359218 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -13,26 +13,33 @@ struct ProviderSidebarListView: View { @State private var draggingProvider: UsageProvider? var body: some View { - List(selection: self.$selection) { - ForEach(self.providers, id: \.self) { provider in - ProviderSidebarRowView( - provider: provider, - store: self.store, - isEnabled: self.isEnabled(provider), - subtitle: self.subtitle(provider), - draggingProvider: self.$draggingProvider) - .tag(provider) - .onDrop( - of: [UTType.plainText], - delegate: ProviderSidebarDropDelegate( - item: provider, - providers: self.providers, - dragging: self.$draggingProvider, - moveProviders: self.moveProviders)) + ScrollView { + VStack(spacing: 0) { + ForEach(self.providers, id: \.self) { provider in + ProviderSidebarRowView( + provider: provider, + store: self.store, + isEnabled: self.isEnabled(provider), + subtitle: self.subtitle(provider), + draggingProvider: self.$draggingProvider) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(self.selection == provider ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, 4)) + .contentShape(Rectangle()) + .onTapGesture { self.selection = provider } + .onDrop( + of: [UTType.plainText], + delegate: ProviderSidebarDropDelegate( + item: provider, + providers: self.providers, + dragging: self.$draggingProvider, + moveProviders: self.moveProviders)) + } } + .padding(.vertical, 4) } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) .background( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) .fill(Color(nsColor: .controlBackgroundColor).opacity(0.8))) diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 14a5416957..408a83d6d8 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -1,7 +1,7 @@ import AppKit import SwiftUI -enum PreferencesTab: String, Hashable { +enum PreferencesTab: String, CaseIterable, Hashable { case general case providers case display @@ -13,6 +13,17 @@ enum PreferencesTab: String, Hashable { static let providersWidth: CGFloat = 720 static let windowHeight: CGFloat = 580 + var title: String { + switch self { + case .general: "General" + case .providers: "Providers" + case .display: "Display" + case .advanced: "Advanced" + case .about: "About" + case .debug: "Debug" + } + } + var preferredWidth: CGFloat { self == .providers ? PreferencesTab.providersWidth : PreferencesTab.defaultWidth } @@ -110,6 +121,24 @@ struct PreferencesView: View { } else { change() } + Self.resizeSettingsWindow(width: tab.preferredWidth, height: tab.preferredHeight, animate: animate) + } + + private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window" + private static let knownTabTitles = Set(PreferencesTab.allCases.map(\.title)) + + private static func resizeSettingsWindow(width: CGFloat, height: CGFloat, animate: Bool) { + guard let window = NSApp.windows.first(where: { + $0.identifier?.rawValue == settingsWindowIdentifier + || knownTabTitles.contains($0.title) + }) else { return } + let toolbarHeight = window.frame.height - window.contentLayoutRect.height + guard toolbarHeight > 0 else { return } + let newSize = NSSize(width: width, height: height + toolbarHeight) + var frame = window.frame + frame.origin.y += frame.size.height - newSize.height + frame.size = newSize + window.setFrame(frame, display: true, animate: animate) } private func ensureValidTabSelection() { From ffe7ed03425cf8cc4cdcda0481b3bdc8b9101b28 Mon Sep 17 00:00:00 2001 From: Chad Neal Date: Fri, 20 Mar 2026 05:18:58 -0600 Subject: [PATCH 0210/1239] Fix lint: wrap long line to stay within 120-char max width Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/CodexBar/PreferencesProviderSidebarView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index 4e49359218..559c5329e3 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -25,7 +25,10 @@ struct ProviderSidebarListView: View { .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(self.selection == provider ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) + .fill( + self.selection == provider + ? Color(nsColor: .selectedContentBackgroundColor) + : Color.clear) .padding(.horizontal, 4)) .contentShape(Rectangle()) .onTapGesture { self.selection = provider } From f10ab99f60d04b09cb3399ead2de794fabb113e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 22:01:16 +0100 Subject: [PATCH 0211/1239] fix: polish Tahoe settings landing (#580) --- CHANGELOG.md | 3 +++ Scripts/compile_and_run.sh | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8161d73bf7..0fbb778a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! +### Menu & Settings +- Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal! + ### Fixes - Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98! diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index 909b41a663..12a39996b8 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -31,8 +31,14 @@ delete_keychain_service_items() { # Ensure Swift >= 5.5 (required for --arch flag in swift build) ensure_swift_version() { + local swift_output local swift_ver - swift_ver=$(swift --version 2>&1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1) + swift_output=$(swift --version 2>&1 || true) + if [[ "$swift_output" =~ (Apple[[:space:]]+)?Swift[[:space:]]+version[[:space:]]+([0-9]+)\.([0-9]+)(\.[0-9]+)? ]]; then + swift_ver="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}${BASH_REMATCH[4]}" + else + fail "Swift >= 5.5 required (found ${swift_output:-none}). Install Xcode or update swiftly." + fi local major minor major=$(echo "$swift_ver" | cut -d. -f1) minor=$(echo "$swift_ver" | cut -d. -f2) From 2f7a6752e3097679d5236d032b8daef9c1717313 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 12 Apr 2026 16:09:52 +0300 Subject: [PATCH 0212/1239] fix(app-group): migrate to team-prefixed shared container --- Scripts/package_app.sh | 7 +- Sources/CodexBar/SettingsStore.swift | 16 +- Sources/CodexBar/StatusItemController.swift | 2 +- Sources/CodexBarCore/AppGroupSupport.swift | 210 ++++++++++++++++++ Sources/CodexBarCore/KeychainAccessGate.swift | 5 +- Sources/CodexBarCore/WidgetSnapshot.swift | 42 +--- .../CodexBarWidgetProvider.swift | 10 +- .../CodexBarTests/AppGroupSupportTests.swift | 88 ++++++++ .../CodexBarWidgetProviderTests.swift | 7 + 9 files changed, 344 insertions(+), 43 deletions(-) create mode 100644 Sources/CodexBarCore/AppGroupSupport.swift create mode 100644 Tests/CodexBarTests/AppGroupSupportTests.swift diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 4a8a6de02d..9e62c83dc9 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -134,9 +134,10 @@ if [[ "$SIGNING_MODE" == "adhoc" ]]; then AUTO_CHECKS=false fi WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget" -APP_GROUP_ID="group.com.steipete.codexbar" +APP_TEAM_ID="${APP_TEAM_ID:-Y5PE65HELJ}" +APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar" if [[ "$BUNDLE_ID" == *".debug"* ]]; then - APP_GROUP_ID="group.com.steipete.codexbar.debug" + APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar.debug" fi ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" @@ -197,6 +198,7 @@ cat > "$APP/Contents/Info.plist" <SUEnableAutomaticChecks<${AUTO_CHECKS}/> CodexBuildTimestamp${BUILD_TIMESTAMP} CodexGitCommit${GIT_COMMIT} + CodexBarTeamID${APP_TEAM_ID} PLIST @@ -292,6 +294,7 @@ if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then CFBundleShortVersionString${MARKETING_VERSION} CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 + CodexBarTeamID${APP_TEAM_ID} NSExtension NSExtensionPointIdentifiercom.apple.widgetkit-extension diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 68ba707aa8..d164393177 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -63,7 +63,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { @MainActor @Observable final class SettingsStore { - static let sharedDefaults = UserDefaults(suiteName: "group.com.steipete.codexbar") + static let sharedDefaults = AppGroupSupport.sharedDefaults() static let mergedOverviewProviderLimit = 3 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment @@ -124,6 +124,20 @@ final class SettingsStore { copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(), tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { + let appGroupID = AppGroupSupport.currentGroupID() + let appGroupMigration = AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults) + let sharedDefaultsAvailable = Self.sharedDefaults != nil + if !Self.isRunningTests { + CodexBarLog.logger(LogCategories.settings).info( + "App group resolved", + metadata: [ + "groupID": appGroupID, + "sharedDefaultsAvailable": sharedDefaultsAvailable ? "1" : "0", + "migrationStatus": appGroupMigration.status.rawValue, + "migratedSnapshot": appGroupMigration.copiedSnapshot ? "1" : "0", + ]) + } + let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil let hadExistingConfig = (try? configStore.load()) != nil let legacyStores = CodexBarConfigMigrator.LegacyStores( diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index b77865f96f..2f13726bd4 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -238,8 +238,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin // Status items for individual providers are now created lazily in updateVisibility() super.init() self.wireBindings() - self.updateIcons() self.updateVisibility() + self.updateIcons() NotificationCenter.default.addObserver( self, selector: #selector(self.handleDebugReplayNotification(_:)), diff --git a/Sources/CodexBarCore/AppGroupSupport.swift b/Sources/CodexBarCore/AppGroupSupport.swift new file mode 100644 index 0000000000..fa2237668a --- /dev/null +++ b/Sources/CodexBarCore/AppGroupSupport.swift @@ -0,0 +1,210 @@ +import Foundation +#if os(macOS) +import Security +#endif + +public enum AppGroupSupport { + public static let defaultTeamID = "Y5PE65HELJ" + public static let teamIDInfoKey = "CodexBarTeamID" + public static let legacyReleaseGroupID = "group.com.steipete.codexbar" + public static let legacyDebugGroupID = "group.com.steipete.codexbar.debug" + public static let widgetSnapshotFilename = "widget-snapshot.json" + public static let migrationVersion = 1 + public static let migrationVersionKey = "appGroupMigrationVersion" + + public struct MigrationResult: Sendable { + public enum Status: String, Sendable { + case alreadyCompleted + case targetUnavailable + case noChangesNeeded + case migrated + } + + public let status: Status + public let copiedSnapshot: Bool + + public init(status: Status, copiedSnapshot: Bool = false) { + self.status = status + self.copiedSnapshot = copiedSnapshot + } + } + + public static func currentGroupID(for bundleID: String? = Bundle.main.bundleIdentifier) -> String { + self.currentGroupID(teamID: self.resolvedTeamID(), bundleID: bundleID) + } + + static func currentGroupID(teamID: String, bundleID: String?) -> String { + let base = "\(teamID).com.steipete.codexbar" + return self.isDebugBundleID(bundleID) ? "\(base).debug" : base + } + + public static func resolvedTeamID(bundle: Bundle = .main) -> String { + self.resolvedTeamID( + infoDictionaryOverride: bundle.infoDictionary, + bundleURLOverride: bundle.bundleURL) + } + + static func resolvedTeamID( + infoDictionaryOverride: [String: Any]?, + bundleURLOverride: URL?) -> String + { + if let teamID = self.codeSignatureTeamID(bundleURL: bundleURLOverride) { + return teamID + } + if let teamID = infoDictionaryOverride?[self.teamIDInfoKey] as? String, + !teamID.isEmpty + { + return teamID + } + return self.defaultTeamID + } + + public static func legacyGroupID(for bundleID: String? = Bundle.main.bundleIdentifier) -> String { + self.isDebugBundleID(bundleID) ? self.legacyDebugGroupID : self.legacyReleaseGroupID + } + + public static func sharedDefaults( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default) + -> UserDefaults? + { + guard self.currentContainerURL(bundleID: bundleID, fileManager: fileManager) != nil else { return nil } + return UserDefaults(suiteName: self.currentGroupID(for: bundleID)) + } + + public static func currentContainerURL( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default) + -> URL? + { + #if os(macOS) + fileManager.containerURL(forSecurityApplicationGroupIdentifier: self.currentGroupID(for: bundleID)) + #else + nil + #endif + } + + public static func snapshotURL( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + if let container = self.currentContainerURL(bundleID: bundleID, fileManager: fileManager) { + return container.appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + } + + let directory = self.localFallbackDirectory(fileManager: fileManager, homeDirectory: homeDirectory) + return directory.appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + } + + public static func localFallbackDirectory( + fileManager: FileManager = .default, + homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let directory = base.appendingPathComponent("CodexBar", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + public static func legacyContainerCandidateURL( + bundleID: String? = Bundle.main.bundleIdentifier, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + homeDirectory + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Group Containers", isDirectory: true) + .appendingPathComponent(self.legacyGroupID(for: bundleID), isDirectory: true) + } + + public static func migrateLegacyDataIfNeeded( + bundleID: String? = Bundle.main.bundleIdentifier, + standardDefaults: UserDefaults = .standard, + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + currentDefaultsOverride: UserDefaults? = nil, + currentSnapshotURLOverride: URL? = nil, + legacySnapshotURLOverride: URL? = nil) + -> MigrationResult + { + if standardDefaults.integer(forKey: self.migrationVersionKey) >= self.migrationVersion { + return MigrationResult(status: .alreadyCompleted) + } + + guard currentDefaultsOverride ?? self.sharedDefaults(bundleID: bundleID, fileManager: fileManager) != nil else { + return MigrationResult(status: .targetUnavailable) + } + + let currentSnapshotURL = currentSnapshotURLOverride + ?? self.currentContainerURL(bundleID: bundleID, fileManager: fileManager)? + .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + let legacySnapshotURL = legacySnapshotURLOverride + ?? self.legacyContainerCandidateURL(bundleID: bundleID, homeDirectory: homeDirectory) + .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + + let copiedSnapshot = { + guard let currentSnapshotURL else { return false } + guard !fileManager.fileExists(atPath: currentSnapshotURL.path), + fileManager.fileExists(atPath: legacySnapshotURL.path) + else { + return false + } + do { + try fileManager.createDirectory( + at: currentSnapshotURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try fileManager.copyItem(at: legacySnapshotURL, to: currentSnapshotURL) + return true + } catch { + return false + } + }() + + let result = if copiedSnapshot { + MigrationResult(status: .migrated, copiedSnapshot: true) + } else { + MigrationResult(status: .noChangesNeeded) + } + + standardDefaults.set(self.migrationVersion, forKey: self.migrationVersionKey) + return result + } + + private static func isDebugBundleID(_ bundleID: String?) -> Bool { + guard let bundleID, !bundleID.isEmpty else { return false } + return bundleID.contains(".debug") + } + + private static func codeSignatureTeamID(bundleURL: URL?) -> String? { + #if os(macOS) + guard let bundleURL else { return nil } + + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let code = staticCode + else { + return nil + } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation( + code, + SecCSFlags(rawValue: kSecCSSigningInformation), + &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any], + let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String, + !teamID.isEmpty + else { + return nil + } + return teamID + #else + _ = bundleURL + return nil + #endif + } +} diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 548a4add4f..86451a75da 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -5,7 +5,6 @@ import SweetCookieKit public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" - private static let appGroupID = "group.com.steipete.codexbar" @TaskLocal private static var taskOverrideValue: Bool? private nonisolated(unsafe) static var overrideValue: Bool? @@ -19,9 +18,7 @@ public enum KeychainAccessGate { #endif if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } - if let shared = UserDefaults(suiteName: Self.appGroupID), - shared.bool(forKey: Self.flagKey) - { + if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true } return false diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 0dc371f027..d87c4dccad 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -114,17 +114,16 @@ public struct WidgetSnapshot: Codable, Sendable { } public enum WidgetSnapshotStore { - public static let appGroupID = "group.com.steipete.codexbar" - private static let filename = "widget-snapshot.json" + private static let filename = AppGroupSupport.widgetSnapshotFilename public static func load(bundleID: String? = Bundle.main.bundleIdentifier) -> WidgetSnapshot? { - guard let url = self.snapshotURL(bundleID: bundleID) else { return nil } + let url = self.snapshotURL(bundleID: bundleID) guard let data = try? Data(contentsOf: url) else { return nil } return try? self.decoder.decode(WidgetSnapshot.self, from: data) } public static func save(_ snapshot: WidgetSnapshot, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let url = self.snapshotURL(bundleID: bundleID) else { return } + let url = self.snapshotURL(bundleID: bundleID) do { let data = try self.encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) @@ -133,32 +132,12 @@ public enum WidgetSnapshotStore { } } - private static func snapshotURL(bundleID: String?) -> URL? { - let fm = FileManager.default - let groupID = self.groupID(for: bundleID) - #if os(macOS) - if let groupID, let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupID) { - return container.appendingPathComponent(self.filename, isDirectory: false) - } - #endif - - let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? fm.temporaryDirectory - let dir = base.appendingPathComponent("CodexBar", isDirectory: true) - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent(self.filename, isDirectory: false) + private static func snapshotURL(bundleID: String?) -> URL { + AppGroupSupport.snapshotURL(bundleID: bundleID) } public static func appGroupID(for bundleID: String?) -> String? { - self.groupID(for: bundleID) - } - - private static func groupID(for bundleID: String?) -> String? { - guard let bundleID, !bundleID.isEmpty else { return self.appGroupID } - if bundleID.contains(".debug") { - return "group.com.steipete.codexbar.debug" - } - return self.appGroupID + AppGroupSupport.currentGroupID(for: bundleID) } private static var encoder: JSONEncoder { @@ -178,7 +157,7 @@ public enum WidgetSelectionStore { private static let selectedProviderKey = "widgetSelectedProvider" public static func loadSelectedProvider(bundleID: String? = Bundle.main.bundleIdentifier) -> UsageProvider? { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return nil } + let defaults = self.sharedDefaults(bundleID: bundleID) guard let raw = defaults.string(forKey: self.selectedProviderKey) else { return nil } return UsageProvider(rawValue: raw) } @@ -187,12 +166,11 @@ public enum WidgetSelectionStore { _ provider: UsageProvider, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return } + let defaults = self.sharedDefaults(bundleID: bundleID) defaults.set(provider.rawValue, forKey: self.selectedProviderKey) } - private static func sharedDefaults(bundleID: String?) -> UserDefaults? { - guard let groupID = WidgetSnapshotStore.appGroupID(for: bundleID) else { return nil } - return UserDefaults(suiteName: groupID) + private static func sharedDefaults(bundleID: String?) -> UserDefaults { + AppGroupSupport.sharedDefaults(bundleID: bundleID) ?? .standard } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 73055305f4..f5f5187e3f 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -184,7 +184,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) @@ -213,7 +213,7 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { } private func makeEntry() -> CodexBarSwitcherEntry { - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let providers = self.availableProviders(from: snapshot) let stored = WidgetSelectionStore.loadSelectedProvider() let selected = providers.first { $0 == stored } ?? providers.first ?? .codex @@ -262,7 +262,7 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), provider: provider, @@ -274,6 +274,10 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } enum WidgetPreviewData { + static func emptySnapshot() -> WidgetSnapshot { + WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) + } + static func snapshot() -> WidgetSnapshot { let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h") let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d") diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift new file mode 100644 index 0000000000..249f109c2c --- /dev/null +++ b/Tests/CodexBarTests/AppGroupSupportTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AppGroupSupportTests { + @Test + func `app group identifiers use resolved team-prefixed release and debug variants`() { + #expect( + AppGroupSupport.currentGroupID(teamID: "Y5PE65HELJ", bundleID: "com.steipete.codexbar") + == "Y5PE65HELJ.com.steipete.codexbar") + #expect( + AppGroupSupport.currentGroupID(teamID: "ABCDE12345", bundleID: "com.steipete.codexbar.debug") + == "ABCDE12345.com.steipete.codexbar.debug") + #expect( + AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar") + == "group.com.steipete.codexbar") + #expect( + AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar.debug") + == "group.com.steipete.codexbar.debug") + } + + @Test + func `resolved team id falls back to plist and then default`() { + #expect( + AppGroupSupport.resolvedTeamID( + infoDictionaryOverride: [AppGroupSupport.teamIDInfoKey: "ABCDE12345"], + bundleURLOverride: nil) == "ABCDE12345") + #expect( + AppGroupSupport.resolvedTeamID( + infoDictionaryOverride: nil, + bundleURLOverride: nil) == AppGroupSupport.defaultTeamID) + } + + @Test + func `legacy migration copies snapshot once`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let standardSuite = "AppGroupSupportTests-standard-\(UUID().uuidString)" + let currentSuite = "AppGroupSupportTests-current-\(UUID().uuidString)" + let legacySuite = "AppGroupSupportTests-legacy-\(UUID().uuidString)" + + let standardDefaults = try #require(UserDefaults(suiteName: standardSuite)) + let currentDefaults = try #require(UserDefaults(suiteName: currentSuite)) + let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite)) + standardDefaults.removePersistentDomain(forName: standardSuite) + currentDefaults.removePersistentDomain(forName: currentSuite) + legacyDefaults.removePersistentDomain(forName: legacySuite) + + legacyDefaults.set(true, forKey: "debugDisableKeychainAccess") + legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider") + + let legacySnapshotURL = root.appendingPathComponent( + "legacy/widget-snapshot.json", + isDirectory: false) + try fileManager.createDirectory( + at: legacySnapshotURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("legacy-snapshot".utf8).write(to: legacySnapshotURL) + + let currentSnapshotURL = root.appendingPathComponent("current/widget-snapshot.json", isDirectory: false) + let result = AppGroupSupport.migrateLegacyDataIfNeeded( + bundleID: "com.steipete.codexbar", + standardDefaults: standardDefaults, + currentDefaultsOverride: currentDefaults, + currentSnapshotURLOverride: currentSnapshotURL, + legacySnapshotURLOverride: legacySnapshotURL) + + #expect(result.status == .migrated) + #expect(result.copiedSnapshot) + #expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess")) + #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == nil) + #expect(fileManager.fileExists(atPath: currentSnapshotURL.path)) + #expect( + standardDefaults.integer(forKey: AppGroupSupport.migrationVersionKey) + == AppGroupSupport.migrationVersion) + + let secondResult = AppGroupSupport.migrateLegacyDataIfNeeded( + bundleID: "com.steipete.codexbar", + standardDefaults: standardDefaults, + currentDefaultsOverride: currentDefaults, + currentSnapshotURLOverride: currentSnapshotURL, + legacySnapshotURLOverride: legacySnapshotURL) + #expect(secondResult.status == .alreadyCompleted) + } +} diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 71c6461de5..56c3768112 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -16,6 +16,13 @@ struct CodexBarWidgetProviderTests { #expect(ProviderChoice.opencodego.provider == .opencodego) } + @Test + func `supported providers fall back to codex when snapshot is empty`() { + let snapshot = WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.codex]) + } + @Test func `supported providers keep alibaba when it is the only enabled provider`() { let now = Date(timeIntervalSince1970: 1_700_000_000) From c317e89f5c4cf1a7a5d2683bce39ee2a230d40ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 22:23:15 +0100 Subject: [PATCH 0213/1239] fix: migrate app group shared defaults --- CHANGELOG.md | 1 + Sources/CodexBar/SettingsStore.swift | 1 + Sources/CodexBarCore/AppGroupSupport.swift | 45 +++++++++++++++++-- .../CodexBarTests/AppGroupSupportTests.swift | 37 ++++++++++++++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fbb778a93..a5789fcf19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! ### Menu & Settings +- Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! - Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal! ### Fixes diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index d164393177..9456af903c 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -135,6 +135,7 @@ final class SettingsStore { "sharedDefaultsAvailable": sharedDefaultsAvailable ? "1" : "0", "migrationStatus": appGroupMigration.status.rawValue, "migratedSnapshot": appGroupMigration.copiedSnapshot ? "1" : "0", + "migratedDefaults": "\(appGroupMigration.copiedDefaults)", ]) } diff --git a/Sources/CodexBarCore/AppGroupSupport.swift b/Sources/CodexBarCore/AppGroupSupport.swift index fa2237668a..b1b346d346 100644 --- a/Sources/CodexBarCore/AppGroupSupport.swift +++ b/Sources/CodexBarCore/AppGroupSupport.swift @@ -11,6 +11,10 @@ public enum AppGroupSupport { public static let widgetSnapshotFilename = "widget-snapshot.json" public static let migrationVersion = 1 public static let migrationVersionKey = "appGroupMigrationVersion" + private static let sharedDefaultsMigrationKeys = [ + "debugDisableKeychainAccess", + "widgetSelectedProvider", + ] public struct MigrationResult: Sendable { public enum Status: String, Sendable { @@ -22,10 +26,12 @@ public enum AppGroupSupport { public let status: Status public let copiedSnapshot: Bool + public let copiedDefaults: Int - public init(status: Status, copiedSnapshot: Bool = false) { + public init(status: Status, copiedSnapshot: Bool = false, copiedDefaults: Int = 0) { self.status = status self.copiedSnapshot = copiedSnapshot + self.copiedDefaults = copiedDefaults } } @@ -127,6 +133,7 @@ public enum AppGroupSupport { fileManager: FileManager = .default, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, currentDefaultsOverride: UserDefaults? = nil, + legacyDefaultsOverride: UserDefaults? = nil, currentSnapshotURLOverride: URL? = nil, legacySnapshotURLOverride: URL? = nil) -> MigrationResult @@ -135,10 +142,14 @@ public enum AppGroupSupport { return MigrationResult(status: .alreadyCompleted) } - guard currentDefaultsOverride ?? self.sharedDefaults(bundleID: bundleID, fileManager: fileManager) != nil else { + guard let currentDefaults = currentDefaultsOverride ?? self.sharedDefaults( + bundleID: bundleID, + fileManager: fileManager) + else { return MigrationResult(status: .targetUnavailable) } + let legacyDefaults = legacyDefaultsOverride ?? UserDefaults(suiteName: self.legacyGroupID(for: bundleID)) let currentSnapshotURL = currentSnapshotURLOverride ?? self.currentContainerURL(bundleID: bundleID, fileManager: fileManager)? .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) @@ -164,8 +175,15 @@ public enum AppGroupSupport { } }() - let result = if copiedSnapshot { - MigrationResult(status: .migrated, copiedSnapshot: true) + let copiedDefaults = self.copyLegacySharedDefaults( + from: legacyDefaults, + to: currentDefaults) + + let result = if copiedSnapshot || copiedDefaults > 0 { + MigrationResult( + status: .migrated, + copiedSnapshot: copiedSnapshot, + copiedDefaults: copiedDefaults) } else { MigrationResult(status: .noChangesNeeded) } @@ -174,6 +192,25 @@ public enum AppGroupSupport { return result } + private static func copyLegacySharedDefaults( + from legacyDefaults: UserDefaults?, + to currentDefaults: UserDefaults) -> Int + { + guard let legacyDefaults else { return 0 } + + var copied = 0 + for key in self.sharedDefaultsMigrationKeys { + guard currentDefaults.object(forKey: key) == nil, + let legacyValue = legacyDefaults.object(forKey: key) + else { + continue + } + currentDefaults.set(legacyValue, forKey: key) + copied += 1 + } + return copied + } + private static func isDebugBundleID(_ bundleID: String?) -> Bool { guard let bundleID, !bundleID.isEmpty else { return false } return bundleID.contains(".debug") diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift index 249f109c2c..ba55d2acdc 100644 --- a/Tests/CodexBarTests/AppGroupSupportTests.swift +++ b/Tests/CodexBarTests/AppGroupSupportTests.swift @@ -65,13 +65,15 @@ struct AppGroupSupportTests { bundleID: "com.steipete.codexbar", standardDefaults: standardDefaults, currentDefaultsOverride: currentDefaults, + legacyDefaultsOverride: legacyDefaults, currentSnapshotURLOverride: currentSnapshotURL, legacySnapshotURLOverride: legacySnapshotURL) #expect(result.status == .migrated) #expect(result.copiedSnapshot) - #expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess")) - #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == nil) + #expect(result.copiedDefaults == 2) + #expect(currentDefaults.bool(forKey: "debugDisableKeychainAccess")) + #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == UsageProvider.cursor.rawValue) #expect(fileManager.fileExists(atPath: currentSnapshotURL.path)) #expect( standardDefaults.integer(forKey: AppGroupSupport.migrationVersionKey) @@ -81,8 +83,39 @@ struct AppGroupSupportTests { bundleID: "com.steipete.codexbar", standardDefaults: standardDefaults, currentDefaultsOverride: currentDefaults, + legacyDefaultsOverride: legacyDefaults, currentSnapshotURLOverride: currentSnapshotURL, legacySnapshotURLOverride: legacySnapshotURL) #expect(secondResult.status == .alreadyCompleted) } + + @Test + func `legacy migration preserves existing target shared defaults`() throws { + let standardSuite = "AppGroupSupportTests-standard-existing-\(UUID().uuidString)" + let currentSuite = "AppGroupSupportTests-current-existing-\(UUID().uuidString)" + let legacySuite = "AppGroupSupportTests-legacy-existing-\(UUID().uuidString)" + + let standardDefaults = try #require(UserDefaults(suiteName: standardSuite)) + let currentDefaults = try #require(UserDefaults(suiteName: currentSuite)) + let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite)) + standardDefaults.removePersistentDomain(forName: standardSuite) + currentDefaults.removePersistentDomain(forName: currentSuite) + legacyDefaults.removePersistentDomain(forName: legacySuite) + + currentDefaults.set(false, forKey: "debugDisableKeychainAccess") + currentDefaults.set(UsageProvider.codex.rawValue, forKey: "widgetSelectedProvider") + legacyDefaults.set(true, forKey: "debugDisableKeychainAccess") + legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider") + + let result = AppGroupSupport.migrateLegacyDataIfNeeded( + bundleID: "com.steipete.codexbar", + standardDefaults: standardDefaults, + currentDefaultsOverride: currentDefaults, + legacyDefaultsOverride: legacyDefaults) + + #expect(result.status == .noChangesNeeded) + #expect(result.copiedDefaults == 0) + #expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess")) + #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == UsageProvider.codex.rawValue) + } } From 2bea4f045b6590f98da0e464c147cb109d81b986 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 20 Apr 2026 00:43:42 +0530 Subject: [PATCH 0214/1239] Fix OpenAI web dashboard handoff --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 1 + ...OpenAIDashboardBrowserCookieImporter.swift | 66 ++-- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 44 ++- .../OpenAIDashboardWebViewCache.swift | 284 ++++++++++++++++-- .../Codex/CodexWebDashboardStrategy.swift | 48 ++- Tests/CodexBarTests/CLIWebFallbackTests.swift | 10 + ...enAIDashboardFetcherCreditsWaitTests.swift | 37 +++ .../OpenAIDashboardWebViewCacheTests.swift | 100 ++++++ 8 files changed, 518 insertions(+), 72 deletions(-) diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 08a15e4a6b..53b6e20160 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -921,6 +921,7 @@ extension UsageStore { result = try await importer.importBestCookies( intoAccountEmail: normalizedTarget, allowAnyAccount: allowAnyAccount, + preferCachedCookieHeader: !force, cacheScope: cacheScope, logger: log) case .off: diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 200193f62a..bf36e53cfc 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -105,6 +105,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importBestCookies( intoAccountEmail targetEmail: String?, allowAnyAccount: Bool = false, + preferCachedCookieHeader: Bool = true, cacheScope: CookieHeaderCache.Scope? = nil, logger: ((String) -> Void)? = nil) async throws -> ImportResult { @@ -130,27 +131,31 @@ public struct OpenAIDashboardBrowserCookieImporter { var diagnostics = ImportDiagnostics() - if let cached = CookieHeaderCache.load(provider: .codex, scope: cacheScope), - !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - log("Using cached cookie header from \(cached.sourceLabel)") - do { - return try await self.importManualCookies( - cookieHeader: cached.cookieHeader, - intoAccountEmail: context.targetEmail, - allowAnyAccount: context.allowAnyAccount, - cacheScope: cacheScope, - logger: log) - } catch let error as ImportError { - switch error { - case .manualCookieHeaderInvalid, .noMatchingAccount, .dashboardStillRequiresLogin: - CookieHeaderCache.clear(provider: .codex, scope: cacheScope) - default: + if preferCachedCookieHeader { + if let cached = CookieHeaderCache.load(provider: .codex, scope: cacheScope), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + log("Using cached cookie header from \(cached.sourceLabel)") + do { + return try await self.importManualCookies( + cookieHeader: cached.cookieHeader, + intoAccountEmail: context.targetEmail, + allowAnyAccount: context.allowAnyAccount, + cacheScope: cacheScope, + logger: log) + } catch let error as ImportError { + switch error { + case .manualCookieHeaderInvalid, .noMatchingAccount, .dashboardStillRequiresLogin: + CookieHeaderCache.clear(provider: .codex, scope: cacheScope) + default: + throw error + } + } catch { throw error } - } catch { - throw error } + } else { + log("Skipping cached cookie header; forcing fresh browser import") } // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts @@ -606,15 +611,11 @@ public struct OpenAIDashboardBrowserCookieImporter { // Validate against the persistent store (login + email sync). do { - defer { - // The probe is only a validation step. Start the real dashboard scrape with a - // fresh WKWebView instead of reusing the probe instance. - OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) - } let probe = try await OpenAIDashboardFetcher().probeUsagePage( websiteDataStore: persistent, logger: logger, - timeout: 20) + timeout: 20, + preserveLoadedPageForReuse: true) let signed = probe.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) let matches = signed?.lowercased() == targetEmail.lowercased() logger("Persistent session signed in as: \(signed ?? "unknown")") @@ -630,8 +631,12 @@ public struct OpenAIDashboardBrowserCookieImporter { signedInEmail: signed, matchesCodexEmail: matches) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) logger("Selected \(candidate.label) but dashboard still requires login.") throw ImportError.dashboardStillRequiresLogin + } catch { + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) + throw error } } @@ -644,15 +649,11 @@ public struct OpenAIDashboardBrowserCookieImporter { await self.setCookies(candidate.cookies, into: persistent) do { - defer { - // The probe is only a validation step. Start the real dashboard scrape with a - // fresh WKWebView instead of reusing the probe instance. - OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) - } let probe = try await OpenAIDashboardFetcher().probeUsagePage( websiteDataStore: persistent, logger: logger, - timeout: 20) + timeout: 20, + preserveLoadedPageForReuse: true) let signed = probe.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) logger("Persistent session signed in as: \(signed ?? "unknown")") return ImportResult( @@ -661,8 +662,12 @@ public struct OpenAIDashboardBrowserCookieImporter { signedInEmail: signed, matchesCodexEmail: false) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) logger("Selected \(candidate.label) but dashboard still requires login.") throw ImportError.dashboardStillRequiresLogin + } catch { + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) + throw error } } @@ -816,6 +821,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importBestCookies( intoAccountEmail _: String?, allowAnyAccount _: Bool = false, + preferCachedCookieHeader _: Bool = true, cacheScope _: CookieHeaderCache.Scope? = nil, logger _: ((String) -> Void)? = nil) async throws -> ImportResult { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index fe654bfab7..71ab766287 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -19,7 +19,7 @@ public struct OpenAIDashboardFetcher { } } - private let usageURL = URL(string: "https://chatgpt.com/codex/settings/usage")! + private let usageURL = URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")! public init() {} @@ -97,6 +97,22 @@ public struct OpenAIDashboardFetcher { } } + nonisolated static func shouldPreserveLoadedPageAfterProbe(_ result: ProbeResult) -> Bool { + guard !result.loginRequired, !result.workspacePicker, !result.cloudflareInterstitial else { + return false + } + + guard self.isUsageRoute(result.href) else { return false } + + guard let signedInEmail = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines), + !signedInEmail.isEmpty + else { + return false + } + + return true + } + public func loadLatestDashboard( accountEmail: String?, logger: ((String) -> Void)? = nil, @@ -342,13 +358,15 @@ public struct OpenAIDashboardFetcher { public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, - timeout: TimeInterval = 30) async throws -> ProbeResult + timeout: TimeInterval = 30, + preserveLoadedPageForReuse: Bool = false) async throws -> ProbeResult { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, - timeout: Self.remainingTimeout(until: deadline)) + timeout: Self.remainingTimeout(until: deadline), + preserveLoadedPageOnRelease: preserveLoadedPageForReuse) defer { lease.release() } let webView = lease.webView let log = lease.log @@ -410,23 +428,28 @@ public struct OpenAIDashboardFetcher { continue } - return ProbeResult( + let result = ProbeResult( href: scrape.href, loginRequired: scrape.loginRequired, workspacePicker: scrape.workspacePicker, cloudflareInterstitial: scrape.cloudflareInterstitial, signedInEmail: normalizedEmail, bodyText: scrape.bodyText) + lease.setPreserveLoadedPageOnRelease( + preserveLoadedPageForReuse && Self.shouldPreserveLoadedPageAfterProbe(result)) + return result } log("Probe timed out (href=\(lastHref ?? "nil"))") - return ProbeResult( + let result = ProbeResult( href: lastHref, loginRequired: false, workspacePicker: false, cloudflareInterstitial: false, signedInEmail: nil, bodyText: lastBody) + lease.setPreserveLoadedPageOnRelease(false) + return result } // MARK: - JS scrape @@ -530,13 +553,15 @@ public struct OpenAIDashboardFetcher { private func makeWebView( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, - timeout: TimeInterval) async throws -> OpenAIDashboardWebViewLease + timeout: TimeInterval, + preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { try await OpenAIDashboardWebViewCache.shared.acquire( websiteDataStore: websiteDataStore, usageURL: self.usageURL, logger: logger, - navigationTimeout: timeout) + navigationTimeout: timeout, + preserveLoadedPageOnRelease: preserveLoadedPageOnRelease) } nonisolated static func sanitizedTimeout(_ timeout: TimeInterval) -> TimeInterval { @@ -556,7 +581,10 @@ public struct OpenAIDashboardFetcher { guard let href, !href.isEmpty else { return false } let path = (URL(string: href)?.path ?? href) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return path.hasSuffix("codex/settings/usage") || path.hasSuffix("codex/cloud/settings/usage") + return path.hasSuffix("codex/settings/usage") + || path.hasSuffix("codex/cloud/settings/usage") + || path.hasSuffix("codex/settings/analytics") + || path.hasSuffix("codex/cloud/settings/analytics") } private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 397e41138c..1a90d28162 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -6,6 +6,7 @@ import WebKit struct OpenAIDashboardWebViewLease { let webView: WKWebView let log: (String) -> Void + let setPreserveLoadedPageOnRelease: (Bool) -> Void let release: () -> Void } @@ -14,17 +15,67 @@ final class OpenAIDashboardWebViewCache { static let shared = OpenAIDashboardWebViewCache() fileprivate static let log = CodexBarLog.logger(LogCategories.openAIWebview) + private final class ReleaseState { + var preserveLoadedPageOnRelease: Bool + + init(preserveLoadedPageOnRelease: Bool) { + self.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease + } + } + + private struct AcquireOptions { + let allowTimeoutRetry: Bool + let preserveLoadedPageOnRelease: Bool + } + private final class Entry { let webView: WKWebView let host: OffscreenWebViewHost var lastUsedAt: Date var isBusy: Bool - - init(webView: WKWebView, host: OffscreenWebViewHost, lastUsedAt: Date, isBusy: Bool) { + var preservedPageExpiresAt: Date? + var preservedPageExpiryTask: Task? + + init( + webView: WKWebView, + host: OffscreenWebViewHost, + lastUsedAt: Date, + isBusy: Bool, + preservedPageExpiresAt: Date? = nil) + { self.webView = webView self.host = host self.lastUsedAt = lastUsedAt self.isBusy = isBusy + self.preservedPageExpiresAt = preservedPageExpiresAt + } + + func armPreservedPage(until expiry: Date) { + self.preservedPageExpiresAt = expiry + } + + func setPreservedPageExpiryTask(_ task: Task?) { + self.preservedPageExpiryTask?.cancel() + self.preservedPageExpiryTask = task + } + + func clearPreservedPage() { + self.preservedPageExpiresAt = nil + self.preservedPageExpiryTask?.cancel() + self.preservedPageExpiryTask = nil + } + + func consumePreservedPageReuseIfAvailable(now: Date) -> Bool { + guard let preservedPageExpiresAt else { return false } + self.preservedPageExpiresAt = nil + self.preservedPageExpiryTask?.cancel() + self.preservedPageExpiryTask = nil + return preservedPageExpiresAt > now + } + + func hasExpiredPreservedPage(now: Date) -> Bool { + guard let preservedPageExpiresAt else { return false } + return preservedPageExpiresAt <= now } } @@ -32,19 +83,41 @@ final class OpenAIDashboardWebViewCache { /// Keep the WebView alive only long enough for immediate retries/menu reopens. /// Long-lived hidden ChatGPT tabs still consume noticeable energy on some setups. private let idleTimeout: TimeInterval = 60 + /// Reuse the validated analytics page only for the immediate next handoff. + private let preservedPageHandoffTimeout: TimeInterval = 5 private let blankURL = URL(string: "about:blank")! - - private func releaseCachedEntry(_ entry: Entry) { + private let reusablePageResetScript = """ + (() => { + try { + delete window.__codexbarDidScrollToCredits; + delete window.__codexbarUsageBreakdownJSON; + delete window.__codexbarUsageBreakdownDebug; + return true; + } catch { + return false; + } + })(); + """ + + private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) { entry.isBusy = false entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) + self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage) + self.prepareCachedWebViewForIdle( + entry.webView, + host: entry.host, + preserveLoadedPage: preserveLoadedPage) self.prune(now: Date()) } - private func releaseNewEntry(_ entry: Entry, webView: WKWebView) { + private func releaseNewEntry(_ entry: Entry, webView: WKWebView, preserveLoadedPage: Bool) { entry.isBusy = false entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(webView, host: entry.host) + self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage) + self.prepareCachedWebViewForIdle( + webView, + host: entry.host, + preserveLoadedPage: preserveLoadedPage) self.prune(now: Date()) } @@ -71,6 +144,31 @@ final class OpenAIDashboardWebViewCache { self.idleTimeout } + var preservedPageHandoffTimeoutForTesting: TimeInterval { + self.preservedPageHandoffTimeout + } + + func hasPreservedPageForTesting(for websiteDataStore: WKWebsiteDataStore) -> Bool { + let key = ObjectIdentifier(websiteDataStore) + return self.entries[key]?.preservedPageExpiresAt != nil + } + + func markPreservedPageForTesting( + websiteDataStore: WKWebsiteDataStore, + expiresAt: Date = .init().addingTimeInterval(5)) + { + let key = ObjectIdentifier(websiteDataStore) + guard let entry = self.entries[key] else { return } + entry.armPreservedPage(until: expiresAt) + self.schedulePreservedPageExpiry(for: key, entry: entry, expiresAt: expiresAt) + } + + func consumePreservedPageForTesting(websiteDataStore: WKWebsiteDataStore, now: Date = Date()) -> Bool { + let key = ObjectIdentifier(websiteDataStore) + guard let entry = self.entries[key] else { return false } + return entry.consumePreservedPageReuseIfAvailable(now: now) + } + /// Seed a cached entry without navigating a real page (for test stability). @discardableResult func cacheEntryForTesting( @@ -97,17 +195,23 @@ final class OpenAIDashboardWebViewCache { /// Clear all cached entries (for test isolation). func clearAllForTesting() { for (_, entry) in self.entries { + entry.clearPreservedPage() entry.host.close() } self.entries.removeAll() } + + func resetReusablePageStateForTesting(_ webView: WKWebView) async -> Bool { + await self.resetReusablePageState(webView) + } #endif func acquire( websiteDataStore: WKWebsiteDataStore, usageURL: URL, logger: ((String) -> Void)?, - navigationTimeout: TimeInterval = 15) async throws -> OpenAIDashboardWebViewLease + navigationTimeout: TimeInterval = 15, + preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { let deadline = Date().addingTimeInterval(max(navigationTimeout, 1)) return try await self.acquire( @@ -115,7 +219,9 @@ final class OpenAIDashboardWebViewCache { usageURL: usageURL, logger: logger, deadline: deadline, - allowTimeoutRetry: true) + options: .init( + allowTimeoutRetry: true, + preserveLoadedPageOnRelease: preserveLoadedPageOnRelease)) } private func acquire( @@ -123,7 +229,7 @@ final class OpenAIDashboardWebViewCache { usageURL: URL, logger: ((String) -> Void)?, deadline: Date, - allowTimeoutRetry: Bool) async throws -> OpenAIDashboardWebViewLease + options: AcquireOptions) async throws -> OpenAIDashboardWebViewLease { let now = Date() self.prune(now: now) @@ -140,9 +246,13 @@ final class OpenAIDashboardWebViewCache { let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore) host.show() do { - try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) + try await self.prepareWebView( + webView, + usageURL: usageURL, + timeout: remainingTimeout, + canReuseLoadedPage: false) } catch { - if allowTimeoutRetry, Self.isPrepareTimeout(error) { + if options.allowTimeoutRetry, Self.isPrepareTimeout(error) { host.close() log("Temporary OpenAI WebView timed out; retrying with a fresh WebView.") return try await self.acquireTemporaryWebView( @@ -157,18 +267,26 @@ final class OpenAIDashboardWebViewCache { return OpenAIDashboardWebViewLease( webView: webView, log: log, + setPreserveLoadedPageOnRelease: { _ in }, release: { host.close() }) } entry.isBusy = true entry.lastUsedAt = now + let canReuseLoadedPage = entry.consumePreservedPageReuseIfAvailable(now: now) + let releaseState = ReleaseState(preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease) entry.host.show() do { - try await self.prepareWebView(entry.webView, usageURL: usageURL, timeout: remainingTimeout) + try await self.prepareWebView( + entry.webView, + usageURL: usageURL, + timeout: remainingTimeout, + canReuseLoadedPage: canReuseLoadedPage) } catch { - if allowTimeoutRetry, Self.isPrepareTimeout(error) { + if options.allowTimeoutRetry, Self.isPrepareTimeout(error) { entry.isBusy = false entry.lastUsedAt = Date() + entry.clearPreservedPage() entry.host.close() self.entries.removeValue(forKey: key) log("Cached OpenAI WebView timed out; recreating it.") @@ -177,10 +295,13 @@ final class OpenAIDashboardWebViewCache { usageURL: usageURL, logger: logger, deadline: deadline, - allowTimeoutRetry: false) + options: .init( + allowTimeoutRetry: false, + preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease)) } entry.isBusy = false entry.lastUsedAt = Date() + entry.clearPreservedPage() entry.host.close() self.entries.removeValue(forKey: key) Self.log.warning("OpenAI webview prepare failed") @@ -190,9 +311,14 @@ final class OpenAIDashboardWebViewCache { return OpenAIDashboardWebViewLease( webView: entry.webView, log: log, + setPreserveLoadedPageOnRelease: { preserveLoadedPageOnRelease in + releaseState.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease + }, release: { [weak self, weak entry] in guard let self, let entry else { return } - self.releaseCachedEntry(entry) + self.releaseCachedEntry( + entry, + preserveLoadedPage: releaseState.preserveLoadedPageOnRelease) }) } @@ -200,11 +326,16 @@ final class OpenAIDashboardWebViewCache { let entry = Entry(webView: webView, host: host, lastUsedAt: now, isBusy: true) self.entries[key] = entry host.show() + let releaseState = ReleaseState(preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease) do { - try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) + try await self.prepareWebView( + webView, + usageURL: usageURL, + timeout: remainingTimeout, + canReuseLoadedPage: false) } catch { - if allowTimeoutRetry, Self.isPrepareTimeout(error) { + if options.allowTimeoutRetry, Self.isPrepareTimeout(error) { self.entries.removeValue(forKey: key) host.close() log("OpenAI WebView timed out during prepare; retrying once.") @@ -213,7 +344,9 @@ final class OpenAIDashboardWebViewCache { usageURL: usageURL, logger: logger, deadline: deadline, - allowTimeoutRetry: false) + options: .init( + allowTimeoutRetry: false, + preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease)) } self.entries.removeValue(forKey: key) host.close() @@ -224,15 +357,22 @@ final class OpenAIDashboardWebViewCache { return OpenAIDashboardWebViewLease( webView: webView, log: log, + setPreserveLoadedPageOnRelease: { preserveLoadedPageOnRelease in + releaseState.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease + }, release: { [weak self, weak entry] in guard let self, let entry else { return } - self.releaseNewEntry(entry, webView: webView) + self.releaseNewEntry( + entry, + webView: webView, + preserveLoadedPage: releaseState.preserveLoadedPageOnRelease) }) } func evict(websiteDataStore: WKWebsiteDataStore) { let key = ObjectIdentifier(websiteDataStore) guard let entry = self.entries.removeValue(forKey: key) else { return } + entry.clearPreservedPage() Self.log.debug("OpenAI webview evicted") entry.host.close() } @@ -241,6 +381,7 @@ final class OpenAIDashboardWebViewCache { let existing = self.entries self.entries.removeAll() for (_, entry) in existing { + entry.clearPreservedPage() entry.host.close() } if !existing.isEmpty { @@ -248,17 +389,35 @@ final class OpenAIDashboardWebViewCache { } } - private func prepareCachedWebViewForIdle(_ webView: WKWebView, host: OffscreenWebViewHost) { + private func prepareCachedWebViewForIdle( + _ webView: WKWebView, + host: OffscreenWebViewHost, + preserveLoadedPage: Bool) + { + webView.navigationDelegate = nil + webView.codexNavigationDelegate = nil + if preserveLoadedPage { + host.hide() + return + } + // Detach the heavyweight ChatGPT SPA as soon as a scrape completes. Keeping the WebView object around // still helps with immediate reuse, but letting chatgpt.com remain the active document is too expensive. webView.stopLoading() - webView.navigationDelegate = nil - webView.codexNavigationDelegate = nil _ = webView.load(URLRequest(url: self.blankURL)) host.hide() } private func prune(now: Date) { + for entry in self.entries.values where !entry.isBusy && entry.hasExpiredPreservedPage(now: now) { + entry.clearPreservedPage() + self.prepareCachedWebViewForIdle( + entry.webView, + host: entry.host, + preserveLoadedPage: false) + Self.log.debug("OpenAI webview preserved page expired") + } + let expired = self.entries.filter { _, entry in !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout } @@ -281,7 +440,12 @@ final class OpenAIDashboardWebViewCache { return (webView, host) } - private func prepareWebView(_ webView: WKWebView, usageURL: URL, timeout: TimeInterval) async throws { + private func prepareWebView( + _ webView: WKWebView, + usageURL: URL, + timeout: TimeInterval, + canReuseLoadedPage: Bool) async throws + { #if DEBUG if usageURL.absoluteString == "about:blank" { _ = webView.loadHTMLString("", baseURL: nil) @@ -289,6 +453,17 @@ final class OpenAIDashboardWebViewCache { } #endif + if canReuseLoadedPage, + let currentURL = webView.url?.absoluteString, + OpenAIDashboardFetcher.isUsageRoute(currentURL) + { + if await self.resetReusablePageState(webView) { + return + } + + Self.log.debug("OpenAI preserved page reset failed; reloading usage URL") + } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in let delegate = NavigationDelegate { result in cont.resume(with: result) @@ -310,7 +485,11 @@ final class OpenAIDashboardWebViewCache { let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore) host.show() do { - try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) + try await self.prepareWebView( + webView, + usageURL: usageURL, + timeout: remainingTimeout, + canReuseLoadedPage: false) } catch { host.close() throw error @@ -318,6 +497,7 @@ final class OpenAIDashboardWebViewCache { return OpenAIDashboardWebViewLease( webView: webView, log: log, + setPreserveLoadedPageOnRelease: { _ in }, release: { host.close() }) } @@ -325,6 +505,60 @@ final class OpenAIDashboardWebViewCache { let nsError = error as NSError return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut } + + private func updatePreservedPageState(for entry: Entry, preserveLoadedPage: Bool) { + if preserveLoadedPage { + let expiresAt = Date().addingTimeInterval(self.preservedPageHandoffTimeout) + entry.armPreservedPage(until: expiresAt) + if let key = self.entries.first(where: { $0.value === entry })?.key { + self.schedulePreservedPageExpiry(for: key, entry: entry, expiresAt: expiresAt) + } + } else { + entry.clearPreservedPage() + } + } + + private func schedulePreservedPageExpiry( + for key: ObjectIdentifier, + entry: Entry, + expiresAt: Date) + { + let delay = max(0, expiresAt.timeIntervalSinceNow) + let task = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + self?.expirePreservedPageIfNeeded(for: key, expectedExpiry: expiresAt) + } + entry.setPreservedPageExpiryTask(task) + } + + private func expirePreservedPageIfNeeded(for key: ObjectIdentifier, expectedExpiry: Date) { + guard let entry = self.entries[key], + !entry.isBusy, + let preservedPageExpiresAt = entry.preservedPageExpiresAt, + preservedPageExpiresAt == expectedExpiry, + preservedPageExpiresAt <= Date() + else { + return + } + + entry.clearPreservedPage() + self.prepareCachedWebViewForIdle( + entry.webView, + host: entry.host, + preserveLoadedPage: false) + Self.log.debug("OpenAI webview preserved page expired") + self.prune(now: Date()) + } + + private func resetReusablePageState(_ webView: WKWebView) async -> Bool { + do { + let any = try await webView.evaluateJavaScript(self.reusablePageResetScript) + return (any as? Bool) ?? true + } catch { + return false + } + } } @MainActor diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index 9f7b6b93ee..b613a54e43 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -150,15 +150,43 @@ extension CodexWebDashboardStrategy { let log: @MainActor (String) -> Void = { line in logger.append(line) } - let result = try await Self.fetchOpenAIWebDashboard( - context: context, - options: options, - browserDetection: browserDetection, - logger: log) - return try Self.makeAuthorizedDashboardResult( - dashboard: result.dashboard, - context: context, - routingTargetEmail: result.routingTargetEmail) + do { + let result = try await Self.fetchOpenAIWebDashboard( + context: context, + options: options, + browserDetection: browserDetection, + preferCachedCookieHeader: true, + logger: log) + return try Self.makeAuthorizedDashboardResult( + dashboard: result.dashboard, + context: context, + routingTargetEmail: result.routingTargetEmail) + } catch { + guard Self.shouldRetryWithFreshBrowserImport(after: error) else { + throw error + } + log("Retrying OpenAI web dashboard with a fresh browser cookie import.") + let result = try await Self.fetchOpenAIWebDashboard( + context: context, + options: options, + browserDetection: browserDetection, + preferCachedCookieHeader: false, + logger: log) + return try Self.makeAuthorizedDashboardResult( + dashboard: result.dashboard, + context: context, + routingTargetEmail: result.routingTargetEmail) + } + } + + nonisolated static func shouldRetryWithFreshBrowserImport(after error: Error) -> Bool { + if error is OpenAIWebCodexError { + return error as? OpenAIWebCodexError == .missingUsage + } + if case OpenAIDashboardFetcher.FetchError.noDashboardData = error { + return true + } + return false } @MainActor @@ -222,6 +250,7 @@ extension CodexWebDashboardStrategy { context: ProviderFetchContext, options: OpenAIWebOptions, browserDetection: BrowserDetection, + preferCachedCookieHeader: Bool, logger: @MainActor @escaping (String) -> Void) async throws -> OpenAIWebDashboardFetchResult { let auth = context.fetcher.loadAuthBackedCodexAccount() @@ -232,6 +261,7 @@ extension CodexWebDashboardStrategy { .importBestCookies( intoAccountEmail: routingTargetEmail, allowAnyAccount: allowAnyAccount, + preferCachedCookieHeader: preferCachedCookieHeader, logger: logger) let effectiveEmail = routingTargetEmail ?? importResult.signedInEmail? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/CodexBarTests/CLIWebFallbackTests.swift b/Tests/CodexBarTests/CLIWebFallbackTests.swift index a3809c5b34..df93a6f0a5 100644 --- a/Tests/CodexBarTests/CLIWebFallbackTests.swift +++ b/Tests/CodexBarTests/CLIWebFallbackTests.swift @@ -62,6 +62,16 @@ struct CLIWebFallbackTests { context: context)) } + @Test + func `codex retries fresh browser import for missing usage and no data`() { + #expect(CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport( + after: OpenAIWebCodexError.missingUsage)) + #expect(CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport( + after: OpenAIDashboardFetcher.FetchError.noDashboardData(body: "missing"))) + #expect(!CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport( + after: OpenAIDashboardFetcher.FetchError.loginRequired)) + } + @Test func `codex display only falls back in auto`() { let strategy = CodexWebDashboardStrategy() diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index 85713405ae..b234a2c325 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -108,6 +108,32 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(shouldWait == false) } + @Test + func `probe handoff preserves page only after confirmed signed in email`() { + let result = OpenAIDashboardFetcher.ProbeResult( + href: "https://chatgpt.com/codex/cloud/settings/analytics#usage", + loginRequired: false, + workspacePicker: false, + cloudflareInterstitial: false, + signedInEmail: "user@example.com", + bodyText: "Credits remaining 42") + + #expect(OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result)) + } + + @Test + func `probe handoff does not preserve timed out usage page without email`() { + let result = OpenAIDashboardFetcher.ProbeResult( + href: "https://chatgpt.com/codex/cloud/settings/analytics#usage", + loginRequired: false, + workspacePicker: false, + cloudflareInterstitial: false, + signedInEmail: nil, + bodyText: "Codex Analytics") + + #expect(!OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result)) + } + @Test func `probe grace restarts after route reload resets readiness timestamps`() { let now = Date() @@ -167,10 +193,21 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage")) } + @Test + func `usage route matcher accepts analytics route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics")) + } + + @Test + func `usage route matcher accepts analytics usage hash route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics#usage")) + } + @Test func `usage route matcher accepts trailing slash variants`() { #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage/")) #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage/")) + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics/")) } @Test diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index f6558b8107..1b361879d9 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -138,6 +138,106 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test + func `Preserved page handoff is consumed only once`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting)) + + #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") + #expect(cache.consumePreservedPageForTesting(websiteDataStore: store), "First acquire should reuse handoff") + #expect( + !cache.consumePreservedPageForTesting(websiteDataStore: store), + "Second acquire should not keep reusing preserved page") + + cache.clearAllForTesting() + } + + @Test + func `Expired preserved page is cleared before idle eviction`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(1)) + + let afterExpiry = Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting + 1) + cache.pruneForTesting(now: afterExpiry) + + #expect(!cache.hasPreservedPageForTesting(for: store), "Expired preserved page should be cleared") + #expect(cache.hasCachedEntry(for: store), "Entry should remain cached after page handoff expires") + + cache.clearAllForTesting() + } + + @Test + func `Preserved page expiry is scheduled without future cache activity`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let webView = cache.cacheEntryForTesting(websiteDataStore: store) + + _ = webView.loadHTMLString("alive", baseURL: nil) + try? await Task.sleep(for: .milliseconds(150)) + + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(0.2)) + + #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") + + try? await Task.sleep(for: .milliseconds(450)) + + let bodyText = try await webView.evaluateJavaScript( + "document.body ? String(document.body.innerText || '') : ''") as? String + + #expect(!cache.hasPreservedPageForTesting(for: store), "Expected scheduled expiry to clear preserved page") + #expect(bodyText?.isEmpty == true, "Expected scheduled expiry to detach the preserved page to about:blank") + + cache.clearAllForTesting() + } + + @Test + func `Reused page reset clears one shot scraper globals`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let lease = try await cache.acquire( + websiteDataStore: store, + usageURL: url, + logger: nil) + + _ = try await lease.webView.evaluateJavaScript( + """ + window.__codexbarDidScrollToCredits = true; + window.__codexbarUsageBreakdownJSON = '[{"day":"2026-04-19"}]'; + window.__codexbarUsageBreakdownDebug = 'debug'; + true; + """) + + #expect(await cache.resetReusablePageStateForTesting(lease.webView)) + + let reset = try await lease.webView.evaluateJavaScript( + """ + typeof window.__codexbarDidScrollToCredits === 'undefined' && + typeof window.__codexbarUsageBreakdownJSON === 'undefined' && + typeof window.__codexbarUsageBreakdownDebug === 'undefined' + """) as? Bool + + #expect(reset == true, "Expected one-shot scraper globals to be cleared before reuse") + + lease.release() + cache.clearAllForTesting() + } + // MARK: - Eviction Tests @Test From 1f58bb8cd5a43bd6e3a08a06556630531632c4d8 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 20 Apr 2026 01:09:48 +0530 Subject: [PATCH 0215/1239] docs: update changelog with OpenAI web dashboard fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5789fcf19..7fc134b424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! +- Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. @ratulsarna ### Menu & Settings - Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! From 94d20eb2d95d769bd7f63a77e6b3644f26ed8639 Mon Sep 17 00:00:00 2001 From: Anirudh Venkatachalam <50367124+anirudhvee@users.noreply.github.com> Date: Sat, 18 Apr 2026 04:28:10 -0700 Subject: [PATCH 0216/1239] feat: add menu keyboard shortcuts --- .../CodexBar/StatusItemController+Menu.swift | 17 +++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 55ff40b7ad..88cb625a51 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -16,6 +16,19 @@ extension StatusItemController { static let costHistoryChartID = "costHistoryChart" static let usageHistoryChartID = "usageHistoryChart" + private func shortcut(for action: MenuDescriptor.MenuAction) -> (key: String, modifiers: NSEvent.ModifierFlags)? { + switch action { + case .refresh: + ("r", [.command]) + case .settings: + (",", [.command]) + case .quit: + ("q", [.command]) + default: + nil + } + } + private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { _ = menu return Self.menuCardBaseWidth @@ -506,6 +519,10 @@ extension StatusItemController { let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") item.target = self item.representedObject = represented + if let shortcut = self.shortcut(for: action) { + item.keyEquivalent = shortcut.key + item.keyEquivalentModifierMask = shortcut.modifiers + } if let iconName = action.systemImageName, let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index c401344675..c103bc7a3a 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -522,6 +522,21 @@ struct StatusMenuTests { #expect(titles.contains("Settings...")) #expect(titles.contains("About CodexBar")) #expect(titles.contains("Quit")) + + let refreshItem = menu.items.first { $0.title == "Refresh" } + #expect(refreshItem != nil) + #expect(refreshItem?.keyEquivalent == "r") + #expect(refreshItem?.keyEquivalentModifierMask == [.command]) + + let settingsItem = menu.items.first { $0.title == "Settings..." } + #expect(settingsItem != nil) + #expect(settingsItem?.keyEquivalent == ",") + #expect(settingsItem?.keyEquivalentModifierMask == [.command]) + + let quitItem = menu.items.first { $0.title == "Quit" } + #expect(quitItem != nil) + #expect(quitItem?.keyEquivalent == "q") + #expect(quitItem?.keyEquivalentModifierMask == [.command]) } } From 31242990b2342658c266b70ce42df6d64e78493b Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 21 Apr 2026 01:21:51 +0530 Subject: [PATCH 0217/1239] chore: update changelog with new features and fixes for the upcoming release - Added highlights for Codex, Synthetic, Antigravity, Menu, and Widgets improvements. - Included details on menu keyboard shortcuts for better user experience. - Acknowledged contributions from various collaborators. --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc134b424..6e0fb39fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 0.22 — Unreleased +### Highlights +- Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. +- Synthetic: parse live quota payloads for five-hour, weekly, and search limits, including continuous reset/regeneration details (#732). Thanks @baanish! +- Antigravity: restore account/quota probing across newer localhost endpoint/token layouts and retry paths (#727). Thanks @icey-zhang! +- Menu: add standard shortcuts for Refresh, Settings, and Quit while the status menu is open (#737). Thanks @anirudhvee! +- Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! + ### Providers & Usage - Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish! - Antigravity: restore localhost probing with async TLS challenge handling, extension-token fallback, and best-effort port selection (#727). Thanks @icey-zhang! @@ -11,6 +18,7 @@ - Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. @ratulsarna ### Menu & Settings +- Menu: show and handle standard shortcuts for Refresh (⌘R), Settings (⌘,), and Quit (⌘Q) while the status menu is open (#737). Thanks @anirudhvee! - Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! - Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal! From 8736b0a4460c5b4284b595f7c68863edd0f55aab Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 21 Apr 2026 02:48:43 +0530 Subject: [PATCH 0218/1239] Preserve keychain cache on temporary lock --- Sources/CodexBarCore/CookieHeaderCache.swift | 3 + Sources/CodexBarCore/KeychainCacheStore.swift | 22 +- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 46 +++- ...ialsStoreTemporaryKeychainCacheTests.swift | 237 ++++++++++++++++++ .../CookieHeaderCacheTests.swift | 65 +++++ .../KeychainCacheStoreTests.swift | 41 ++- 6 files changed, 396 insertions(+), 18 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift diff --git a/Sources/CodexBarCore/CookieHeaderCache.swift b/Sources/CodexBarCore/CookieHeaderCache.swift index 0d24cde937..dae36ebf22 100644 --- a/Sources/CodexBarCore/CookieHeaderCache.swift +++ b/Sources/CodexBarCore/CookieHeaderCache.swift @@ -36,6 +36,9 @@ public enum CookieHeaderCache { case let .found(entry): self.log.debug("Cookie cache hit", metadata: ["provider": provider.rawValue]) return entry + case .temporarilyUnavailable: + self.log.debug("Cookie cache temporarily unavailable", metadata: ["provider": provider.rawValue]) + return nil case .invalid: self.log.warning("Cookie cache invalid; clearing", metadata: ["provider": provider.rawValue]) KeychainCacheStore.clear(key: key) diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 03060d9c26..ad5b9db37c 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -21,6 +21,7 @@ public enum KeychainCacheStore { public enum LoadResult { case found(Entry) case missing + case temporarilyUnavailable case invalid } @@ -29,6 +30,9 @@ public enum KeychainCacheStore { private static let cacheLabel = "CodexBar Cache" private nonisolated(unsafe) static var globalServiceOverride: String? @TaskLocal private static var serviceOverride: String? + #if DEBUG && os(macOS) + @TaskLocal private static var loadFailureStatusOverride: OSStatus? + #endif private static let testStoreLock = NSLock() private struct TestStoreKey: Hashable { let service: String @@ -42,6 +46,11 @@ public enum KeychainCacheStore { key: Key, as type: Entry.Type = Entry.self) -> LoadResult { + #if DEBUG && os(macOS) + if let status = self.loadFailureStatusOverride { + return self.loadResultForKeychainReadFailure(status: status, key: key) + } + #endif if let testResult = loadFromTestStore(key: key, as: type) { return testResult } @@ -170,6 +179,17 @@ public enum KeychainCacheStore { self.serviceOverride } + #if DEBUG && os(macOS) + public static func withLoadFailureStatusOverrideForTesting( + _ status: OSStatus?, + operation: () throws -> T) rethrows -> T + { + try self.$loadFailureStatusOverride.withValue(status) { + try operation() + } + } + #endif + static func setTestStoreForTesting(_ enabled: Bool) { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } @@ -213,7 +233,7 @@ public enum KeychainCacheStore { case errSecInteractionNotAllowed: // Keychain is temporarily locked, e.g. immediately after wake from sleep. self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") - return .missing + return .temporarilyUnavailable default: self.log.error("Keychain cache read failed (\(key.account)): \(status)") return .invalid diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 35dfdc2744..6388be800e 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -207,6 +207,7 @@ public enum ClaudeOAuthCredentialsStore { var lastError: Error? var expiredRecord: ClaudeOAuthCredentialRecord? + var cacheTemporarilyUnavailable = false switch KeychainCacheStore.load(key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) { case let .found(entry): @@ -239,6 +240,8 @@ public enum ClaudeOAuthCredentialsStore { } case .invalid: KeychainCacheStore.clear(key: ClaudeOAuthCredentialsStore.cacheKey) + case .temporarilyUnavailable: + cacheTemporarilyUnavailable = true case .missing: break } @@ -259,7 +262,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: Date()) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(fileData, owner: .claudeCLI) + if !cacheTemporarilyUnavailable { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(fileData, owner: .claudeCLI) + } return record } } catch let error as ClaudeOAuthCredentialsError { @@ -274,7 +279,8 @@ public enum ClaudeOAuthCredentialsStore { if allowClaudeKeychainRepairWithoutPrompt, !allowKeychainPrompt { if let repaired = recovery.repairFromClaudeKeychainWithoutPromptIfAllowed( now: Date(), - respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) + respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes, + allowCacheKeychainWrite: !cacheTemporarilyUnavailable) { return repaired } @@ -283,6 +289,7 @@ public enum ClaudeOAuthCredentialsStore { if let prompted = self.loadFromClaudeKeychainWithPromptIfAllowed( allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: respectKeychainPromptCooldown, + allowCacheKeychainWrite: !cacheTemporarilyUnavailable, lastError: &lastError) { return prompted @@ -299,6 +306,7 @@ public enum ClaudeOAuthCredentialsStore { private func loadFromClaudeKeychainWithPromptIfAllowed( allowKeychainPrompt: Bool, respectKeychainPromptCooldown: Bool, + allowCacheKeychainWrite: Bool, lastError: inout Error?) -> ClaudeOAuthCredentialRecord? { let shouldApplyPromptCooldown = @@ -355,7 +363,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: Date()) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + if allowCacheKeychainWrite { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + } return record } @@ -404,7 +414,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: Date()) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + if allowCacheKeychainWrite { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + } return record } catch let error as ClaudeOAuthCredentialsError { if case .notFound = error { @@ -423,24 +435,28 @@ public enum ClaudeOAuthCredentialsStore { let current = ClaudeOAuthCredentialsStore.currentFileFingerprint() let stored = ClaudeOAuthCredentialsStore.loadFileFingerprint() guard current != stored else { return false } - ClaudeOAuthCredentialsStore.saveFileFingerprint(current) ClaudeOAuthCredentialsStore.log.info("Claude OAuth credentials file changed; invalidating cache") ClaudeOAuthCredentialsStore.writeMemoryCache(record: nil, timestamp: nil) var shouldClearKeychainCache = false + var shouldSaveFileFingerprint = true if let current { if let modifiedAtMs = current.modifiedAtMs { let modifiedAt = Date(timeIntervalSince1970: TimeInterval(Double(modifiedAtMs) / 1000.0)) - if case let .found(entry) = KeychainCacheStore.load( + switch KeychainCacheStore.load( key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) { + case let .found(entry): if entry.storedAt < modifiedAt { shouldClearKeychainCache = true } - } else { + case .missing, .invalid: shouldClearKeychainCache = true + case .temporarilyUnavailable: + shouldClearKeychainCache = false + shouldSaveFileFingerprint = false } } else { shouldClearKeychainCache = true @@ -452,6 +468,9 @@ public enum ClaudeOAuthCredentialsStore { if shouldClearKeychainCache { ClaudeOAuthCredentialsStore.clearCacheKeychain() } + if shouldSaveFileFingerprint { + ClaudeOAuthCredentialsStore.saveFileFingerprint(current) + } return true } } @@ -507,6 +526,8 @@ public enum ClaudeOAuthCredentialsStore { owner: entry.owner ?? .claudeCLI, source: .cacheKeychain) return isRefreshableOrValid(record) + case .temporarilyUnavailable: + return true default: break } @@ -697,7 +718,8 @@ public enum ClaudeOAuthCredentialsStore { func repairFromClaudeKeychainWithoutPromptIfAllowed( now: Date, - respectKeychainPromptCooldown: Bool) -> ClaudeOAuthCredentialRecord? + respectKeychainPromptCooldown: Bool, + allowCacheKeychainWrite: Bool = true) -> ClaudeOAuthCredentialRecord? { #if os(macOS) let mode = ClaudeOAuthKeychainPromptPreference.current() @@ -735,7 +757,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(securityData, owner: .claudeCLI) + if allowCacheKeychainWrite { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(securityData, owner: .claudeCLI) + } ClaudeOAuthCredentialsStore.log.info( "Claude keychain credentials loaded without prompt; syncing OAuth cache", @@ -773,7 +797,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI) + if allowCacheKeychainWrite { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI) + } ClaudeOAuthCredentialsStore.log.info( "Claude keychain credentials loaded without prompt; syncing OAuth cache", diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift new file mode 100644 index 0000000000..b9e0630f55 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift @@ -0,0 +1,237 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { + private struct WrongCacheEntry: Codable { + let value: String + } + + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let refreshField: String = { + guard let refreshToken else { return "" } + return ",\n \"refreshToken\": \"\(refreshToken)\"" + }() + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"]\(refreshField) + } + } + """ + return Data(json.utf8) + } + + #if os(macOS) + @Test + func `credentials file invalidation preserves keychain cache when temporarily unavailable`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let firstFile = self.makeCredentialsData( + accessToken: "first-file", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try firstFile.write(to: fileURL) + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(), + owner: .claudeCLI)) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let updatedFile = self.makeCredentialsData( + accessToken: "updated-file-token-longer", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try updatedFile.write(to: fileURL) + + KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + } + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected temporary unavailability not to clear Claude cache") + } + + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected pending invalidation to clear stale Claude cache") + } + } + } + } + } + } + + @Test + func `temporary keychain cache unavailability does not overwrite cache from credentials file fallback`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fileData = self.makeCredentialsData( + accessToken: "file-fallback-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try fileData.write(to: fileURL) + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(), + owner: .claudeCLI)) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let loaded = try KeychainCacheStore.withLoadFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + #expect(loaded.accessToken == "file-fallback-token") + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected file fallback not to overwrite unavailable cache") + } + } + } + } + } + } + } + + @Test + func `has cached credentials treats temporary keychain cache unavailability as present`() { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let hasCached = KeychainCacheStore.withLoadFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) + } + + #expect(hasCached == true) + } + } + } + #endif + + @Test + func `invalid keychain cache is cleared by load`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store(key: cacheKey, entry: WrongCacheEntry(value: "wrong-shape")) + + do { + _ = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected invalid Claude cache to be cleared") + } + } + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/CookieHeaderCacheTests.swift b/Tests/CodexBarTests/CookieHeaderCacheTests.swift index 3a8a7b50dd..de12476947 100644 --- a/Tests/CodexBarTests/CookieHeaderCacheTests.swift +++ b/Tests/CodexBarTests/CookieHeaderCacheTests.swift @@ -4,6 +4,10 @@ import Testing @Suite(.serialized) struct CookieHeaderCacheTests { + private struct WrongEntry: Codable { + let value: String + } + @Test func `stores and loads entry`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -105,4 +109,65 @@ struct CookieHeaderCacheTests { let loadedAgain = CookieHeaderCache.load(provider: provider) #expect(loadedAgain?.cookieHeader == "auth=legacy") } + + #if os(macOS) + @Test + func `temporary keychain unavailability returns nil without migrating legacy file`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) + defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + + let provider: UsageProvider = .codex + let legacyURL = legacyBase.appendingPathComponent("\(provider.rawValue)-cookie.json") + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: legacyURL) + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + + let loaded = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.load(provider: provider) + } + + #expect(loaded == nil) + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + + switch KeychainCacheStore.load(key: .cookie(provider: provider), as: CookieHeaderCache.Entry.self) { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected temporary miss not to migrate legacy cache") + } + } + #endif + + @Test + func `invalid keychain cache is cleared`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) + defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + + let provider: UsageProvider = .codex + let key = KeychainCacheStore.Key.cookie(provider: provider) + KeychainCacheStore.store(key: key, entry: WrongEntry(value: "not-a-cookie-entry")) + + #expect(CookieHeaderCache.load(provider: provider) == nil) + + switch KeychainCacheStore.load(key: key, as: CookieHeaderCache.Entry.self) { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected invalid cookie cache to be cleared") + } + } } diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 41bd827283..12145ea6a2 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -24,7 +24,7 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case let .found(loaded): #expect(loaded == entry) - case .missing, .invalid: + case .missing, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected keychain cache entry") } } @@ -45,7 +45,7 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case let .found(loaded): #expect(loaded == second) - case .missing, .invalid: + case .missing, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected overwritten keychain cache entry") } } @@ -64,24 +64,51 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case .missing: #expect(true) - case .found, .invalid: + case .found, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected keychain cache entry to be cleared") } } #if os(macOS) @Test - func `interaction not allowed is treated as temporarily missing`() { + func `interaction not allowed is treated as temporarily unavailable`() { let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) let result: KeychainCacheStore.LoadResult = KeychainCacheStore.loadResultForKeychainReadFailure( status: errSecInteractionNotAllowed, key: key) switch result { - case .missing: + case .temporarilyUnavailable: #expect(true) - case .found, .invalid: - #expect(Bool(false), "Expected temporary keychain lock to preserve cache") + case .found, .missing, .invalid: + #expect(Bool(false), "Expected temporary keychain lock to be retry-later") + } + } + + @Test + func `load failure override bypasses test store without affecting store or clear`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let entry = TestEntry(value: "stored", storedAt: Date(timeIntervalSince1970: 0)) + KeychainCacheStore.store(key: key, entry: entry) + defer { KeychainCacheStore.clear(key: key) } + + KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case .temporarilyUnavailable: + #expect(true) + case .found, .missing, .invalid: + #expect(Bool(false), "Expected override to run before test store") + } + } + + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case let .found(loaded): + #expect(loaded == entry) + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected override not to mutate test store") } } #endif From d9c82bfd52c2447869b9ebe22ecd622b1a180b39 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 00:48:09 +0100 Subject: [PATCH 0219/1239] docs: finalize changelog for 0.22 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0fb39fa8..1892c880c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.22 — Unreleased +## 0.22 — 2026-04-21 ### Highlights - Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. From afe15bd2a156fcbc3b3ecebb966c27fecd9fa347 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 00:54:37 +0100 Subject: [PATCH 0220/1239] build: bump 0.22 build number --- version.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.env b/version.env index f2f277da72..0423e7bb1a 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ MARKETING_VERSION=0.22 -BUILD_NUMBER=56 +BUILD_NUMBER=57 From cd5899a748cca5345030d0be3facb6c36d69397a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 01:12:55 +0100 Subject: [PATCH 0221/1239] docs: update appcast for 0.22 --- appcast.xml | 66 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/appcast.xml b/appcast.xml index 0585c6e371..635565eac6 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,45 @@ CodexBar + + 0.22 + Tue, 21 Apr 2026 01:12:52 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 57 + 0.22 + 14.0 + CodexBar 0.22 +

Highlights

+
    +
  • Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry.
  • +
  • Synthetic: parse live quota payloads for five-hour, weekly, and search limits, including continuous reset/regeneration details (#732). Thanks @baanish!
  • +
  • Antigravity: restore account/quota probing across newer localhost endpoint/token layouts and retry paths (#727). Thanks @icey-zhang!
  • +
  • Menu: add standard shortcuts for Refresh, Settings, and Quit while the status menu is open (#737). Thanks @anirudhvee!
  • +
  • Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman!
  • +
+

Providers & Usage

+
    +
  • Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish!
  • +
  • Antigravity: restore localhost probing with async TLS challenge handling, extension-token fallback, and best-effort port selection (#727). Thanks @icey-zhang!
  • +
  • Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael!
  • +
  • Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe!
  • +
  • Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang!
  • +
  • Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. @ratulsarna
  • +
+

Menu & Settings

+
    +
  • Menu: show and handle standard shortcuts for Refresh (⌘R), Settings (⌘,), and Quit (⌘Q) while the status menu is open (#737). Thanks @anirudhvee!
  • +
  • Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman!
  • +
  • Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal!
  • +
+

Fixes

+
    +
  • Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98!
  • +
+

View full changelog

+]]>
+ +
0.21 Sat, 18 Apr 2026 19:49:47 +0100 @@ -98,33 +137,6 @@ ]]> - - 0.20.0-beta.1 - Wed, 01 Apr 2026 00:36:32 +0900 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 54 - 0.20.0-beta.1 - 14.0 - CodexBar 0.20.0-beta.1 -

Highlights

-
    -
  • Add basic multi-account support to Codex. Thanks @monterrr and @Rag30 for the initial effort and ideas!
  • -
  • Add Perplexity provider with recurring, bonus, and purchased-credit tracking; plan detection (Pro/Max); and browser-cookie auto-import with manual-cookie fallback (#449). Thanks @BeelixGit!
  • -
-

Providers & Usage

-
    -
  • Add the foundation for multi-account support to Codex and basic UX for adding and switching accounts. @ratulsarna
  • -
  • Codex: normalize weekly-only rate limits across OAuth and CLI/RPC so free-plan accounts render as Weekly instead of a fake Session, preserve unknown single-window payloads in the primary lane, hide the empty Session lane in widgets, and accept weekly-only Codex CLI /status/RPC data without failing. @ratulsarna
  • -
  • Perplexity: add provider support with credit tracking for recurring (monthly), bonus (promotional), and purchased on-demand credits; plan detection (Pro/Max); and browser-cookie auto-import with manual-cookie fallback (#449). Thanks @BeelixGit!
  • -
-

Menu & Settings

-
    -
  • Fix alignment of menu chart hover coordinates on macOS. Thanks @cuidong233!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 532d0191c6261bf82976669f03cd4ebb39e1dd6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 02:14:46 +0100 Subject: [PATCH 0222/1239] test: isolate Gemini fnm CLI fixture --- Tests/CodexBarTests/GeminiStatusProbeAPITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 61cb8b0f5c..921ebe1e43 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -231,7 +231,7 @@ struct GeminiStatusProbeAPITests { setenv("PATH", pathValue, 1) let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - unsetenv("GEMINI_CLI_PATH") + setenv("GEMINI_CLI_PATH", binURL.path, 1) defer { if let previousPath { setenv("PATH", previousPath, 1) From a7d81d3fb31b36a63fc68d8565472ee36fde1226 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 04:51:41 +0100 Subject: [PATCH 0223/1239] fix: report CLI version from app bundle --- Sources/CodexBarCLI/CLIIO.swift | 37 +++++++++++++++++- Tests/CodexBarTests/CLIEntryTests.swift | 52 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index fe42751cbd..160d0ef13d 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -12,7 +12,7 @@ extension CodexBarCLI { } static func printVersion() -> Never { - if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + if let version = currentVersion() { print("CodexBar \(version)") } else { print("CodexBar") @@ -21,7 +21,7 @@ extension CodexBarCLI { } static func printHelp(for command: String?) -> Never { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let version = self.currentVersion() ?? "unknown" switch command { case "usage": print(Self.usageHelp(version: version)) @@ -35,6 +35,39 @@ extension CodexBarCLI { Self.platformExit(0) } + static func currentVersion( + bundle: Bundle = .main, + executablePath: String? = CommandLine.arguments.first) -> String? + { + if let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String { + return version + } + guard let executablePath, !executablePath.isEmpty else { return nil } + + let executableURL = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath() + return Self.containingAppVersion(for: executableURL) + } + + static func containingAppVersion(for executableURL: URL) -> String? { + var currentURL = executableURL.deletingLastPathComponent() + let fileManager = FileManager.default + + while currentURL.path != currentURL.deletingLastPathComponent().path { + if currentURL.pathExtension == "app" { + let infoURL = currentURL + .appendingPathComponent("Contents") + .appendingPathComponent("Info.plist") + guard let data = fileManager.contents(atPath: infoURL.path), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + else { return nil } + return plist["CFBundleShortVersionString"] as? String + } + currentURL.deleteLastPathComponent() + } + + return nil + } + static func platformExit(_ code: Int32) -> Never { #if canImport(Darwin) Darwin.exit(code) diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index daa8f174f6..0aa92f786e 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -44,6 +44,58 @@ struct CLIEntryTests { #expect(header.contains("cli")) } + @Test + func `CLI version falls back to containing app bundle`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cli-version-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true) + try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true) + + let infoURL = contentsURL.appendingPathComponent("Info.plist") + let plist: [String: Any] = ["CFBundleShortVersionString": "9.8.7"] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: infoURL) + + let helperURL = helpersURL.appendingPathComponent("CodexBarCLI") + try Data().write(to: helperURL) + + #expect(CodexBarCLI.containingAppVersion(for: helperURL) == "9.8.7") + } + + @Test + func `CLI version follows symlinked helper`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cli-version-symlink-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true) + let emptyBundleURL = root.appendingPathComponent("Empty.bundle", isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true) + let binURL = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: emptyBundleURL, withIntermediateDirectories: true) + + let infoURL = contentsURL.appendingPathComponent("Info.plist") + let plist: [String: Any] = ["CFBundleShortVersionString": "2.4.6"] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: infoURL) + + let helperURL = helpersURL.appendingPathComponent("CodexBarCLI") + try Data().write(to: helperURL) + + let symlinkURL = binURL.appendingPathComponent("codexbar") + try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: helperURL) + + let emptyBundle = try #require(Bundle(url: emptyBundleURL)) + #expect(CodexBarCLI.currentVersion(bundle: emptyBundle, executablePath: symlinkURL.path) == "2.4.6") + } + @Test func `render open AI web dashboard text includes summary`() { let event = CreditEvent( From e44161fe44f012a53d840b5149b17b220e66b17a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 05:05:55 +0100 Subject: [PATCH 0224/1239] chore: start 0.23 development --- CHANGELOG.md | 5 +++++ version.env | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1892c880c0..0a017183ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.23 — Unreleased + +### Changes +- Development ongoing. + ## 0.22 — 2026-04-21 ### Highlights diff --git a/version.env b/version.env index 0423e7bb1a..073e2844fa 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.22 -BUILD_NUMBER=57 +MARKETING_VERSION=0.23 +BUILD_NUMBER=58 From d640a0fc765bbcb745b7bee349574429b01540c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 19:01:56 +0100 Subject: [PATCH 0225/1239] fix: clean up cached CLI probe processes --- CHANGELOG.md | 3 +++ .../CodexBarCore/Host/PTY/TTYCommandRunner.swift | 13 +++++++++++++ .../Providers/Claude/ClaudeCLISession.swift | 13 +++++++++++++ .../Providers/Codex/CodexCLISession.swift | 13 +++++++++++++ Tests/CodexBarTests/TTYCommandRunnerTests.swift | 13 +++++++++++++ 5 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a017183ca..1b9c8893ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changes - Development ongoing. +### Fixes +- Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. + ## 0.22 — 2026-04-21 ### Highlights diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index d55bff859f..7608ef11b2 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -175,6 +175,19 @@ public struct TTYCommandRunner { } } + @discardableResult + static func registerActiveProcessForAppShutdown(pid: pid_t, binary: String) -> Bool { + TTYCommandRunnerActiveProcessRegistry.register(pid: pid, binary: binary) + } + + static func updateActiveProcessGroupForAppShutdown(pid: pid_t, processGroup: pid_t?) { + TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: processGroup) + } + + static func unregisterActiveProcessForAppShutdown(pid: pid_t) { + TTYCommandRunnerActiveProcessRegistry.unregister(pid: pid) + } + private static func resolveShutdownTargets( _ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)], hostProcessGroup: pid_t, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index 9ae1bc052f..b96908e11f 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -298,9 +298,21 @@ actor ClaudeCLISession { } let pid = proc.processIdentifier + guard TTYCommandRunner.registerActiveProcessForAppShutdown( + pid: pid, + binary: URL(fileURLWithPath: binary).lastPathComponent) + else { + proc.terminate() + kill(pid, SIGKILL) + try? primaryHandle.close() + try? secondaryHandle.close() + throw SessionError.launchFailed("App shutdown in progress") + } + var processGroup: pid_t? if setpgid(pid, pid) == 0 { processGroup = pid + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: processGroup) } self.process = proc @@ -354,6 +366,7 @@ actor ClaudeCLISession { } kill(proc.processIdentifier, SIGKILL) } + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier) } self.process = nil diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift index 8a6c811e8f..2238fc0b2a 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift @@ -300,9 +300,21 @@ actor CodexCLISession { } let pid = proc.processIdentifier + guard TTYCommandRunner.registerActiveProcessForAppShutdown( + pid: pid, + binary: resolvedURL.lastPathComponent) + else { + proc.terminate() + kill(pid, SIGKILL) + try? primaryHandle.close() + try? secondaryHandle.close() + throw SessionError.launchFailed("App shutdown in progress") + } + var processGroup: pid_t? if setpgid(pid, pid) == 0 { processGroup = pid + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: processGroup) } self.process = proc @@ -341,6 +353,7 @@ actor CodexCLISession { } kill(proc.processIdentifier, SIGKILL) } + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier) } self.process = nil diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index 78c6419ea0..2bfcebc853 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -35,6 +35,19 @@ struct TTYCommandRunnerEnvTests { #expect(TTYCommandRunner._test_trackedProcessCount() == 0) } + @Test + func `cached CLI sessions share shutdown tracking`() { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } + + #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex")) + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 1) + + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } + @Test func `tracked process helpers ignore invalid PID`() { TTYCommandRunner._test_resetTrackedProcesses() From d4cb96778edda6078cdbf077ed56d98550d9a9da Mon Sep 17 00:00:00 2001 From: Chenhao Yang Date: Tue, 21 Apr 2026 18:43:01 -0500 Subject: [PATCH 0226/1239] Fix widget App Intents packaging --- Scripts/package_app.sh | 81 +++++++++++++++++++ .../CodexBarWidgetProvider.swift | 28 ++++--- .../CodexBarWidgetProviderTests.swift | 11 +++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 9e62c83dc9..ac8842dab9 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -98,6 +98,86 @@ path.write_text(text) PY } +generate_widget_appintents_metadata() { + local widget_resources_dir="$1" + local xcode_conf + local host_arch + local derived_dir + local build_dir + local object_dir + local source_file_list + local const_values_list + local dependency_metadata + local static_dependency_metadata + local appintents_tool + local sdk_root + local swiftc_path + local toolchain_dir + local xcode_version + + xcode_conf="Release" + if [[ "$LOWER_CONF" == "debug" ]]; then + xcode_conf="Debug" + fi + + host_arch=$(uname -m) + derived_dir="$ROOT/.build/xcode-widget-metadata-${LOWER_CONF}" + build_dir="$derived_dir/Build/Intermediates.noindex/CodexBar.build/${xcode_conf}/CodexBarWidget.build" + object_dir="$build_dir/Objects-normal/${host_arch}" + source_file_list="$object_dir/CodexBarWidget.SwiftFileList" + const_values_list="$object_dir/CodexBarWidget.SwiftConstValuesFileList" + dependency_metadata="$build_dir/CodexBarWidget.DependencyMetadataFileList" + static_dependency_metadata="$build_dir/CodexBarWidget.DependencyStaticMetadataFileList" + + appintents_tool=$(xcrun --find appintentsmetadataprocessor) + sdk_root=$(xcrun --sdk macosx --show-sdk-path) + swiftc_path=$(xcrun --find swiftc) + toolchain_dir=$(dirname "$(dirname "$(dirname "$swiftc_path")")") + xcode_version=$(xcodebuild -version | awk '/Build version/ { print $3 }') + + rm -rf "$derived_dir" + xcodebuild \ + -workspace "$ROOT/.swiftpm/xcode/package.xcworkspace" \ + -scheme CodexBarWidget \ + -configuration "$xcode_conf" \ + -destination "platform=macOS,arch=${host_arch}" \ + -derivedDataPath "$derived_dir" \ + build >/dev/null + + if [[ ! -f "$source_file_list" ]]; then + echo "ERROR: Missing App Intents metadata inputs for CodexBarWidget." >&2 + exit 1 + fi + + find "$object_dir" -name '*.swiftconstvalues' | sort > "$const_values_list" + if [[ ! -s "$const_values_list" ]]; then + echo "ERROR: Missing App Intents const-values outputs for CodexBarWidget." >&2 + exit 1 + fi + rm -rf "$widget_resources_dir/Metadata.appintents" + mkdir -p "$widget_resources_dir" + + "$appintents_tool" \ + --output "$widget_resources_dir" \ + --toolchain-dir "$toolchain_dir" \ + --module-name CodexBarWidget \ + --sdk-root "$sdk_root" \ + --xcode-version "$xcode_version" \ + --platform-family macOS \ + --deployment-target 14.0 \ + --target-triple "${host_arch}-apple-macos14.0" \ + --source-file-list "$source_file_list" \ + --swift-const-vals-list "$const_values_list" \ + --metadata-file-list "$dependency_metadata" \ + --static-metadata-file-list "$static_dependency_metadata" \ + --force >/dev/null + + if [[ ! -f "$widget_resources_dir/Metadata.appintents/extract.actionsdata" ]]; then + echo "ERROR: Failed to generate App Intents metadata for CodexBarWidget." >&2 + exit 1 + fi +} + KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then swift build -c "$CONF" --arch "${ARCH_LIST[0]}" @@ -304,6 +384,7 @@ if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then PLIST install_binary "CodexBarWidget" "$WIDGET_APP/Contents/MacOS/CodexBarWidget" + generate_widget_appintents_metadata "$WIDGET_APP/Contents/Resources" fi # Embed Sparkle.framework if [[ -d ".build/$CONF/Sparkle.framework" ]]; then diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index f5f5187e3f..b7a44dace8 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -100,7 +100,7 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { static let description = IntentDescription("Select the provider to display in the widget.") @Parameter(title: "Provider") - var provider: ProviderChoice + var provider: ProviderChoice? init() { self.provider = .codex @@ -132,10 +132,10 @@ struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { static let description = IntentDescription("Select the provider and metric to display.") @Parameter(title: "Provider") - var provider: ProviderChoice + var provider: ProviderChoice? @Parameter(title: "Metric") - var metric: CompactMetric + var metric: CompactMetric? init() { self.provider = .codex @@ -149,6 +149,14 @@ struct CodexBarWidgetEntry: TimelineEntry { let snapshot: WidgetSnapshot } +func resolvedWidgetProvider(_ choice: ProviderChoice?) -> UsageProvider { + choice?.provider ?? .codex +} + +func resolvedCompactMetric(_ metric: CompactMetric?) -> CompactMetric { + metric ?? .credits +} + struct CodexBarCompactEntry: TimelineEntry { let date: Date let provider: UsageProvider @@ -172,7 +180,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { } func snapshot(for configuration: ProviderSelectionIntent, in context: Context) async -> CodexBarWidgetEntry { - let provider = configuration.provider.provider + let provider = resolvedWidgetProvider(configuration.provider) return CodexBarWidgetEntry( date: Date(), provider: provider, @@ -183,7 +191,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { for configuration: ProviderSelectionIntent, in context: Context) async -> Timeline { - let provider = configuration.provider.provider + let provider = resolvedWidgetProvider(configuration.provider) let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) @@ -249,11 +257,12 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry { - let provider = configuration.provider.provider + let provider = resolvedWidgetProvider(configuration.provider) + let metric = resolvedCompactMetric(configuration.metric) return CodexBarCompactEntry( date: Date(), provider: provider, - metric: configuration.metric, + metric: metric, snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()) } @@ -261,12 +270,13 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { for configuration: CompactMetricSelectionIntent, in context: Context) async -> Timeline { - let provider = configuration.provider.provider + let provider = resolvedWidgetProvider(configuration.provider) + let metric = resolvedCompactMetric(configuration.metric) let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), provider: provider, - metric: configuration.metric, + metric: metric, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 56c3768112..e5039f217d 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -104,4 +104,15 @@ struct CodexBarWidgetProviderTests { #expect(rows == [WidgetUsageRow(id: "weekly", title: "Weekly", percentLeft: 75)]) } + + @Test + func `widget provider resolution falls back to codex when provider intent is nil`() { + #expect(resolvedWidgetProvider(nil) == UsageProvider.codex) + } + + @Test + func `compact widget resolution falls back to defaults when intent values are nil`() { + #expect(resolvedWidgetProvider(nil) == UsageProvider.codex) + #expect(resolvedCompactMetric(nil) == CompactMetric.credits) + } } From 592cfd56a57971ed77fe2decfba49deb0f0ec1e3 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Wed, 22 Apr 2026 09:17:58 +0300 Subject: [PATCH 0227/1239] fix(widget): use app intent parameter defaults --- .../CodexBarWidgetProvider.swift | 32 +++++++------------ .../CodexBarWidgetProviderTests.swift | 13 ++++---- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index b7a44dace8..01075cabea 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -99,8 +99,8 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Provider" static let description = IntentDescription("Select the provider to display in the widget.") - @Parameter(title: "Provider") - var provider: ProviderChoice? + @Parameter(title: "Provider", default: .codex) + var provider: ProviderChoice init() { self.provider = .codex @@ -131,11 +131,11 @@ struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Provider + Metric" static let description = IntentDescription("Select the provider and metric to display.") - @Parameter(title: "Provider") - var provider: ProviderChoice? + @Parameter(title: "Provider", default: .codex) + var provider: ProviderChoice - @Parameter(title: "Metric") - var metric: CompactMetric? + @Parameter(title: "Metric", default: .credits) + var metric: CompactMetric init() { self.provider = .codex @@ -149,14 +149,6 @@ struct CodexBarWidgetEntry: TimelineEntry { let snapshot: WidgetSnapshot } -func resolvedWidgetProvider(_ choice: ProviderChoice?) -> UsageProvider { - choice?.provider ?? .codex -} - -func resolvedCompactMetric(_ metric: CompactMetric?) -> CompactMetric { - metric ?? .credits -} - struct CodexBarCompactEntry: TimelineEntry { let date: Date let provider: UsageProvider @@ -180,7 +172,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { } func snapshot(for configuration: ProviderSelectionIntent, in context: Context) async -> CodexBarWidgetEntry { - let provider = resolvedWidgetProvider(configuration.provider) + let provider = configuration.provider.provider return CodexBarWidgetEntry( date: Date(), provider: provider, @@ -191,7 +183,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { for configuration: ProviderSelectionIntent, in context: Context) async -> Timeline { - let provider = resolvedWidgetProvider(configuration.provider) + let provider = configuration.provider.provider let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) @@ -257,8 +249,8 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry { - let provider = resolvedWidgetProvider(configuration.provider) - let metric = resolvedCompactMetric(configuration.metric) + let provider = configuration.provider.provider + let metric = configuration.metric return CodexBarCompactEntry( date: Date(), provider: provider, @@ -270,8 +262,8 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { for configuration: CompactMetricSelectionIntent, in context: Context) async -> Timeline { - let provider = resolvedWidgetProvider(configuration.provider) - let metric = resolvedCompactMetric(configuration.metric) + let provider = configuration.provider.provider + let metric = configuration.metric let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index e5039f217d..e951d59dbd 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -106,13 +106,12 @@ struct CodexBarWidgetProviderTests { } @Test - func `widget provider resolution falls back to codex when provider intent is nil`() { - #expect(resolvedWidgetProvider(nil) == UsageProvider.codex) - } + func `widget configuration intents default to codex and credits`() { + let providerIntent = ProviderSelectionIntent() + let compactIntent = CompactMetricSelectionIntent() - @Test - func `compact widget resolution falls back to defaults when intent values are nil`() { - #expect(resolvedWidgetProvider(nil) == UsageProvider.codex) - #expect(resolvedCompactMetric(nil) == CompactMetric.credits) + #expect(providerIntent.provider == .codex) + #expect(compactIntent.provider == .codex) + #expect(compactIntent.metric == .credits) } } From 88dee3cbf36ee0ed3bacb6de6af67876b04be651 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Wed, 22 Apr 2026 09:27:50 +0300 Subject: [PATCH 0228/1239] docs(changelog): credit widget metadata fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b9c8893ec..e82ccd8643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixes - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. +- Widgets: package `Metadata.appintents` for the widget extension and use App Intent parameter defaults so configurable widgets load correctly in WidgetKit. Thanks @vincentyangch! ## 0.22 — 2026-04-21 From 06e732e29fa14799f90400250ef2c5da25c0ddce Mon Sep 17 00:00:00 2001 From: AISupplyGuy Date: Wed, 22 Apr 2026 14:22:02 -0700 Subject: [PATCH 0229/1239] Add Claude Design and Routines usage bars (#740) * Add Claude Design and Routines usage bars * Polish Claude labels and OpenAI cookie access errors * Prefer populated alias over null in OAuth extra usage decoding Per Codex review on PR #1: decodeWindowWithSource returned on the first matching key in the payload even when its value was null, so a response with seven_day_design: null followed by seven_day_omelette: {...} picked the null alias and rendered a 0% bar. Scan all aliases, prefer a populated one, and fall back to the first null-valued key only to keep the bar visible when the API returns a known key with null payload. * Fix Claude usage lint cleanup * Drop OpenAI cookie import changes * Place Claude Sonnet bar before extras --------- Co-authored-by: Ratul Sarna --- Sources/CodexBar/MenuCardView.swift | 45 +++++-- .../ClaudeOAuth/ClaudeOAuthUsageFetcher.swift | 95 ++++++++++++-- .../Claude/ClaudeProviderDescriptor.swift | 1 + .../Providers/Claude/ClaudeUsageFetcher.swift | 61 ++++++++- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 38 ++++-- .../ClaudeWebExtraRateWindowParser.swift | 118 ++++++++++++++++++ Sources/CodexBarCore/UsageFetcher.swift | 19 +++ Tests/CodexBarTests/ClaudeOAuthTests.swift | 61 +++++++++ .../ClaudeWebUsageExtraWindowTests.swift | 48 +++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 68 ++++++++++ 10 files changed, 524 insertions(+), 30 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift create mode 100644 Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 6356c51a6c..18b77df05d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -965,18 +965,6 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, zaiTimeDetail: zaiTimeDetail)) } - if input.provider == .kilo, - metrics.contains(where: { $0.id == "primary" }), - metrics.contains(where: { $0.id == "secondary" }) - { - metrics.sort { lhs, rhs in - let kiloOrder: [String: Int] = [ - "secondary": 0, - "primary": 1, - ] - return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max) - } - } if input.metadata.supportsOpus, let opus = snapshot.tertiary { var tertiaryDetailText: String? if input.provider == .alibaba, @@ -1004,6 +992,39 @@ extension UsageMenuCardView.Model { pacePercent: nil, paceOnTop: true)) } + if let extraRateWindows = snapshot.extraRateWindows { + metrics.append(contentsOf: extraRateWindows.map { namedWindow in + Metric( + id: namedWindow.id, + title: namedWindow.title, + percent: Self.clamped( + input.usageBarsShowUsed + ? namedWindow.window.usedPercent + : namedWindow.window.remainingPercent), + percentStyle: percentStyle, + resetText: Self.resetText( + for: namedWindow.window, + style: input.resetTimeDisplayStyle, + now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + }) + } + if input.provider == .kilo, + metrics.contains(where: { $0.id == "primary" }), + metrics.contains(where: { $0.id == "secondary" }) + { + metrics.sort { lhs, rhs in + let kiloOrder: [String: Int] = [ + "secondary": 0, + "primary": 1, + ] + return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max) + } + } if let codexProjection = input.codexProjection, codexProjection.supplementalMetrics.contains(.codeReview), diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 22003fb237..33e8677e7d 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -114,17 +114,96 @@ struct OAuthUsageResponse: Decodable { let sevenDayOAuthApps: OAuthUsageWindow? let sevenDayOpus: OAuthUsageWindow? let sevenDaySonnet: OAuthUsageWindow? + let sevenDayDesign: OAuthUsageWindow? + let sevenDayRoutines: OAuthUsageWindow? + let sevenDayDesignSourceKey: String? + let sevenDayRoutinesSourceKey: String? let iguanaNecktie: OAuthUsageWindow? let extraUsage: OAuthExtraUsage? - enum CodingKeys: String, CodingKey { - case fiveHour = "five_hour" - case sevenDay = "seven_day" - case sevenDayOAuthApps = "seven_day_oauth_apps" - case sevenDayOpus = "seven_day_opus" - case sevenDaySonnet = "seven_day_sonnet" - case iguanaNecktie = "iguana_necktie" - case extraUsage = "extra_usage" + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.fiveHour = Self.decodeWindow(in: container, keys: ["five_hour"]) + self.sevenDay = Self.decodeWindow(in: container, keys: ["seven_day"]) + self.sevenDayOAuthApps = Self.decodeWindow(in: container, keys: ["seven_day_oauth_apps"]) + self.sevenDayOpus = Self.decodeWindow(in: container, keys: ["seven_day_opus"]) + self.sevenDaySonnet = Self.decodeWindow(in: container, keys: ["seven_day_sonnet"]) + let design = Self.decodeWindowWithSource(in: container, keys: [ + "seven_day_design", + "seven_day_claude_design", + "claude_design", + "design", + "seven_day_omelette", + "omelette", + "omelette_promotional", + ]) + self.sevenDayDesign = design.window + self.sevenDayDesignSourceKey = design.sourceKey + let routines = Self.decodeWindowWithSource(in: container, keys: [ + "seven_day_routines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "cowork", + ]) + self.sevenDayRoutines = routines.window + self.sevenDayRoutinesSourceKey = routines.sourceKey + self.iguanaNecktie = Self.decodeWindow(in: container, keys: ["iguana_necktie"]) + self.extraUsage = Self.decodeValue(in: container, keys: ["extra_usage"]) + } + + private static func decodeWindow( + in container: KeyedDecodingContainer, + keys: [String]) -> OAuthUsageWindow? + { + self.decodeValue(in: container, keys: keys) + } + + private static func decodeWindowWithSource( + in container: KeyedDecodingContainer, + keys: [String]) -> (window: OAuthUsageWindow?, sourceKey: String?) + { + var firstNullKey: String? + for keyName in keys { + guard let key = DynamicCodingKey(stringValue: keyName) else { continue } + guard container.contains(key) else { continue } + if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) { + return (value, keyName) + } + if firstNullKey == nil { + firstNullKey = keyName + } + } + return (nil, firstNullKey) + } + + private static func decodeValue( + in container: KeyedDecodingContainer, + keys: [String]) -> T? + { + for keyName in keys { + guard let key = DynamicCodingKey(stringValue: keyName) else { continue } + if let value = try? container.decodeIfPresent(T.self, forKey: key) { + return value + } + } + return nil + } +} + +private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + nil } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 50a54a301b..20db613124 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -303,6 +303,7 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { primary: usage.primary, secondary: usage.secondary, tertiary: usage.opus, + extraRateWindows: usage.extraRateWindows.isEmpty ? nil : usage.extraRateWindows, providerCost: usage.providerCost, updatedAt: usage.updatedAt, identity: identity) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index dd4848a537..82d9198659 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -10,6 +10,7 @@ public struct ClaudeUsageSnapshot: Sendable { public let primary: RateWindow public let secondary: RateWindow? public let opus: RateWindow? + public let extraRateWindows: [NamedRateWindow] public let providerCost: ProviderCostSnapshot? public let updatedAt: Date public let accountEmail: String? @@ -21,6 +22,7 @@ public struct ClaudeUsageSnapshot: Sendable { primary: RateWindow, secondary: RateWindow?, opus: RateWindow?, + extraRateWindows: [NamedRateWindow] = [], providerCost: ProviderCostSnapshot? = nil, updatedAt: Date, accountEmail: String?, @@ -31,6 +33,7 @@ public struct ClaudeUsageSnapshot: Sendable { self.primary = primary self.secondary = secondary self.opus = opus + self.extraRateWindows = extraRateWindows self.providerCost = providerCost self.updatedAt = updatedAt self.accountEmail = accountEmail @@ -841,6 +844,7 @@ extension ClaudeUsageFetcher { let modelSpecific = makeWindow( usage.sevenDaySonnet ?? usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) + let extraRateWindows = Self.oauthExtraRateWindows(from: usage) let loginMethod = ClaudePlan.oauthLoginMethod(rateLimitTier: credentials.rateLimitTier) let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod) @@ -849,6 +853,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: weekly, opus: modelSpecific, + extraRateWindows: extraRateWindows, providerCost: providerCost, updatedAt: Date(), accountEmail: nil, @@ -887,6 +892,50 @@ extension ClaudeUsageFetcher { (used: used / 100.0, limit: limit / 100.0) } + private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { + let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [ + ( + id: "claude-design", + title: "Designs", + window: usage.sevenDayDesign, + sourceKey: usage.sevenDayDesignSourceKey), + ( + id: "claude-routines", + title: "Daily Routines", + window: usage.sevenDayRoutines, + sourceKey: usage.sevenDayRoutinesSourceKey), + ] + if let designKey = usage.sevenDayDesignSourceKey { + Self.log.debug("Claude OAuth extra usage key matched: design=\(designKey)") + } + if let routinesKey = usage.sevenDayRoutinesSourceKey { + Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)") + } + return definitions.compactMap { definition in + let utilization: Double + let resetDate: Date? + if let window = definition.window, let parsedUtilization = window.utilization { + utilization = parsedUtilization + resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) + } else if definition.sourceKey != nil { + // Keep product bars visible when the API returns a known key with null payload. + utilization = 0 + resetDate = nil + } else { + return nil + } + let resetDescription = resetDate.map(Self.formatResetDate) + return NamedRateWindow( + id: definition.id, + title: definition.title, + window: RateWindow( + usedPercent: utilization, + windowMinutes: Self.weeklyWindowMinutes, + resetsAt: resetDate, + resetDescription: resetDescription)) + } + } + // MARK: - Web API path (uses browser cookies) private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot { @@ -927,6 +976,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: secondary, opus: opus, + extraRateWindows: webData.extraRateWindows, providerCost: webData.extraUsageCost, updatedAt: Date(), accountEmail: webData.accountEmail, @@ -986,6 +1036,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: weekly, opus: opus, + extraRateWindows: [], providerCost: nil, updatedAt: Date(), accountEmail: snap.accountEmail, @@ -1009,13 +1060,17 @@ extension ClaudeUsageFetcher { Self.log.debug(msg) } } - // Only merge cost extras; keep identity fields from the primary data source. - if snapshot.providerCost == nil, let extra = webData.extraUsageCost { + // Only merge usage/cost extras; keep identity fields from the primary data source. + let mergedExtraRateWindows = snapshot.extraRateWindows.isEmpty ? webData.extraRateWindows : snapshot + .extraRateWindows + let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost + if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { return ClaudeUsageSnapshot( primary: snapshot.primary, secondary: snapshot.secondary, opus: snapshot.opus, - providerCost: extra, + extraRateWindows: mergedExtraRateWindows, + providerCost: mergedProviderCost, updatedAt: snapshot.updatedAt, accountEmail: snapshot.accountEmail, accountOrganization: snapshot.accountOrganization, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index c350d30c49..6fb3f41eaa 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -84,6 +84,7 @@ public enum ClaudeWebAPIFetcher { public let weeklyPercentUsed: Double? public let weeklyResetsAt: Date? public let opusPercentUsed: Double? + public let extraRateWindows: [NamedRateWindow] public let extraUsageCost: ProviderCostSnapshot? public let accountOrganization: String? public let accountEmail: String? @@ -95,6 +96,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: Double?, weeklyResetsAt: Date?, opusPercentUsed: Double?, + extraRateWindows: [NamedRateWindow], extraUsageCost: ProviderCostSnapshot?, accountOrganization: String?, accountEmail: String?, @@ -105,6 +107,7 @@ public enum ClaudeWebAPIFetcher { self.weeklyPercentUsed = weeklyPercentUsed self.weeklyResetsAt = weeklyResetsAt self.opusPercentUsed = opusPercentUsed + self.extraRateWindows = extraRateWindows self.extraUsageCost = extraUsageCost self.accountOrganization = accountOrganization self.accountEmail = accountEmail @@ -195,6 +198,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: extra, accountOrganization: usage.accountOrganization, accountEmail: usage.accountEmail, @@ -207,6 +211,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: usage.extraUsageCost, accountOrganization: usage.accountOrganization, accountEmail: account.email, @@ -219,6 +224,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: usage.extraUsageCost, accountOrganization: name, accountEmail: usage.accountEmail, @@ -439,7 +445,7 @@ public enum ClaudeWebAPIFetcher { switch httpResponse.statusCode { case 200: - return try self.parseUsageResponse(data) + return try self.parseUsageResponse(data, logger: logger) case 401, 403: throw FetchError.unauthorized default: @@ -447,7 +453,7 @@ public enum ClaudeWebAPIFetcher { } } - private static func parseUsageResponse(_ data: Data) throws -> WebUsageData { + private static func parseUsageResponse(_ data: Data, logger: ((String) -> Void)? = nil) throws -> WebUsageData { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw FetchError.invalidResponse } @@ -480,12 +486,19 @@ public enum ClaudeWebAPIFetcher { } } - // Parse seven_day_opus (Opus-specific weekly) usage + // Parse seven_day_sonnet (preferred) / seven_day_opus usage var opusPercent: Double? - if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] { - if let utilization = sevenDayOpus["utilization"] as? Int { - opusPercent = Double(utilization) - } + if let sevenDaySonnet = json["seven_day_sonnet"] as? [String: Any] { + opusPercent = Self.percentValue(from: sevenDaySonnet["utilization"]) + } else if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] { + opusPercent = Self.percentValue(from: sevenDayOpus["utilization"]) + } + let extraRateParse = ClaudeWebExtraRateWindowParser.parse(from: json) + if let sourceKey = extraRateParse.sourceKeys["claude-design"] { + logger?("Usage API extra window key matched: design=\(sourceKey)") + } + if let sourceKey = extraRateParse.sourceKeys["claude-routines"] { + logger?("Usage API extra window key matched: routines=\(sourceKey)") } return WebUsageData( @@ -494,12 +507,23 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: weeklyPercent, weeklyResetsAt: weeklyResets, opusPercentUsed: opusPercent, + extraRateWindows: extraRateParse.windows, extraUsageCost: nil, accountOrganization: nil, accountEmail: nil, loginMethod: nil) } + private static func percentValue(from value: Any?) -> Double? { + if let intValue = value as? Int { + return Double(intValue) + } + if let doubleValue = value as? Double { + return doubleValue + } + return nil + } + // MARK: - Extra usage cost (Claude "Extra") private struct OverageSpendLimitResponse: Decodable { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift new file mode 100644 index 0000000000..b1df6343cf --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift @@ -0,0 +1,118 @@ +import Foundation + +enum ClaudeWebExtraRateWindowParser { + private static let definitions: [(id: String, title: String, keys: [String])] = [ + ( + id: "claude-design", + title: "Designs", + keys: [ + "seven_day_design", + "seven_day_claude_design", + "claude_design", + "design", + "seven_day_omelette", + "omelette", + "omelette_promotional", + ]), + ( + id: "claude-routines", + title: "Daily Routines", + keys: [ + "seven_day_routines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "cowork", + ]), + ] + + static func parse(from json: [String: Any]) -> (windows: [NamedRateWindow], sourceKeys: [String: String]) { + var windows: [NamedRateWindow] = [] + var sourceKeys: [String: String] = [:] + windows.reserveCapacity(Self.definitions.count) + + for definition in Self.definitions { + if let foundWindow = Self.firstUsageWindow(in: json, keys: definition.keys) { + let rawWindow = foundWindow.window + guard let utilization = Self.percentValue(from: rawWindow["utilization"]) else { continue } + let resetsAt = (rawWindow["resets_at"] as? String).flatMap(Self.parseISO8601Date) + windows.append(Self.namedWindow( + id: definition.id, + title: definition.title, + usedPercent: utilization, + resetsAt: resetsAt)) + sourceKeys[definition.id] = foundWindow.sourceKey + continue + } + + // Some accounts expose the key with null payloads (for example `seven_day_cowork: null`). + // Preserve the bar in that case with a 0% window so the product section remains visible. + if let key = Self.firstUsageKey(in: json, keys: definition.keys) { + windows.append(Self.namedWindow( + id: definition.id, + title: definition.title, + usedPercent: 0, + resetsAt: nil)) + sourceKeys[definition.id] = key + } + } + return (windows, sourceKeys) + } + + private static func namedWindow( + id: String, + title: String, + usedPercent: Double, + resetsAt: Date?) -> NamedRateWindow + { + NamedRateWindow( + id: id, + title: title, + window: RateWindow( + usedPercent: usedPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: resetsAt, + resetDescription: nil)) + } + + private static func firstUsageWindow( + in json: [String: Any], + keys: [String]) -> (window: [String: Any], sourceKey: String)? + { + for key in keys { + if let window = json[key] as? [String: Any] { + return (window, key) + } + } + return nil + } + + private static func firstUsageKey(in json: [String: Any], keys: [String]) -> String? { + for key in keys where json.keys.contains(key) { + return key + } + return nil + } + + private static func percentValue(from value: Any?) -> Double? { + if let intValue = value as? Int { + return Double(intValue) + } + if let doubleValue = value as? Double { + return doubleValue + } + return nil + } + + private static func parseISO8601Date(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 24d413717c..22ebd19c70 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -28,6 +28,18 @@ public struct RateWindow: Codable, Equatable, Sendable { } } +public struct NamedRateWindow: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let window: RateWindow + + public init(id: String, title: String, window: RateWindow) { + self.id = id + self.title = title + self.window = window + } +} + public struct ProviderIdentitySnapshot: Codable, Sendable { public let providerID: UsageProvider? public let accountEmail: String? @@ -60,6 +72,7 @@ public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? @@ -72,6 +85,7 @@ public struct UsageSnapshot: Codable, Sendable { case primary case secondary case tertiary + case extraRateWindows case providerCost case openRouterUsage case updatedAt @@ -85,6 +99,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, + extraRateWindows: [NamedRateWindow]? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, @@ -96,6 +111,7 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.extraRateWindows = extraRateWindows self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage @@ -110,6 +126,7 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary) self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) + self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time @@ -140,6 +157,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.primary, forKey: .primary) try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) + try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encode(self.updatedAt, forKey: .updatedAt) @@ -224,6 +242,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: self.primary, secondary: self.secondary, tertiary: self.tertiary, + extraRateWindows: self.extraRateWindows, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index cb3541bc15..d62d742075 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -81,6 +81,67 @@ struct ClaudeOAuthTests { #expect(snap.loginMethod == "Claude Pro") } + @Test + func `maps O auth design and routines usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_design": { "utilization": 44, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_routines": { "utilization": 18, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.count == 2) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.title == "Designs") + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 44) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.title == "Daily Routines") + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 18) + } + + @Test + func `maps O auth omelette and cowork usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_cowork": { "utilization": 9, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.count == 2) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 29) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 9) + } + + @Test + func `maps O auth null cowork as zero routines window`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_cowork": null + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + } + + @Test + func `prefers populated alias over null alias in mixed payload`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_design": null, + "seven_day_omelette": { "utilization": 37, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_routines": null, + "seven_day_cowork": { "utilization": 14, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 37) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 14) + } + @Test func `maps O auth extra usage`() throws { // OAuth API returns values in cents (minor units), same as Web API. diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift new file mode 100644 index 0000000000..324332297b --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeWebUsageExtraWindowTests { + @Test + func `parses claude web API sonnet usage response`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 6, "resets_at": "2025-12-30T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.opusPercentUsed == 6) + } + + @Test + func `parses claude web API omelette and cowork usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": { "utilization": 11, "resets_at": "2025-12-31T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.count == 2) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 26) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11) + } + + @Test + func `parses claude web API cowork null as zero routines window`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": null + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index b4e3b7522c..ee8fce91ae 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -124,6 +124,74 @@ struct MenuCardModelTests { #expect(model.planText == "Max") } + @Test + func `claude model includes design and routines bars when present`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 8, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 16, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7800), + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "claude-design", + title: "Designs", + window: RateWindow( + usedPercent: 31, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(8200), + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 7, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(9200), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "plus"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Designs", "Daily Routines"]) + } + @Test func `shows error subtitle when present`() throws { let metadata = try #require(ProviderDefaults.metadata[.codex]) From a5100c3f4ea30a17b2ae3928cdb01e844b136226 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 23 Apr 2026 11:39:41 +0300 Subject: [PATCH 0230/1239] fix(ui): align menu width (#784) * fix(ui): align menu card width with menu accessories * fix(scripts): detect signing identities correctly * fix(menu): prevent wrapped status text from widening menu * fix: fail on ambiguous signing identities and rebuild stale-width menus * fix(menu): remove reserved accessory gutter * fix(scripts): revert signing identity changes * fix(menu): align hosted submenus with parent width --- .../StatusItemController+HostedSubmenus.swift | 4 +- .../CodexBar/StatusItemController+Menu.swift | 119 ++++++++++++------ ...tatusItemController+UsageHistoryMenu.swift | 3 +- .../StatusItemControllerMenuTests.swift | 16 +++ Tests/CodexBarTests/StatusMenuTests.swift | 114 ++++++++++++++++- 5 files changed, 214 insertions(+), 42 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 1676db9b19..c716636b27 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -3,8 +3,6 @@ import CodexBarCore import SwiftUI extension StatusItemController { - private static let hostedSubviewWidth: CGFloat = 310 - func makeHostedSubviewPlaceholderMenu(chartID: String, provider: UsageProvider? = nil) -> NSMenu { let submenu = NSMenu() submenu.delegate = self @@ -25,7 +23,7 @@ extension StatusItemController { return } - let width = Self.hostedSubviewWidth + let width = self.renderedMenuWidth(for: menu.supermenu ?? menu) menu.removeAllItems() let didHydrate: Bool = switch chartID { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 88cb625a51..48a295e5e2 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -29,9 +29,25 @@ extension StatusItemController { } } - private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { - _ = menu - return Self.menuCardBaseWidth + private func menuCardWidth( + for providers: [UsageProvider], + sections: [MenuDescriptor.Section]) -> CGFloat + { + _ = providers + let baselineWidth = Self.menuCardBaseWidth + return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) + } + + private func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { + let measuringMenu = NSMenu() + measuringMenu.autoenablesItems = false + self.addActionableSections(sections, to: measuringMenu, width: baseWidth) + return ceil(measuringMenu.size.width) + } + + func renderedMenuWidth(for menu: NSMenu) -> CGFloat { + let measuredWidth = ceil(menu.size.width) + return max(measuredWidth, Self.menuCardBaseWidth) } func makeMenu() -> NSMenu { @@ -127,7 +143,6 @@ extension StatusItemController { } else { switcherSelection?.provider ?? provider } - let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu) let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex let codexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider) let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider) @@ -135,6 +150,16 @@ extension StatusItemController { let openAIContext = self.openAIWebContext( currentProvider: currentProvider, showAllTokenAccounts: showAllTokenAccounts) + let descriptor = MenuDescriptor.build( + provider: selectedProvider, + store: self.store, + settings: self.settings, + account: self.account, + managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + updateReady: self.updater.updateStatus.isUpdateReady, + includeContextualActions: !isOverviewSelected) + let menuWidth = self.menuCardWidth(for: enabledProviders, sections: descriptor.sections) let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } @@ -145,6 +170,10 @@ extension StatusItemController { let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && ((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher)) + let reusableRowWidthsMatch = self.reusableFixedWidthRows(in: menu).allSatisfy { item in + guard let view = item.view else { return false } + return abs(view.frame.width - menuWidth) <= 0.5 + } let canSmartUpdate = self.shouldMergeIcons && enabledProviders.count > 1 && !isOverviewSelected && @@ -154,6 +183,7 @@ extension StatusItemController { switcherOverviewAvailabilityMatches && tokenSwitcherCompatible && codexSwitcherCompatible && + reusableRowWidthsMatch && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView @@ -168,22 +198,12 @@ extension StatusItemController { } menu.removeAllItems() - - let descriptor = MenuDescriptor.build( - provider: selectedProvider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, - includeContextualActions: !isOverviewSelected) - self.addProviderSwitcherIfNeeded( to: menu, enabledProviders: enabledProviders, includesOverview: includesOverview, - selection: switcherSelection ?? .provider(currentProvider)) + selection: switcherSelection ?? .provider(currentProvider), + width: menuWidth) // Track which providers the switcher was built with for smart update detection if self.shouldMergeIcons, enabledProviders.count > 1 { self.lastSwitcherProviders = enabledProviders @@ -191,9 +211,9 @@ extension StatusItemController { self.lastMergedSwitcherSelection = switcherSelection self.lastSwitcherIncludesOverview = includesOverview } - self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay) + self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay, width: menuWidth) self.lastCodexAccountMenuDisplay = codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) + self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay, width: menuWidth) let menuContext = MenuCardContext( currentProvider: currentProvider, selectedProvider: selectedProvider, @@ -218,13 +238,36 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) { menu.addItem(.separator()) } } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) } + private func reusableFixedWidthRows(in menu: NSMenu) -> [NSMenuItem] { + guard !menu.items.isEmpty else { return [] } + + var reusableRows: [NSMenuItem] = [] + var index = 0 + if menu.items.first?.view is ProviderSwitcherView { + reusableRows.append(menu.items[0]) + index = 2 + } + if menu.items.count > index, + menu.items[index].view is CodexAccountSwitcherView + { + reusableRows.append(menu.items[index]) + index += 2 + } + if menu.items.count > index, + menu.items[index].view is TokenAccountSwitcherView + { + reusableRows.append(menu.items[index]) + } + return reusableRows + } + /// Smart update: only rebuild content sections when switching providers (keep the switcher intact). private func updateMenuContent( _ menu: NSMenu, @@ -277,7 +320,7 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) { menu.addItem(.separator()) } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) @@ -326,28 +369,30 @@ extension StatusItemController { to menu: NSMenu, enabledProviders: [UsageProvider], includesOverview: Bool, - selection: ProviderSwitcherSelection) + selection: ProviderSwitcherSelection, + width: CGFloat) { guard self.shouldMergeIcons, enabledProviders.count > 1 else { return } let switcherItem = self.makeProviderSwitcherItem( providers: enabledProviders, includesOverview: includesOverview, selected: selection, - menu: menu) + menu: menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } - private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?) { + private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?, width: CGFloat) { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu) + let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu, width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } - private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?) { + private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) { guard let display else { return } - let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu) + let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } @@ -578,7 +623,7 @@ extension StatusItemController { } private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { - let item = NSMenuItem(title: text, action: nil, keyEquivalent: "") + let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") let view = self.makeWrappedSecondaryTextView(text: text) let height = self.menuTextItemHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) @@ -631,13 +676,14 @@ extension StatusItemController { providers: [UsageProvider], includesOverview: Bool, selected: ProviderSwitcherSelection, - menu: NSMenu) -> NSMenuItem + menu: NSMenu, + width: CGFloat) -> NSMenuItem { let view = ProviderSwitcherView( providers: providers, selected: selected, includesOverview: includesOverview, - width: self.menuCardWidth(for: providers, menu: menu), + width: width, showsIcons: self.settings.switcherShowsIcons, iconProvider: { [weak self] provider in self?.switcherIcon(for: provider) ?? NSImage() @@ -672,12 +718,13 @@ extension StatusItemController { private func makeTokenAccountSwitcherItem( display: TokenAccountMenuDisplay, - menu: NSMenu) -> NSMenuItem + menu: NSMenu, + width: CGFloat) -> NSMenuItem { let view = TokenAccountSwitcherView( accounts: display.accounts, selectedIndex: display.activeIndex, - width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), + width: width, onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) @@ -698,12 +745,13 @@ extension StatusItemController { private func makeCodexAccountSwitcherItem( display: CodexAccountMenuDisplay, - menu: NSMenu) -> NSMenuItem + menu: NSMenu, + width: CGFloat) -> NSMenuItem { let view = CodexAccountSwitcherView( accounts: display.accounts, selectedAccountID: display.activeVisibleAccountID, - width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), + width: width, onSelect: { [weak self, weak menu] visibleAccountID in guard let self else { return } self.handleCodexVisibleAccountSelection(visibleAccountID, menu: menu) @@ -928,7 +976,7 @@ extension StatusItemController { } for item in cardItems { guard let view = item.view else { continue } - let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) + let width = self.renderedMenuWidth(for: menu) let height = self.menuCardHeight(for: view, width: width) view.frame = NSRect( origin: .zero, @@ -1287,8 +1335,7 @@ extension StatusItemController { } private func refreshHostedSubviewHeights(in menu: NSMenu) { - let enabledProviders = self.store.enabledProvidersForDisplay() - let width = self.menuCardWidth(for: enabledProviders, menu: menu) + let width = self.renderedMenuWidth(for: menu) for item in menu.items { guard let view = item.view else { continue } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index a2d5b9600b..6cfed42060 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -10,9 +10,8 @@ private final class UsageHistoryMenuHostingView: NSHostingView Bool { + func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false } - let width: CGFloat = 310 let item = self.makeMenuCardItem( HStack(spacing: 0) { Text("Subscription Utilization") diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 2df1afef97..f6e2d28701 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -149,4 +150,19 @@ struct StatusItemControllerMenuTests { snapshot: snapshot)) #expect(snapshot.primary?.usedPercent == 10) } + + @Test + @MainActor + func `menu card width stays at base width when menu accessories are present`() { + let shortcutMenu = NSMenu() + let refreshItem = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "r") + shortcutMenu.addItem(refreshItem) + #expect(ceil(shortcutMenu.size.width) < 310) + + let submenuMenu = NSMenu() + let parentItem = NSMenuItem(title: "Session", action: nil, keyEquivalent: "") + parentItem.submenu = NSMenu(title: "Session") + submenuMenu.addItem(parentItem) + #expect(ceil(submenuMenu.size.width) < 310) + } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index c103bc7a3a..0d0ec05d21 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -348,6 +348,61 @@ struct StatusMenuTests { } } + @Test + func `merged provider switch rebuilds stale width switcher rows`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(initialSwitcher != nil) + let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init) + initialSwitcher?.frame.size.width = 250 + + let nextProviderButton = self.switcherButtons(in: menu).first(where: { $0.state == .off }) + #expect(nextProviderButton != nil) + nextProviderButton?.performClick(nil) + + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(updatedSwitcher != nil) + if let initialSwitcherID, let updatedSwitcher { + #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher)) + #expect(updatedSwitcher.frame.width == 310) + } + } + @Test func `merged switcher includes overview tab when multiple providers enabled`() { self.disableMenuCardsForTesting() @@ -578,7 +633,7 @@ extension StatusMenuTests { let statusItem = menu.items.first(where: { $0.toolTip == statusText }) #expect(statusItem != nil) #expect(statusItem?.view != nil) - #expect(statusItem?.title == statusText) + #expect(statusItem?.title.isEmpty == true) #expect(statusItem?.view?.frame.width == 310) } @@ -716,6 +771,63 @@ extension StatusMenuTests { #expect(!titles.contains("Usage breakdown")) } + @Test + func `hosted chart submenu matches widened parent menu width`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.menuRefreshEnabled = false + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.menuRefreshEnabled = previousMenuRefresh + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let parentMenu = NSMenu() + parentMenu.autoenablesItems = false + let wideItem = NSMenuItem(title: String(repeating: "W", count: 60), action: nil, keyEquivalent: "") + parentMenu.addItem(wideItem) + + let submenu = controller.makeHostedSubviewPlaceholderMenu(chartID: StatusItemController.usageBreakdownChartID) + let submenuItem = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + submenuItem.submenu = submenu + parentMenu.addItem(submenuItem) + + let parentWidth = ceil(parentMenu.size.width) + #expect(parentWidth > 310) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + + let chartItem = submenu.items.first + #expect(chartItem?.representedObject as? String == StatusItemController.usageBreakdownChartID) + #expect(chartItem?.view != nil) + #expect(abs((chartItem?.view?.frame.width ?? 0) - parentWidth) <= 0.5) + } + @Test func `shows open AI web submenus when history exists`() throws { self.disableMenuCardsForTesting() From c6bde6c312df4c564a5f237ef6463e3af2b2aefd Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 23 Apr 2026 12:17:39 +0300 Subject: [PATCH 0231/1239] docs(changelog): note menu width alignment fixes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e82ccd8643..fdae849f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixes - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. +- Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! - Widgets: package `Metadata.appintents` for the widget extension and use App Intent parameter defaults so configurable widgets load correctly in WidgetKit. Thanks @vincentyangch! ## 0.22 — 2026-04-21 From e7d270bd4bf9339ed60bc4a430ec7d3df2643377 Mon Sep 17 00:00:00 2001 From: Amoranio <122813308+amoranio@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:06:14 +0000 Subject: [PATCH 0232/1239] Copilot Provider Update Added clear information of the automatic copy/paste of device codes which maybe missed due to window load. This should make things slightly clearer. --- Sources/CodexBar/PreferencesProviderSettingsRows.swift | 7 +++++++ .../Providers/Copilot/CopilotProviderImplementation.swift | 1 + .../Providers/Shared/ProviderSettingsDescriptors.swift | 1 + 3 files changed, 9 insertions(+) diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 414f41c55e..514c7e3cea 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -196,6 +196,13 @@ struct ProviderSettingsFieldRowView: View { } } } + + if let footer = self.field.footerText, !footer.isEmpty { + Text(footer) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 986d81f2f4..5933593294 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -31,6 +31,7 @@ struct CopilotProviderImplementation: ProviderImplementation { id: "copilot-api-token", title: "GitHub Login", subtitle: "Requires authentication via GitHub Device Flow.", + footerText: "Device code is copied to your clipboard — paste with ⌘V.", kind: .secure, placeholder: "Sign in via button below", binding: context.stringBinding(\.copilotAPIToken), diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index d5a85b8f7a..81b752b9a0 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -76,6 +76,7 @@ struct ProviderSettingsFieldDescriptor: Identifiable { let id: String let title: String let subtitle: String + var footerText: String? = nil let kind: Kind let placeholder: String? let binding: Binding From d8207f20081159cba6fe6e451e3b38aa5124f428 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 23 Apr 2026 21:45:45 +0530 Subject: [PATCH 0233/1239] Clarify Copilot device code hint --- .../Providers/Copilot/CopilotProviderImplementation.swift | 2 +- .../CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 5933593294..6d400417c7 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -31,7 +31,7 @@ struct CopilotProviderImplementation: ProviderImplementation { id: "copilot-api-token", title: "GitHub Login", subtitle: "Requires authentication via GitHub Device Flow.", - footerText: "Device code is copied to your clipboard — paste with ⌘V.", + footerText: "The device code is copied to your clipboard. Paste it into the GitHub page with ⌘V.", kind: .secure, placeholder: "Sign in via button below", binding: context.stringBinding(\.copilotAPIToken), diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 81b752b9a0..7b408d8daa 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -76,7 +76,7 @@ struct ProviderSettingsFieldDescriptor: Identifiable { let id: String let title: String let subtitle: String - var footerText: String? = nil + var footerText: String? let kind: Kind let placeholder: String? let binding: Binding From 73a5b8ffd101cbe354f31e791b580848dd82262d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 23 Apr 2026 21:53:39 +0530 Subject: [PATCH 0234/1239] Update changelog for Claude bars and widget packaging --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdae849f99..a8b99f3136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,12 @@ ## 0.23 — Unreleased ### Changes -- Development ongoing. +- Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! ### Fixes - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. - Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! -- Widgets: package `Metadata.appintents` for the widget extension and use App Intent parameter defaults so configurable widgets load correctly in WidgetKit. Thanks @vincentyangch! +- Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch! ## 0.22 — 2026-04-21 From dab7f7929283727780427dfa201c89c0a32c366f Mon Sep 17 00:00:00 2001 From: Sash Zats Date: Fri, 24 Apr 2026 05:37:07 -0400 Subject: [PATCH 0235/1239] Add weekly reset confetti overlay (#785) --- Package.resolved | 10 +- Package.swift | 2 + Sources/CodexBar/CodexbarApp.swift | 30 ++ Sources/CodexBar/Notifications+CodexBar.swift | 17 + .../CodexBar/PreferencesAdvancedPane.swift | 4 + .../ScreenConfettiOverlayController.swift | 290 ++++++++++++++++++ Sources/CodexBar/SettingsStore+Defaults.swift | 8 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+Actions.swift | 20 ++ Sources/CodexBar/StatusItemController.swift | 7 + .../CodexBar/UsageStore+PlanUtilization.swift | 144 ++++++++- Sources/CodexBar/UsageStore.swift | 2 + .../CodexBarCore/Logging/LogCategories.swift | 1 + Tests/CodexBarTests/SettingsStoreTests.swift | 26 ++ .../UsageStorePlanUtilizationTests.swift | 223 ++++++++++++++ 17 files changed, 786 insertions(+), 3 deletions(-) create mode 100644 Sources/CodexBar/ScreenConfettiOverlayController.swift diff --git a/Package.resolved b/Package.resolved index 4249056442..2dd5f95812 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fc2d77d3435ccf0f5a2d2a8f782cbb3c38264c7542f33838f2409544695bce6e", + "originHash" : "6e0bbde3ad4d9af0981adadaf1f109eb154e54018d06dbe966616f09c3898482", "pins" : [ { "identity" : "commander", @@ -54,6 +54,14 @@ "revision" : "0687f71944021d616d34d922343dcef086855920", "version" : "600.0.1" } + }, + { + "identity" : "vortex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zats/Vortex", + "state" : { + "revision" : "ef5392088d4aeb255c4eee83157dbdafcd31bf07" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 208b857ee6..e8e769f33e 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log", from: "1.12.0"), .package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"), .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"), + .package(url: "https://github.com/zats/Vortex", revision: "ef5392088d4aeb255c4eee83157dbdafcd31bf07"), sweetCookieKitDependency, ], targets: { @@ -82,6 +83,7 @@ let package = Package( dependencies: [ .product(name: "Sparkle", package: "Sparkle"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "Vortex", package: "Vortex"), "CodexBarMacroSupport", "CodexBarCore", ], diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index cb7fb5be44..ccaad69697 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -278,6 +278,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let updaterController: UpdaterProviding = makeUpdaterController() + private let confettiOverlayController = ScreenConfettiOverlayController() + private let confettiLogger = CodexBarLog.logger(LogCategories.confetti) private var statusController: StatusItemControlling? private var store: UsageStore? private var settings: SettingsStore? @@ -285,6 +287,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var preferencesSelection: PreferencesSelection? private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? + private var hasInstalledWeeklyLimitResetObserver = false func configure(_ dependencies: Dependencies) { self.store = dependencies.store @@ -307,12 +310,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.statusController?.openMenuFromShortcut() } } + if !self.hasInstalledWeeklyLimitResetObserver { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleWeeklyLimitResetNotification(_:)), + name: .codexbarWeeklyLimitReset, + object: nil) + self.hasInstalledWeeklyLimitResetObserver = true + } } func applicationWillTerminate(_ notification: Notification) { + self.confettiOverlayController.dismiss() TTYCommandRunner.terminateActiveProcessesForAppShutdown() } + @objc private func handleWeeklyLimitResetNotification(_ notification: Notification) { + guard let event = notification.object as? WeeklyLimitResetEvent else { return } + guard self.settings?.confettiOnWeeklyLimitResetsEnabled == true else { return } + let origin = self.statusController?.celebrationOriginPoint(for: event.provider) + self.confettiLogger.info( + "Triggering confetti", + metadata: [ + "provider": event.provider.rawValue, + "accountIdentifier": event.accountIdentifier, + "originKnown": origin == nil ? "0" : "1", + ]) + self.confettiOverlayController.play(originInScreen: origin) + } + /// Use the classic (non-Liquid Glass) app icon on macOS versions before 26. private func configureAppIconForMacOSVersion() { if #unavailable(macOS 26) { @@ -382,4 +408,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { fallbackManagedCodexAccountCoordinator, fallbackCodexAccountPromotionCoordinator) } + + deinit { + NotificationCenter.default.removeObserver(self) + } } diff --git a/Sources/CodexBar/Notifications+CodexBar.swift b/Sources/CodexBar/Notifications+CodexBar.swift index 8c04562769..354c63bf16 100644 --- a/Sources/CodexBar/Notifications+CodexBar.swift +++ b/Sources/CodexBar/Notifications+CodexBar.swift @@ -1,7 +1,24 @@ +import CodexBarCore import Foundation extension Notification.Name { static let codexbarOpenSettings = Notification.Name("codexbarOpenSettings") static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow") + static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset") static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange") } + +@MainActor +final class WeeklyLimitResetEvent: NSObject { + let provider: UsageProvider + let accountIdentifier: String + let accountLabel: String? + let usedPercent: Double + + init(provider: UsageProvider, accountIdentifier: String, accountLabel: String?, usedPercent: Double) { + self.provider = provider + self.accountIdentifier = accountIdentifier + self.accountLabel = accountLabel + self.usedPercent = usedPercent + } +} diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f26..9b901c9d83 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -64,6 +64,10 @@ struct AdvancedPane: View { title: "Surprise me", subtitle: "Check if you like your agents having some fun up there.", binding: self.$settings.randomBlinkEnabled) + PreferenceToggleRow( + title: "Weekly limit confetti", + subtitle: "Play full-screen confetti when weekly usage resets.", + binding: self.$settings.confettiOnWeeklyLimitResetsEnabled) } Divider() diff --git a/Sources/CodexBar/ScreenConfettiOverlayController.swift b/Sources/CodexBar/ScreenConfettiOverlayController.swift new file mode 100644 index 0000000000..acacceb5e7 --- /dev/null +++ b/Sources/CodexBar/ScreenConfettiOverlayController.swift @@ -0,0 +1,290 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Vortex + +@MainActor +final class ScreenConfettiOverlayController { + private static let overlayLifetime: TimeInterval = 5 + + private let logger = CodexBarLog.logger(LogCategories.confetti) + private var windows: [NSWindow] = [] + private var dismissalTask: Task? + + func play(originInScreen origin: CGPoint?) { + guard self.windows.isEmpty else { + self.logger.debug("Ignoring confetti trigger while overlay is already active") + return + } + + let screens = NSScreen.screens + guard !screens.isEmpty else { + self.logger.error("Cannot present confetti overlay because no screens were found") + return + } + + let palette = Self.randomPalette() + self.windows = screens.map { screen in + let frame = screen.frame + let localOrigin = Self.localOrigin(in: frame, from: origin) + let contentView = ScreenConfettiOverlayView(origin: localOrigin, colors: palette) + .allowsHitTesting(false) + let hostingView = NSHostingView(rootView: contentView) + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + + let window = ClickThroughOverlayPanel( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false, + screen: screen) + window.contentView = hostingView + window.level = .statusBar + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .stationary] + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false + window.ignoresMouseEvents = true + window.acceptsMouseMovedEvents = false + window.isMovable = false + window.isReleasedWhenClosed = false + window.canHide = false + window.hidesOnDeactivate = false + window.becomesKeyOnlyIfNeeded = false + window.isExcludedFromWindowsMenu = true + window.setFrame(frame, display: false) + return window + } + + self.logger.info( + "Presenting confetti overlay", + metadata: [ + "screenCount": "\(self.windows.count)", + "originKnown": origin == nil ? "0" : "1", + ]) + + for window in self.windows { + window.orderFrontRegardless() + } + + self.dismissalTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.overlayLifetime)) + self?.dismiss() + } + } + + func dismiss() { + self.dismissalTask?.cancel() + self.dismissalTask = nil + + guard !self.windows.isEmpty else { return } + for window in self.windows { + window.orderOut(nil) + window.close() + } + self.windows.removeAll(keepingCapacity: true) + } + + private static func localOrigin(in screenFrame: CGRect, from globalOrigin: CGPoint?) -> CGPoint { + let fallback = CGPoint(x: screenFrame.maxX - 28, y: screenFrame.maxY - 8) + let resolved: CGPoint = if let globalOrigin, screenFrame.contains(globalOrigin) { + globalOrigin + } else { + fallback + } + + let insetFrame = screenFrame.insetBy(dx: 8, dy: 8) + return CGPoint( + x: min(max(resolved.x, insetFrame.minX), insetFrame.maxX) - screenFrame.minX, + y: min(max(resolved.y, insetFrame.minY), insetFrame.maxY) - screenFrame.minY) + } + + private static func randomPalette() -> [Color] { + let hue = Double.random(in: 0...1) + let hueOffsets = [0.0, 0.08, 0.16, 0.5, 0.66, 0.83] + return hueOffsets.map { offset in + Color( + hue: (hue + offset).truncatingRemainder(dividingBy: 1), + saturation: Double.random(in: 0.55...0.95), + brightness: Double.random(in: 0.85...1)) + } + } +} + +private final class ClickThroughOverlayPanel: NSPanel { + override var canBecomeKey: Bool { + false + } + + override var canBecomeMain: Bool { + false + } + + override var acceptsFirstResponder: Bool { + false + } +} + +private struct ScreenConfettiOverlayView: View { + private static let clockwiseRotationAngles: [Double] = [270, 234, 198, 162, 126, 90] + private static let counterclockwiseRotationAngles: [Double] = [90, 126, 162, 198, 234, 270] + + let origin: CGPoint + let colors: [Color] + + @Environment(\.self) private var environment + @State private var visiblePhaseCount = 0 + + var body: some View { + GeometryReader { proxy in + let clockwiseAngles = Array(Self.clockwiseRotationAngles.prefix(self.visiblePhaseCount).enumerated()) + let counterclockwiseAngles = Array( + Self.counterclockwiseRotationAngles.prefix(self.visiblePhaseCount).enumerated()) + ZStack { + ForEach(clockwiseAngles, id: \.offset) { index, angle in + VortexView(self.makeFireworkConfettiSystem( + in: proxy.size, + launchAngle: angle, + phaseIndex: index, + lateralOffset: -12)) + { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(.white) + .frame(width: 10, height: 20) + .tag("confetti-bar") + + Circle() + .fill(.white) + .frame(width: 9, height: 9) + .tag("confetti-dot") + + Capsule(style: .continuous) + .fill(.white) + .frame(width: 8, height: 16) + .rotationEffect(.degrees(30)) + .tag("confetti-pill") + + Circle() + .fill(.white) + .frame(width: 6, height: 6) + .blur(radius: 1) + .tag("confetti-tracer") + } + } + + ForEach(counterclockwiseAngles, id: \.offset) { index, angle in + VortexView(self.makeFireworkConfettiSystem( + in: proxy.size, + launchAngle: angle, + phaseIndex: index, + lateralOffset: 12)) + { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(.white) + .frame(width: 10, height: 20) + .tag("confetti-bar") + + Circle() + .fill(.white) + .frame(width: 9, height: 9) + .tag("confetti-dot") + + Capsule(style: .continuous) + .fill(.white) + .frame(width: 8, height: 16) + .rotationEffect(.degrees(30)) + .tag("confetti-pill") + + Circle() + .fill(.white) + .frame(width: 6, height: 6) + .blur(radius: 1) + .tag("confetti-tracer") + } + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .task { + self.visiblePhaseCount = 1 + for phaseCount in 2...Self.clockwiseRotationAngles.count { + try? await Task.sleep(for: .milliseconds(60)) + self.visiblePhaseCount = phaseCount + } + } + } + } + + private func makeFireworkConfettiSystem( + in size: CGSize, + launchAngle: Double, + phaseIndex: Int, + lateralOffset: CGFloat) + -> VortexSystem + { + let canvasOrigin = self.canvasOrigin(in: size, lateralOffset: lateralOffset) + let normalizedX = size.width > 0 ? canvasOrigin.x / size.width : 1 + let normalizedY = size.height > 0 ? canvasOrigin.y / size.height : 0 + let resolvedColors = self.colors.map { color -> VortexSystem.Color in + let components = color.resolve(in: self.environment) + return VortexSystem.Color( + red: Double(components.red), + green: Double(components.green), + blue: Double(components.blue), + opacity: Double(components.opacity)) + } + + let explosion = VortexSystem( + tags: ["confetti-bar", "confetti-dot", "confetti-pill"], + spawnOccasion: .onDeath, + shape: .point, + birthRate: 24000, + emissionLimit: 42, + emissionDuration: 0.08, + idleDuration: 10, + lifespan: 4.2, + speed: 0.72, + speedVariation: 0.44, + angleRange: .degrees(360), + acceleration: [0, 0.32], + dampingFactor: 0.18, + angularSpeed: [0, 0, 3], + angularSpeedVariation: [2, 2, 14], + colors: .random(resolvedColors), + size: 0.74, + sizeVariation: 0.26, + sizeMultiplierAtDeath: 0.94, + stretchFactor: 0.82) + + return VortexSystem( + tags: ["confetti-tracer"], + secondarySystems: [explosion], + position: [normalizedX, normalizedY], + shape: .point, + birthRate: 18, + emissionLimit: 4, + emissionDuration: 0.22, + idleDuration: 10, + lifespan: 0.58 + (Double(phaseIndex) * 0.03), + speed: 1.36 + (Double(phaseIndex) * 0.04), + speedVariation: 0.12, + angle: .degrees(launchAngle), + angleRange: .degrees(12), + acceleration: [0, 0.12], + dampingFactor: 0.06, + angularSpeed: [0, 0, 6], + angularSpeedVariation: [1, 1, 8], + colors: .single(.white), + size: 0.34, + sizeVariation: 0.08, + sizeMultiplierAtDeath: 0.4, + stretchFactor: 1.3) + } + + private func canvasOrigin(in size: CGSize, lateralOffset: CGFloat = 0) -> CGPoint { + CGPoint( + x: min(max(self.origin.x + lateralOffset, 0), size.width), + y: min(max(size.height - self.origin.y + 18, 0), size.height)) + } +} diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 84aebdd9f3..ab32e57975 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -192,6 +192,14 @@ extension SettingsStore { } } + var confettiOnWeeklyLimitResetsEnabled: Bool { + get { self.defaultsState.confettiOnWeeklyLimitResetsEnabled } + set { + self.defaultsState.confettiOnWeeklyLimitResetsEnabled = newValue + self.userDefaults.set(newValue, forKey: "confettiOnWeeklyLimitResetsEnabled") + } + } + var menuBarShowsHighestUsage: Bool { get { self.defaultsState.menuBarShowsHighestUsage } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 5ac7f16f60..73786c47ff 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -22,6 +22,7 @@ extension SettingsStore { _ = self.costUsageEnabled _ = self.hidePersonalInfo _ = self.randomBlinkEnabled + _ = self.confettiOnWeeklyLimitResetsEnabled _ = self.claudeOAuthKeychainPromptMode _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 9456af903c..5a01847d13 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -255,6 +255,8 @@ extension SettingsStore { let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false + let confettiOnWeeklyLimitResetsEnabled = userDefaults.object( + forKey: "confettiOnWeeklyLimitResetsEnabled") as? Bool ?? false let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") @@ -299,6 +301,7 @@ extension SettingsStore { costUsageEnabled: costUsageEnabled, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, + confettiOnWeeklyLimitResetsEnabled: confettiOnWeeklyLimitResetsEnabled, menuBarShowsHighestUsage: menuBarShowsHighestUsage, claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 69e6760328..a65fb45d51 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -21,6 +21,7 @@ struct SettingsDefaultsState { var costUsageEnabled: Bool var hidePersonalInfo: Bool var randomBlinkEnabled: Bool + var confettiOnWeeklyLimitResetsEnabled: Bool var menuBarShowsHighestUsage: Bool var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index b470e049c9..e9fcf6f66a 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -198,6 +198,26 @@ extension StatusItemController { item.button?.performClick(nil) } + func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? { + let item: NSStatusItem = if self.shouldMergeIcons { + self.statusItem + } else if let provider, let existing = self.statusItems[provider], existing.isVisible { + existing + } else { + self.lazyStatusItem(for: provider ?? .codex) + } + + guard let button = item.button, + let window = button.window + else { + return nil + } + + let buttonFrameInWindow = button.convert(button.bounds, to: nil) + let screenFrame = window.convertToScreen(buttonFrameInWindow) + return CGPoint(x: screenFrame.midX, y: screenFrame.midY) + } + private func openSettings(tab: PreferencesTab) { DispatchQueue.main.async { self.preferencesSelection.tab = tab diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 2f13726bd4..dee5431a73 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -9,6 +9,13 @@ import SwiftUI @MainActor protocol StatusItemControlling: AnyObject { func openMenuFromShortcut() + func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? +} + +extension StatusItemControlling { + func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? { + nil + } } @MainActor diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index d45b542765..de23b47823 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -2,6 +2,15 @@ import CodexBarCore import Foundation extension UsageStore { + private nonisolated static let weeklyLimitResetThreshold = 1.0 + private nonisolated static let weeklyLimitResetDetectorDefaultsKey = "weeklyLimitResetDetectorStates" + private nonisolated static let weeklyWindowMinutes = 7 * 24 * 60 + + struct WeeklyLimitResetDetectorState: Codable, Equatable { + let wasAboveThreshold: Bool + let lastObservedAt: Date + } + func supportsPlanUtilizationHistory(for provider: UsageProvider) -> Bool { switch provider { case .codex, .claude: @@ -65,6 +74,22 @@ extension UsageStore { now: Date = Date()) async { + let samples = self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now) + guard !samples.isEmpty else { return } + + let detectorAccountKey = self.planUtilizationAccountKey( + for: provider, + snapshot: snapshot, + preferredAccount: account) + await MainActor.run { + self.postWeeklyLimitResetCelebrationIfNeeded( + provider: provider, + account: account, + snapshot: snapshot, + accountKey: detectorAccountKey, + samples: samples) + } + guard self.supportsPlanUtilizationHistory(for: provider) else { return } guard !self.shouldDeferClaudePlanUtilizationHistory(provider: provider) else { return } @@ -80,7 +105,6 @@ extension UsageStore { shouldAdoptUnscopedHistory: shouldAdoptUnscopedHistory, providerBuckets: &providerBuckets) let histories = providerBuckets.histories(for: accountKey) - let samples = self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now) guard let updatedHistories = Self.updatedPlanUtilizationHistories( existingHistories: histories, @@ -195,6 +219,62 @@ extension UsageStore { return max(0, min(100, value)) } + private func postWeeklyLimitResetCelebrationIfNeeded( + provider: UsageProvider, + account: ProviderTokenAccount?, + snapshot: UsageSnapshot, + accountKey: String?, + samples: [PlanUtilizationSeriesSample]) + { + guard let weeklySample = samples.last(where: { $0.name == .weekly }) else { return } + + let accountIdentifier = self.weeklyLimitResetAccountIdentifier( + provider: provider, + account: account, + snapshot: snapshot, + accountKey: accountKey) + let detectorKey = Self.weeklyLimitResetDetectorStateKey( + provider: provider, + accountIdentifier: accountIdentifier) + let currentUsed = weeklySample.entry.usedPercent + let currentObservedAt = weeklySample.entry.capturedAt + let wasAboveThreshold = currentUsed > Self.weeklyLimitResetThreshold + if let existingState = self.weeklyLimitResetDetectorStates[detectorKey], + currentObservedAt <= existingState.lastObservedAt + { + return + } + + let shouldPost = self.weeklyLimitResetDetectorStates[detectorKey]?.wasAboveThreshold == true + && !wasAboveThreshold + self.weeklyLimitResetDetectorStates[detectorKey] = WeeklyLimitResetDetectorState( + wasAboveThreshold: wasAboveThreshold, + lastObservedAt: currentObservedAt) + self.persistWeeklyLimitResetDetectorStates() + + guard shouldPost else { return } + let accountLabel = self.weeklyLimitResetAccountLabel( + provider: provider, + account: account, + snapshot: snapshot) + let event = WeeklyLimitResetEvent( + provider: provider, + accountIdentifier: accountIdentifier, + accountLabel: accountLabel, + usedPercent: currentUsed) + + CodexBarLog.logger(LogCategories.confetti).info( + "Weekly limit reset", + metadata: [ + "provider": provider.rawValue, + "accountIdentifier": accountIdentifier, + "accountLabel": accountLabel ?? "", + "usedPercent": String(format: "%.2f", currentUsed), + "observedAt": String(format: "%.0f", currentObservedAt.timeIntervalSince1970), + ]) + NotificationCenter.default.post(name: .codexbarWeeklyLimitReset, object: event) + } + private func planUtilizationSeriesSamples( provider: UsageProvider, snapshot: UsageSnapshot, @@ -235,7 +315,10 @@ extension UsageStore { appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .opus) default: - break + for window in [snapshot.primary, snapshot.secondary, snapshot.tertiary] { + guard let window, window.windowMinutes == Self.weeklyWindowMinutes else { continue } + appendWindow(window, name: .weekly) + } } return samplesByKey.values.sorted { lhs, rhs in @@ -426,6 +509,63 @@ extension UsageStore { provider == .claude && self.shouldHidePlanUtilizationMenuItem(for: .claude) } + private func weeklyLimitResetAccountIdentifier( + provider: UsageProvider, + account: ProviderTokenAccount?, + snapshot: UsageSnapshot, + accountKey: String?) -> String + { + let identity = snapshot.identity(for: provider) + return account?.id.uuidString.lowercased() + ?? accountKey + ?? identity?.accountEmail + ?? identity?.accountOrganization + ?? provider.rawValue + } + + private func weeklyLimitResetAccountLabel( + provider: UsageProvider, + account: ProviderTokenAccount?, + snapshot: UsageSnapshot) -> String? + { + let identity = snapshot.identity(for: provider) + return account?.label + ?? identity?.accountEmail + ?? identity?.accountOrganization + } + + private nonisolated static func weeklyLimitResetDetectorStateKey( + provider: UsageProvider, + accountIdentifier: String) -> String + { + "\(provider.rawValue):\(accountIdentifier)" + } + + nonisolated static func loadWeeklyLimitResetDetectorStates(from userDefaults: UserDefaults) + -> [String: WeeklyLimitResetDetectorState] + { + guard let data = userDefaults.data(forKey: self.weeklyLimitResetDetectorDefaultsKey) else { return [:] } + do { + return try JSONDecoder().decode([String: WeeklyLimitResetDetectorState].self, from: data) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to decode weekly limit reset detector state", + metadata: ["error": String(describing: error)]) + return [:] + } + } + + private func persistWeeklyLimitResetDetectorStates() { + do { + let data = try JSONEncoder().encode(self.weeklyLimitResetDetectorStates) + self.settings.userDefaults.set(data, forKey: Self.weeklyLimitResetDetectorDefaultsKey) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to encode weekly limit reset detector state", + metadata: ["error": String(describing: error)]) + } + } + private func resolvePlanUtilizationAccountKey( provider: UsageProvider, snapshot: UsageSnapshot?, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 36cc0bf2a6..930778b90e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -205,6 +205,7 @@ final class UsageStore { @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] + @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @@ -256,6 +257,7 @@ final class UsageStore { implementation.makeRuntime().map { (implementation.id, $0) } }) self.planUtilizationHistory = planUtilizationHistoryStore.load() + self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults) self.logStartupState() self.bindSettings() self.pathDebugInfo = PathDebugSnapshot( diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 90a7175d23..fba33c0ce6 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -14,6 +14,7 @@ public enum LogCategories { public static let codexRPC = "codex-rpc" public static let configMigration = "config-migration" public static let configStore = "config-store" + public static let confetti = "confetti" public static let cookieCache = "cookie-cache" public static let cookieHeaderStore = "cookie-header-store" public static let copilotTokenStore = "copilot-token-store" diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 0a50a51662..6a85d0c70b 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -6,6 +6,7 @@ import Testing @Suite(.serialized) @MainActor +// swiftlint:disable:next type_body_length struct SettingsStoreTests { private final class ObservationFlag: @unchecked Sendable { private let lock = NSLock() @@ -85,6 +86,31 @@ struct SettingsStoreTests { #expect(storeB.refreshFrequency.seconds == 900) } + @Test + func `weekly confetti setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-weekly-confetti" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.confettiOnWeeklyLimitResetsEnabled == false) + storeA.confettiOnWeeklyLimitResetsEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.confettiOnWeeklyLimitResetsEnabled == true) + } + @Test func `persists selected menu provider across instances`() throws { let suite = "SettingsStoreTests-selectedMenuProvider" diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 17732675a9..afcc9050d3 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +// swiftlint:disable:next type_body_length struct UsageStorePlanUtilizationTests { @Test func `coalesces changed usage within hour into single entry`() throws { @@ -651,6 +652,228 @@ struct UsageStorePlanUtilizationTests { #expect(findSeries(histories, name: .opus, windowMinutes: 10080)?.entries.last?.usedPercent == 30) } + @MainActor + @Test + func `weekly quota celebration posts when weekly usage resets to zero`() async { + let store = Self.makeStore() + var events: [WeeklyLimitResetEvent] = [] + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { notification in + if let event = notification.object as? WeeklyLimitResetEvent { + events.append(event) + } + } + defer { NotificationCenter.default.removeObserver(token) } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + + #expect(events.count == 1) + #expect(events[0].provider == .claude) + #expect(events[0].accountLabel == "alice@example.com") + #expect(events[0].usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration posts when reset lands mid hour without history split`() async { + let store = Self.makeStore() + var events: [WeeklyLimitResetEvent] = [] + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { notification in + if let event = notification.object as? WeeklyLimitResetEvent { + events.append(event) + } + } + defer { NotificationCenter.default.removeObserver(token) } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_700_100_000), + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_700_100_030), + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_001_800), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + + let histories = store.planUtilizationHistory(for: .claude) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40) + #expect(events.count == 1) + #expect(events[0].usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration ignores first seen reset sample`() async { + let store = Self.makeStore() + var eventCount = 0 + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { _ in + eventCount += 1 + } + defer { NotificationCenter.default.removeObserver(token) } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: snapshot.updatedAt) + + #expect(eventCount == 0) + } + + @MainActor + @Test + func `weekly quota celebration fires once across repeated low samples`() async { + let store = Self.makeStore() + var events: [WeeklyLimitResetEvent] = [] + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { notification in + if let event = notification.object as? WeeklyLimitResetEvent { + events.append(event) + } + } + defer { NotificationCenter.default.removeObserver(token) } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + let firstLow = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 1, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_001_800), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + let secondLow = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_002_100), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: firstLow, now: firstLow.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: secondLow, now: secondLow.updatedAt) + + #expect(events.count == 1) + #expect(events[0].usedPercent == 1) + } + + @MainActor + @Test + func `weekly quota celebration posts for generic provider weekly lane`() async { + let store = Self.makeStore() + var events: [WeeklyLimitResetEvent] = [] + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { notification in + if let event = notification.object as? WeeklyLimitResetEvent { + events.append(event) + } + } + defer { NotificationCenter.default.removeObserver(token) } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: "zai-org", + loginMethod: "pro")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: "zai-org", + loginMethod: "pro")) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + + #expect(events.count == 1) + #expect(events[0].provider == .zai) + #expect(events[0].accountLabel == "zai-org") + #expect(events[0].usedPercent == 0) + } + @MainActor @Test func `concurrent plan history writes coalesce within single hour bucket per series`() async throws { From 8c0a2c87394d14878dc3e12f35a96e21c9abede5 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 24 Apr 2026 15:26:33 +0530 Subject: [PATCH 0236/1239] Isolate weekly reset notification tests --- .../UsageStorePlanUtilizationTests.swift | 167 +++++++++++------- 1 file changed, 101 insertions(+), 66 deletions(-) diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index afcc9050d3..89f2fd40a2 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -656,17 +656,9 @@ struct UsageStorePlanUtilizationTests { @Test func `weekly quota celebration posts when weekly usage resets to zero`() async { let store = Self.makeStore() - var events: [WeeklyLimitResetEvent] = [] - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { notification in - if let event = notification.object as? WeeklyLimitResetEvent { - events.append(event) - } - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "reset-zero@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } let before = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -674,7 +666,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) let after = UsageSnapshot( @@ -683,16 +675,17 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_003_600), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + let events = recorder.events #expect(events.count == 1) #expect(events[0].provider == .claude) - #expect(events[0].accountLabel == "alice@example.com") + #expect(events[0].accountLabel == accountLabel) #expect(events[0].usedPercent == 0) } @@ -700,17 +693,9 @@ struct UsageStorePlanUtilizationTests { @Test func `weekly quota celebration posts when reset lands mid hour without history split`() async { let store = Self.makeStore() - var events: [WeeklyLimitResetEvent] = [] - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { notification in - if let event = notification.object as? WeeklyLimitResetEvent { - events.append(event) - } - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "mid-hour-reset@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } let before = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -722,7 +707,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) let after = UsageSnapshot( @@ -735,7 +720,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_001_800), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) @@ -745,6 +730,7 @@ struct UsageStorePlanUtilizationTests { let histories = store.planUtilizationHistory(for: .claude) #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1) #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40) + let events = recorder.events #expect(events.count == 1) #expect(events[0].usedPercent == 0) } @@ -753,15 +739,9 @@ struct UsageStorePlanUtilizationTests { @Test func `weekly quota celebration ignores first seen reset sample`() async { let store = Self.makeStore() - var eventCount = 0 - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { _ in - eventCount += 1 - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "first-seen-reset@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -769,30 +749,22 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: snapshot.updatedAt) - #expect(eventCount == 0) + #expect(recorder.events.isEmpty) } @MainActor @Test func `weekly quota celebration fires once across repeated low samples`() async { let store = Self.makeStore() - var events: [WeeklyLimitResetEvent] = [] - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { notification in - if let event = notification.object as? WeeklyLimitResetEvent { - events.append(event) - } - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "repeated-low@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } let before = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -800,7 +772,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) let firstLow = UsageSnapshot( @@ -809,7 +781,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_001_800), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) let secondLow = UsageSnapshot( @@ -818,7 +790,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_002_100), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) @@ -826,6 +798,7 @@ struct UsageStorePlanUtilizationTests { await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: firstLow, now: firstLow.updatedAt) await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: secondLow, now: secondLow.updatedAt) + let events = recorder.events #expect(events.count == 1) #expect(events[0].usedPercent == 1) } @@ -834,17 +807,9 @@ struct UsageStorePlanUtilizationTests { @Test func `weekly quota celebration posts for generic provider weekly lane`() async { let store = Self.makeStore() - var events: [WeeklyLimitResetEvent] = [] - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { notification in - if let event = notification.object as? WeeklyLimitResetEvent { - events.append(event) - } - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "zai-reset-org" + let recorder = WeeklyLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel) + defer { recorder.invalidate() } let before = UsageSnapshot( primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), @@ -853,7 +818,7 @@ struct UsageStorePlanUtilizationTests { identity: ProviderIdentitySnapshot( providerID: .zai, accountEmail: nil, - accountOrganization: "zai-org", + accountOrganization: accountLabel, loginMethod: "pro")) let after = UsageSnapshot( primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), @@ -862,15 +827,16 @@ struct UsageStorePlanUtilizationTests { identity: ProviderIdentitySnapshot( providerID: .zai, accountEmail: nil, - accountOrganization: "zai-org", + accountOrganization: accountLabel, loginMethod: "pro")) await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + let events = recorder.events #expect(events.count == 1) #expect(events[0].provider == .zai) - #expect(events[0].accountLabel == "zai-org") + #expect(events[0].accountLabel == accountLabel) #expect(events[0].usedPercent == 0) } @@ -1065,6 +1031,75 @@ func findSeries( histories.first { $0.name == name && $0.windowMinutes == windowMinutes } } +private final class WeeklyLimitResetEventRecorder: @unchecked Sendable { + struct Event: Sendable { + let provider: UsageProvider + let accountLabel: String? + let usedPercent: Double + } + + private let provider: UsageProvider + private let accountLabel: String? + private let lock = NSLock() + private var observedEvents: [Event] = [] + private var token: NSObjectProtocol? + + init(provider: UsageProvider, accountLabel: String?) { + self.provider = provider + self.accountLabel = accountLabel + self.token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { [weak self] notification in + guard let self, + let event = notification.object as? WeeklyLimitResetEvent + else { + return + } + + let recorded = MainActor.assumeIsolated { () -> Event? in + guard event.provider == self.provider, + event.accountLabel == self.accountLabel + else { + return nil + } + return Event( + provider: event.provider, + accountLabel: event.accountLabel, + usedPercent: event.usedPercent) + } + guard let recorded else { return } + + self.lock.lock() + self.observedEvents.append(recorded) + self.lock.unlock() + } + } + + var events: [Event] { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents.count + } + + func invalidate() { + guard let token else { return } + NotificationCenter.default.removeObserver(token) + self.token = nil + } + + deinit { + self.invalidate() + } +} + func formattedBoundary(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") From 4ae0c3b5422a1f42e41ae0506e07874e129af021 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 24 Apr 2026 18:51:47 +0530 Subject: [PATCH 0237/1239] Update changelog to include opt-in confetti celebration for weekly limit resets (#785). Thanks @zats! --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b99f3136..b409ce036f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! +- Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! ### Fixes - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. From a6b034284bbd211cb5fe559b7df2d986e1f43b5d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 24 Apr 2026 19:28:39 +0530 Subject: [PATCH 0238/1239] Add Cursor extra usage menu bar metric Co-Authored-By: Ye Hui <39940860+huiye98@users.noreply.github.com> --- .../MenuBarMetricWindowResolver.swift | 12 +++ .../CodexBar/PreferencesProvidersPane.swift | 6 ++ .../SettingsStore+MenuPreferences.swift | 26 +++++- Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/StatusItemController.swift | 2 + .../Providers/Cursor/CursorStatusProbe.swift | 2 +- .../CursorStatusProbeTests.swift | 1 + .../MenuBarMetricWindowResolverTests.swift | 21 +++++ .../ProvidersPaneCoverageTests.swift | 42 ++++++++++ .../SettingsStoreAdditionalTests.swift | 8 ++ .../StatusItemExtraUsageMetricTests.swift | 83 +++++++++++++++++++ 11 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 9857f39a11..f013d636e1 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -17,6 +17,8 @@ enum MenuBarMetricWindowResolver { { guard let snapshot else { return nil } switch preference { + case .extraUsage: + return Self.extraUsageWindow(snapshot: snapshot) case .tertiary: return Self.window(in: snapshot, following: Self.tertiaryOrder(for: provider)) case .primary: @@ -141,4 +143,14 @@ enum MenuBarMetricWindowResolver { guard !windows.isEmpty else { return nil } return windows.max(by: { $0.usedPercent < $1.usedPercent }) } + + private static func extraUsageWindow(snapshot: UsageSnapshot?) -> RateWindow? { + guard let cost = snapshot?.providerCost, cost.limit > 0 else { return nil } + let usedPercent = max(0, min(100, (cost.used / cost.limit) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: cost.resetsAt, + resetDescription: nil) + } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7e39ee0e2d..a5738086c0 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -465,6 +465,7 @@ struct ProvidersPane: View { let snapshot = self.store.snapshot(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) let supportsTertiary = self.settings.menuBarMetricSupportsTertiary(for: provider, snapshot: snapshot) + let supportsExtraUsage = self.settings.menuBarMetricSupportsExtraUsage(for: provider, snapshot: snapshot) var metricOptions: [ProviderSettingsPickerOption] = [ ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), ProviderSettingsPickerOption( @@ -480,6 +481,11 @@ struct ProvidersPane: View { id: MenuBarMetricPreference.tertiary.rawValue, title: "Tertiary (\(tertiaryTitle))")) } + if supportsExtraUsage { + metricOptions.append(ProviderSettingsPickerOption( + id: MenuBarMetricPreference.extraUsage.rawValue, + title: MenuBarMetricPreference.extraUsage.label)) + } if supportsAverage { metricOptions.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index fa62c073f7..f4b136e865 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -9,7 +9,7 @@ extension SettingsStore { switch preference { case .automatic, .primary: return preference - case .secondary, .average, .tertiary: + case .secondary, .average, .tertiary, .extraUsage: return .automatic } } @@ -21,6 +21,9 @@ extension SettingsStore { if preference == .tertiary, !self.menuBarMetricSupportsTertiary(for: provider) { return .automatic } + if preference == .extraUsage, !self.menuBarMetricSupportsExtraUsage(for: provider) { + return .automatic + } return preference } @@ -29,7 +32,7 @@ extension SettingsStore { switch preference { case .automatic, .primary: self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue - case .secondary, .average, .tertiary: + case .secondary, .average, .tertiary, .extraUsage: self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue } return @@ -38,6 +41,10 @@ extension SettingsStore { self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue return } + if preference == .extraUsage, !self.menuBarMetricSupportsExtraUsage(for: provider) { + self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue + return + } self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue } @@ -56,6 +63,16 @@ extension SettingsStore { return self.menuBarMetricSupportsTertiary(for: provider) } + func menuBarMetricSupportsExtraUsage(for provider: UsageProvider) -> Bool { + provider == .cursor + } + + func menuBarMetricSupportsExtraUsage(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool { + guard self.menuBarMetricSupportsExtraUsage(for: provider) else { return false } + guard let cost = snapshot?.providerCost else { return false } + return cost.limit > 0 + } + func menuBarMetricPreference(for provider: UsageProvider, snapshot: UsageSnapshot?) -> MenuBarMetricPreference { let preference = self.menuBarMetricPreference(for: provider) if preference == .tertiary, @@ -63,6 +80,11 @@ extension SettingsStore { { return .automatic } + if preference == .extraUsage, + !self.menuBarMetricSupportsExtraUsage(for: provider, snapshot: snapshot) + { + return .automatic + } return preference } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 5a01847d13..b006f6d0eb 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -43,6 +43,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { case primary case secondary case tertiary + case extraUsage case average var id: String { @@ -55,6 +56,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { case .primary: "Primary" case .secondary: "Secondary" case .tertiary: "Tertiary" + case .extraUsage: "Extra usage" case .average: "Average" } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dee5431a73..210fd8530e 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -192,6 +192,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin switch preference { case .secondary, .tertiary: return second ?? first + case .extraUsage: + return first case .average: guard self.settings.menuBarMetricSupportsAverage(for: .codex), let primary = first, diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index b94a351159..4dee402a20 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -407,7 +407,7 @@ public struct CursorStatusSnapshot: Sendable { used: resolvedOnDemandUsed, limit: resolvedOnDemandLimit ?? 0, currencyCode: "USD", - period: "monthly", + period: "Monthly", resetsAt: self.billingCycleEnd, updatedAt: Date()) } else { diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index b7ba51eae2..e34277f9fb 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -370,6 +370,7 @@ struct CursorStatusProbeTests { #expect(usageSnapshot.providerCost != nil) #expect(usageSnapshot.providerCost?.used == 0.0) #expect(usageSnapshot.providerCost?.limit == 75.0) + #expect(usageSnapshot.providerCost?.period == "Monthly") } @Test diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index e72581e064..df37232dcc 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -20,4 +20,25 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.usedPercent == 92) } + + @Test + func `extra usage metric maps provider cost into a menu bar window`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 37.5, + limit: 150, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .extraUsage, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 25) + } } diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index ee7e4e2298..33a67187fb 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -62,6 +62,48 @@ struct ProvidersPaneCoverageTests { #expect(tertiaryOption?.title == "Tertiary (API)") } + @Test + func `cursor menu bar metric picker omits extra usage when on demand budget is missing`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(!ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + } + + @Test + func `cursor menu bar metric picker includes extra usage when on demand budget is available`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue } + #expect(option?.title == "Extra usage") + } + @Test func `zai menu bar metric picker omits tertiary lane when snapshot has no 5-hour metric`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-no-tertiary-picker") diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 7ff18809ca..4948cb31b3 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -36,6 +36,11 @@ struct SettingsStoreAdditionalTests { #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) #expect(settings.menuBarMetricSupportsTertiary(for: .cursor, snapshot: nil) == false) + settings.setMenuBarMetricPreference(.extraUsage, for: .cursor) + #expect(settings.menuBarMetricPreference(for: .cursor) == .extraUsage) + #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsExtraUsage(for: .cursor, snapshot: nil) == false) + settings.setMenuBarMetricPreference(.tertiary, for: .perplexity) #expect(settings.menuBarMetricPreference(for: .perplexity) == .tertiary) #expect(settings.menuBarMetricPreference(for: .perplexity, snapshot: nil) == .tertiary) @@ -60,6 +65,9 @@ struct SettingsStoreAdditionalTests { settings.setMenuBarMetricPreference(.tertiary, for: .openrouter) #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) + + settings.setMenuBarMetricPreference(.extraUsage, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) } @Test diff --git a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift new file mode 100644 index 0000000000..34b9e3746d --- /dev/null +++ b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift @@ -0,0 +1,83 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemExtraUsageMetricTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `menu bar extra usage preference uses cursor on demand budget`() { + let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-budget") + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 15) + } + + @Test + func `menu bar extra usage preference falls back to automatic when cursor on demand budget is missing`() { + let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-missing-budget") + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 72) + } + + private func makeCursorController(suiteName: String) -> (UsageStore, StatusItemController) { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .cursor + settings.setMenuBarMetricPreference(.extraUsage, for: .cursor) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + return (store, controller) + } +} From b75e1693e3bd1ac6f7552794793d5c8ee6266f68 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 24 Apr 2026 19:34:46 +0530 Subject: [PATCH 0239/1239] Update changelog to include Cursor extra usage menu bar metric (#789). --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b409ce036f..5ce625cb23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! +- Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98! - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! ### Fixes From 47a4776e914d653f8240a5ebf52e1330d2b34db0 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 24 Apr 2026 23:28:36 +0200 Subject: [PATCH 0240/1239] feat: add Mistral AI as a new provider (#607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Resolve merge conflicts: keep both Perplexity and Mistral providers Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use Gregorian calendar for billing queries and register Mistral in token-account catalog Address PR #607 review: use fixed Gregorian/UTC calendar so non-Gregorian system calendars don't produce wrong month/year for the Mistral billing API, and add the missing .mistral entry to TokenAccountSupportCatalog so token- account overrides and manual cookie mode work correctly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(mistral): surface monthly cost as detail text in card and CLI UsageFormatter.resetLine prefers resetsAt over resetDescription, so the "€X.XXXX this month" string on the primary window was never rendered. ProviderCostSnapshot with limit=0 was also dropped by providerCostSection. Wire Mistral through the existing Warp/Kilo/Alibaba pattern that routes primary.resetDescription into a detail line below the bar, in both the menu card and the CLI renderer. Drop the dead providerCost payload. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix Mistral Linux build --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Ratul Sarna --- Sources/CodexBar/MenuCardView.swift | 2 +- .../MistralProviderImplementation.swift | 106 +++++++++ .../Mistral/MistralSettingsStore.swift | 64 +++++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-mistral.svg | 1 + Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/CLIRenderer.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 13 +- .../Mistral/MistralCookieImporter.swift | 101 ++++++++ .../Providers/Mistral/MistralErrors.swift | 35 +++ .../Providers/Mistral/MistralModels.swift | 124 ++++++++++ .../Mistral/MistralProviderDescriptor.swift | 121 ++++++++++ .../Mistral/MistralUsageFetcher.swift | 197 ++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 27 ++- .../CodexBarCore/Providers/Providers.swift | 2 + .../TokenAccountSupportCatalog+Data.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 3 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + Tests/CodexBarTests/MenuCardModelTests.swift | 46 ++++ .../MistralUsageParserTests.swift | 222 ++++++++++++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 1 + docs/providers.md | 14 +- 24 files changed, 1085 insertions(+), 11 deletions(-) create mode 100644 Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-mistral.svg create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralModels.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift create mode 100644 Tests/CodexBarTests/MistralUsageParserTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 18b77df05d..c97aed64b1 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1069,7 +1069,7 @@ extension UsageMenuCardView.Model { { primaryDetailText = detail } - if input.provider == .alibaba, + if input.provider == .alibaba || input.provider == .mistral, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift new file mode 100644 index 0000000000..1949d2ed0f --- /dev/null +++ b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift @@ -0,0 +1,106 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct MistralProviderImplementation: ProviderImplementation { + let id: UsageProvider = .mistral + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.mistralCookieSource + _ = settings.mistralCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .mistral(context.settings.mistralSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.mistralCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.mistralCookieSource != .manual { + settings.mistralCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.mistralCookieSource.rawValue }, + set: { raw in + context.settings.mistralCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.mistralCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies from admin.mistral.ai.", + manual: "Paste a Cookie header captured from the billing page.", + off: "Mistral cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "mistral-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies from admin.mistral.ai.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.load(provider: .mistral) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "mistral-cookie-header", + title: "Cookie header", + subtitle: "Paste the Cookie header from a request to admin.mistral.ai. " + + "Must contain an ory_session_* cookie.", + kind: .secure, + placeholder: "ory_session_…=…; csrftoken=…", + binding: context.stringBinding(\.mistralCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "mistral-open-console", + title: "Open Mistral Admin", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://admin.mistral.ai/organization/usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.mistralCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift new file mode 100644 index 0000000000..e994855170 --- /dev/null +++ b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift @@ -0,0 +1,64 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var mistralCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .mistral)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .mistral) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .mistral, field: "cookieHeader", value: newValue) + } + } + + var mistralCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .mistral, fallback: .auto) } + set { + self.updateProviderConfig(provider: .mistral) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .mistral, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureMistralCookieLoaded() {} +} + +extension SettingsStore { + func mistralSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .MistralProviderSettings + { + ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: self.mistralSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.mistralSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func mistralSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.mistralCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .mistral), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .mistral, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func mistralSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.mistralCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .mistral), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .mistral).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 6dd8c45c41..1b4950ec33 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -39,6 +39,7 @@ enum ProviderImplementationRegistry { case .warp: WarpProviderImplementation() case .perplexity: PerplexityProviderImplementation() case .abacus: AbacusProviderImplementation() + case .mistral: MistralProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-mistral.svg b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg new file mode 100644 index 0000000000..c946b52252 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg @@ -0,0 +1 @@ +Mistral diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 930778b90e..3704feedaf 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -882,7 +882,7 @@ extension UsageStore { let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains, .perplexity, .abacus: + .kimik2, .jetbrains, .perplexity, .abacus, .mistral: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 211cb9d52d..e22260c8d9 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -199,7 +199,7 @@ enum CLIRenderer { now: Date, lines: inout [String]) { - if provider == .warp || provider == .kilo { + if provider == .warp || provider == .kilo || provider == .mistral { if let reset = self.resetLineForDetailBackedWindow(window: window, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index e43073e820..5d4f9cba22 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -193,6 +193,13 @@ struct TokenAccountCLIContext { abacus: ProviderSettingsSnapshot.AbacusProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) + case .mistral: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) + return self.makeSnapshot( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: return nil } @@ -215,7 +222,8 @@ struct TokenAccountCLIContext { ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil, perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil, - abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot + abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil, + mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -234,7 +242,8 @@ struct TokenAccountCLIContext { ollama: ollama, jetbrains: jetbrains, perplexity: perplexity, - abacus: abacus) + abacus: abacus, + mistral: mistral) } private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) -> diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift new file mode 100644 index 0000000000..0bd65cb44a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift @@ -0,0 +1,101 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +private let mistralCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.mistral]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum MistralCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["mistral.ai", "admin.mistral.ai", "auth.mistral.ai"] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + + /// Extracts the CSRF token from the `csrftoken` cookie for the `X-CSRFTOKEN` header. + public var csrfToken: String? { + self.cookies.first { $0.name == "csrftoken" }?.value + } + } + + /// Returns `true` if any cookie name starts with `ory_session_` (the Ory Kratos session cookie). + private static func hasSessionCookie(_ cookies: [HTTPCookie]) -> Bool { + cookies.contains { $0.name.hasPrefix("ory_session_") } + } + + public static func importSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[mistral-cookie] \(msg)") } + let installedBrowsers = preferredBrowsers.isEmpty + ? mistralCookieImportOrder.cookieImportCandidates(using: browserDetection) + : preferredBrowsers.cookieImportCandidates(using: browserDetection) + + for browserSource in installedBrowsers { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + if !httpCookies.isEmpty { + guard Self.hasSessionCookie(httpCookies) else { + log("Skipping \(source.label) cookies: missing ory_session_* cookie") + continue + } + log("Found \(httpCookies.count) Mistral cookies in \(source.label)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + } + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + throw MistralCookieImportError.noCookies + } + + public static func hasSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession( + browserDetection: browserDetection, + preferredBrowsers: preferredBrowsers, + logger: logger) + return true + } catch { + return false + } + } +} + +enum MistralCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No Mistral session cookies found in browsers." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift new file mode 100644 index 0000000000..1c8ab4ae42 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum MistralUsageError: LocalizedError, Sendable { + case missingCookie + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCookie: + "No Mistral session cookies found in browsers." + case .invalidCredentials: + "Mistral session expired or invalid (HTTP 401/403)." + case let .apiError(detail): + "Mistral API error: \(detail)" + case let .parseFailed(detail): + "Failed to parse Mistral billing response: \(detail)" + } + } +} + +enum MistralSettingsError: LocalizedError { + case missingCookie + case invalidCookie + + var errorDescription: String? { + switch self { + case .missingCookie: + "No Mistral session cookies found in browsers." + case .invalidCookie: + "Mistral cookie header is invalid or missing ory_session cookie." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift new file mode 100644 index 0000000000..cdd81427e8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -0,0 +1,124 @@ +import Foundation + +// MARK: - API Response Models + +/// Top-level response from `GET https://admin.mistral.ai/api/billing/v2/usage`. +struct MistralBillingResponse: Codable { + let completion: MistralModelUsageCategory? + let ocr: MistralModelUsageCategory? + let connectors: MistralModelUsageCategory? + let librariesApi: MistralLibrariesUsageCategory? + let fineTuning: MistralFineTuningCategory? + let audio: MistralModelUsageCategory? + let vibeUsage: Double? + let date: String? + let previousMonth: String? + let nextMonth: String? + let startDate: String? + let endDate: String? + let currency: String? + let currencySymbol: String? + let prices: [MistralPrice]? + + enum CodingKeys: String, CodingKey { + case completion, ocr, connectors, audio, date, currency, prices + case librariesApi = "libraries_api" + case fineTuning = "fine_tuning" + case vibeUsage = "vibe_usage" + case previousMonth = "previous_month" + case nextMonth = "next_month" + case startDate = "start_date" + case endDate = "end_date" + case currencySymbol = "currency_symbol" + } +} + +struct MistralModelUsageCategory: Codable { + let models: [String: MistralModelUsageData]? +} + +struct MistralLibrariesUsageCategory: Codable { + let pages: MistralModelUsageCategory? + let tokens: MistralModelUsageCategory? +} + +struct MistralFineTuningCategory: Codable { + let training: [String: MistralModelUsageData]? + let storage: [String: MistralModelUsageData]? +} + +struct MistralModelUsageData: Codable { + let input: [MistralUsageEntry]? + let output: [MistralUsageEntry]? + let cached: [MistralUsageEntry]? +} + +struct MistralUsageEntry: Codable { + let usageType: String? + let eventType: String? + let billingMetric: String? + let billingDisplayName: String? + let billingGroup: String? + let timestamp: String? + let value: Int? + let valuePaid: Int? + + enum CodingKeys: String, CodingKey { + case timestamp, value + case usageType = "usage_type" + case eventType = "event_type" + case billingMetric = "billing_metric" + case billingDisplayName = "billing_display_name" + case billingGroup = "billing_group" + case valuePaid = "value_paid" + } +} + +struct MistralPrice: Codable { + let eventType: String? + let billingMetric: String? + let billingGroup: String? + let price: String? + + enum CodingKeys: String, CodingKey { + case price + case eventType = "event_type" + case billingMetric = "billing_metric" + case billingGroup = "billing_group" + } +} + +// MARK: - Intermediate Snapshot + +public struct MistralUsageSnapshot: Sendable { + public let totalCost: Double + public let currency: String + public let currencySymbol: String + public let totalInputTokens: Int + public let totalOutputTokens: Int + public let totalCachedTokens: Int + public let modelCount: Int + public let startDate: Date? + public let endDate: Date? + public let updatedAt: Date + + public func toUsageSnapshot() -> UsageSnapshot { + let resetDate = self.endDate.map { Calendar.current.date(byAdding: .second, value: 1, to: $0) ?? $0 } + let costDescription = if self.totalCost > 0 { + "\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month" + } else { + "No usage this month" + } + let primary = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: costDescription) + return UsageSnapshot( + primary: primary, + secondary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: nil) + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift new file mode 100644 index 0000000000..914a2c5e89 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -0,0 +1,121 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum MistralProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .mistral, + metadata: ProviderMetadata( + id: .mistral, + displayName: "Mistral", + sessionLabel: "Monthly", + weeklyLabel: "", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Mistral usage", + cliName: "mistral", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://admin.mistral.ai/organization/usage", + statusPageURL: nil, + statusLinkURL: "https://status.mistral.ai"), + branding: ProviderBranding( + iconStyle: .mistral, + iconResourceName: "ProviderIcon-mistral", + color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Mistral cost summary is not yet supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MistralWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "mistral", + aliases: ["mistral-ai"], + versionDetector: nil)) + } +} + +struct MistralWebFetchStrategy: ProviderFetchStrategy { + let id: String = "mistral.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.mistral?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.mistral?.cookieSource ?? .auto + do { + let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: true) + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: context.webTimeout) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } catch MistralUsageError.invalidCredentials where cookieSource != .manual { + #if os(macOS) + CookieHeaderCache.clear(provider: .mistral) + let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: false) + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: context.webTimeout) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + #else + throw MistralUsageError.invalidCredentials + #endif + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveCookieHeader( + context: ProviderFetchContext, + allowCached: Bool) throws -> (cookieHeader: String, csrfToken: String?) + { + if let settings = context.settings?.mistral, settings.cookieSource == .manual { + if let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) { + let pairs = CookieHeaderNormalizer.pairs(from: header) + let hasSessionCookie = pairs.contains { $0.name.hasPrefix("ory_session_") } + if hasSessionCookie { + let csrfToken = pairs.first { $0.name == "csrftoken" }?.value + return (header, csrfToken) + } + } + throw MistralSettingsError.invalidCookie + } + + #if os(macOS) + if allowCached, + let cached = CookieHeaderCache.load(provider: .mistral), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + let pairs = CookieHeaderNormalizer.pairs(from: cached.cookieHeader) + let csrfToken = pairs.first { $0.name == "csrftoken" }?.value + return (cached.cookieHeader, csrfToken) + } + let session = try MistralCookieImporter.importSession(browserDetection: context.browserDetection) + CookieHeaderCache.store( + provider: .mistral, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return (session.cookieHeader, session.csrfToken) + #else + throw MistralSettingsError.missingCookie + #endif + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift new file mode 100644 index 0000000000..1e4925444a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -0,0 +1,197 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum MistralUsageFetcher { + private static let baseURL = URL(string: "https://admin.mistral.ai")! + + public static func fetchUsage( + cookieHeader: String, + csrfToken: String?, + timeout: TimeInterval = 15) async throws -> MistralUsageSnapshot + { + let now = Date() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let month = calendar.component(.month, from: now) + let year = calendar.component(.year, from: now) + + let usagePath = self.baseURL.appendingPathComponent("/api/billing/v2/usage") + var components = URLComponents(url: usagePath, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "month", value: "\(month)"), + URLQueryItem(name: "year", value: "\(year)"), + ] + guard let url = components.url else { + throw MistralUsageError.apiError("Failed to construct URL") + } + + var request = URLRequest(url: url, timeoutInterval: timeout) + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("https://admin.mistral.ai/organization/usage", forHTTPHeaderField: "Referer") + request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin") + if let csrfToken { + request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw MistralUsageError.apiError("Invalid response type") + } + + switch httpResponse.statusCode { + case 200: + break + case 401, 403: + throw MistralUsageError.invalidCredentials + default: + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + throw MistralUsageError.apiError("HTTP \(httpResponse.statusCode): \(body)") + } + + return try Self.parseResponse(data: data, updatedAt: now) + } + + static func parseResponse(data: Data, updatedAt: Date) throws -> MistralUsageSnapshot { + let decoder = JSONDecoder() + let billing: MistralBillingResponse + do { + billing = try decoder.decode(MistralBillingResponse.self, from: data) + } catch { + throw MistralUsageError.parseFailed(error.localizedDescription) + } + + let prices = Self.buildPriceIndex(billing.prices ?? []) + var totalCost: Double = 0 + var totalInput = 0 + var totalOutput = 0 + var totalCached = 0 + var modelCount = 0 + + // Aggregate completion tokens + if let models = billing.completion?.models { + for (_, modelData) in models { + modelCount += 1 + let (input, output, cached, cost) = Self.aggregateModel(modelData, prices: prices) + totalInput += input + totalOutput += output + totalCached += cached + totalCost += cost + } + } + + // Aggregate OCR, connectors, audio if present + for category in [billing.ocr, billing.connectors, billing.audio] { + if let models = category?.models { + for (_, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + } + } + } + + // Aggregate libraries_api (pages + tokens) + for category in [billing.librariesApi?.pages, billing.librariesApi?.tokens] { + if let models = category?.models { + for (_, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + } + } + } + + // Aggregate fine_tuning (training + storage) + for models in [billing.fineTuning?.training, billing.fineTuning?.storage] { + if let models { + for (_, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + } + } + } + + let currency = billing.currency ?? "EUR" + let currencySymbol = billing.currencySymbol ?? "€" + + let startDate = billing.startDate.flatMap { Self.parseDate($0) } + let endDate = billing.endDate.flatMap { Self.parseDate($0) } + + return MistralUsageSnapshot( + totalCost: totalCost, + currency: currency, + currencySymbol: currencySymbol, + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalCachedTokens: totalCached, + modelCount: modelCount, + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt) + } + + // MARK: - Private Helpers + + private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] { + var index: [String: Double] = [:] + for price in prices { + guard let metric = price.billingMetric, + let group = price.billingGroup, + let priceStr = price.price, + let value = Double(priceStr) + else { continue } + let key = "\(metric)::\(group)" + index[key] = value + } + return index + } + + private static func aggregateModel( + _ data: MistralModelUsageData, + prices: [String: Double]) -> (input: Int, output: Int, cached: Int, cost: Double) + { + var totalInput = 0 + var totalOutput = 0 + var totalCached = 0 + var totalCost: Double = 0 + + for entry in data.input ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalInput += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + for entry in data.output ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalOutput += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + for entry in data.cached ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalCached += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + return (totalInput, totalOutput, totalCached, totalCost) + } + + private static func parseDate(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6fb994efcc..d98204b8ef 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -79,6 +79,7 @@ public enum ProviderDescriptorRegistry { .warp: WarpProviderDescriptor.descriptor, .perplexity: PerplexityProviderDescriptor.descriptor, .abacus: AbacusProviderDescriptor.descriptor, + .mistral: MistralProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 63aa6221c1..2692c4920b 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -21,7 +21,8 @@ public struct ProviderSettingsSnapshot: Sendable { ollama: OllamaProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil, - abacus: AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot + abacus: AbacusProviderSettings? = nil, + mistral: MistralProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -43,7 +44,8 @@ public struct ProviderSettingsSnapshot: Sendable { ollama: ollama, jetbrains: jetbrains, perplexity: perplexity, - abacus: abacus) + abacus: abacus, + mistral: mistral) } public struct CodexProviderSettings: Sendable { @@ -244,6 +246,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct MistralProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -264,6 +276,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let jetbrains: JetBrainsProviderSettings? public let perplexity: PerplexityProviderSettings? public let abacus: AbacusProviderSettings? + public let mistral: MistralProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -289,7 +302,8 @@ public struct ProviderSettingsSnapshot: Sendable { ollama: OllamaProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil, - abacus: AbacusProviderSettings? = nil) + abacus: AbacusProviderSettings? = nil, + mistral: MistralProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -311,6 +325,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.jetbrains = jetbrains self.perplexity = perplexity self.abacus = abacus + self.mistral = mistral } } @@ -333,6 +348,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) case abacus(ProviderSettingsSnapshot.AbacusProviderSettings) + case mistral(ProviderSettingsSnapshot.MistralProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -356,6 +372,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings? + public var mistral: ProviderSettingsSnapshot.MistralProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled @@ -382,6 +399,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .jetbrains(value): self.jetbrains = value case let .perplexity(value): self.perplexity = value case let .abacus(value): self.abacus = value + case let .mistral(value): self.mistral = value } } @@ -406,6 +424,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { ollama: self.ollama, jetbrains: self.jetbrains, perplexity: self.perplexity, - abacus: self.abacus) + abacus: self.abacus, + mistral: self.mistral) } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 83dade0549..22493fa0f5 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -29,6 +29,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case openrouter case perplexity case abacus + case mistral } // swiftformat:enable sortDeclarations @@ -60,6 +61,7 @@ public enum IconStyle: Sendable, CaseIterable { case openrouter case perplexity case abacus + case mistral case combined } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index a13d28a80b..893be0d5a4 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -72,5 +72,12 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .mistral: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Mistral Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 17ddf1dba4..69cc02db61 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -235,7 +235,8 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus, + .mistral: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 01075cabea..ce60108dee 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -77,6 +77,7 @@ enum ProviderChoice: String, AppEnum { case .warp: return nil // Warp not yet supported in widgets case .perplexity: return nil // Perplexity not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets + case .mistral: return nil // Mistral not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 4e03801b32..00c791aa8b 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -283,6 +283,7 @@ private struct ProviderSwitchChip: View { case .warp: "Warp" case .perplexity: "Pplx" case .abacus: "Abacus" + case .mistral: "Mistral" } } } @@ -644,6 +645,8 @@ enum WidgetColors { Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal case .abacus: Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) + case .mistral: + Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange } } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index ee8fce91ae..a0ee416630 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -775,4 +775,50 @@ struct MenuCardModelTests { #expect(primary.resetText == nil) #expect(primary.detailText == "10/100 credits") } + + @Test + func `mistral model surfaces monthly cost as primary detail text`() throws { + let now = Date() + let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60) + let identity = ProviderIdentitySnapshot( + providerID: .mistral, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: "€1.2345 this month"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.detailText == "€1.2345 this month") + #expect(primary.resetText?.hasPrefix("Resets") == true) + } } diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift new file mode 100644 index 0000000000..ec1d6dd84b --- /dev/null +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -0,0 +1,222 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MistralUsageParserTests { + // swiftlint:disable line_length + + private static let novemberResponseJSON = """ + {"completion":{"models":{"mistral-large-latest::mistral-large-2411":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"input","timestamp":"2025-11-14","value":11121,"value_paid":11121}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"output","timestamp":"2025-11-14","value":1115,"value_paid":1115}]},"mistral-small-latest::mistral-small-2506":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-14","value":20,"value_paid":20},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-24","value":100,"value_paid":100}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-14","value":500,"value_paid":500},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-24","value":2482,"value_paid":2482}]}}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2025-11-01T00:00:00Z","previous_month":"2025-10","next_month":"2025-12","start_date":"2025-11-01T00:00:00Z","end_date":"2025-11-30T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"input","price":"0.0000017000"},{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"output","price":"0.0000051000"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"input","price":"8.50E-8"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"output","price":"2.550E-7"}]} + """ + + private static let emptyResponseJSON = """ + {"completion":{"models":{}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2026-02-01T00:00:00Z","previous_month":"2026-01","next_month":"2026-03","start_date":"2026-02-01T00:00:00Z","end_date":"2026-02-28T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[]} + """ + + // swiftlint:enable line_length + + @Test + func `parses response with usage data and computes token totals`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + // mistral-large input: 11121, mistral-small input: 20+100=120 + #expect(snapshot.totalInputTokens == 11121 + 120) + // mistral-large output: 1115, mistral-small output: 500+2482=2982 + #expect(snapshot.totalOutputTokens == 1115 + 2982) + #expect(snapshot.totalCachedTokens == 0) + #expect(snapshot.modelCount == 2) + #expect(snapshot.currency == "EUR") + #expect(snapshot.currencySymbol == "€") + } + + @Test + func `computes cost from tokens and prices`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + // mistral-large-2411 input: 11121 * 0.0000017 = 0.0189057 + // mistral-large-2411 output: 1115 * 0.0000051 = 0.0056865 + // mistral-small-2506 input: 120 * 0.000000085 = 0.0000102 + // mistral-small-2506 output: 2982 * 0.000000255 = 0.00076041 + let expectedCost = 0.0189057 + 0.0056865 + 0.0000102 + 0.00076041 + #expect(abs(snapshot.totalCost - expectedCost) < 0.0001) + #expect(snapshot.totalCost > 0) + } + + @Test + func `parses empty response with no usage`() throws { + let data = try #require(Self.emptyResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + #expect(snapshot.totalInputTokens == 0) + #expect(snapshot.totalOutputTokens == 0) + #expect(snapshot.totalCost == 0) + #expect(snapshot.modelCount == 0) + #expect(snapshot.currency == "EUR") + } + + @Test + func `parses dates from response`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + #expect(snapshot.startDate != nil) + #expect(snapshot.endDate != nil) + + let calendar = Calendar.current + if let start = snapshot.startDate { + #expect(calendar.component(.month, from: start) == 11) + #expect(calendar.component(.year, from: start) == 2025) + } + } + + @Test + func `throws parseFailed for invalid JSON`() { + let data = Data("not json".utf8) + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + } + } +} + +struct MistralUsageSnapshotConversionTests { + @Test + func `converts cost into primary resetDescription so it surfaces as detail text`() { + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: Date(), + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary != nil) + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription?.contains("€1.2345") == true) + // providerCost is intentionally nil: the menu card's providerCostSection requires + // limit > 0 to render a bar, and Mistral is pay-as-you-go with no quota. The cost + // is surfaced via primary.resetDescription (rendered as detail text in the card). + #expect(usage.providerCost == nil) + } + + @Test + func `converts zero cost with no-usage description`() { + let snapshot = MistralUsageSnapshot( + totalCost: 0, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + startDate: nil, + endDate: nil, + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "No usage this month") + } +} + +struct MistralStrategyTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext( + sourceMode: ProviderSourceMode = .auto, + settings: ProviderSettingsSnapshot? = nil, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } + + @Test + func `strategy is unavailable when cookie source is off`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == false) + } + + @Test + func `strategy is available when cookie source is auto`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == true) + } + + @Test + func `strategy is available when cookie source is manual`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .manual, + manualCookieHeader: "ory_session_x=abc; csrftoken=xyz")) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == true) + } + + @Test + func `strategy never falls back (single strategy provider)`() { + let strategy = MistralWebFetchStrategy() + let context = self.makeContext() + let shouldFallback = strategy.shouldFallback( + on: MistralUsageError.invalidCredentials, + context: context) + #expect(shouldFallback == false) + } + + @Test + func `descriptor metadata is correct`() { + let descriptor = MistralProviderDescriptor.descriptor + #expect(descriptor.id == .mistral) + #expect(descriptor.metadata.displayName == "Mistral") + #expect(descriptor.metadata.cliName == "mistral") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(descriptor.cli.name == "mistral") + #expect(descriptor.fetchPlan.sourceModes == [.auto, .web]) + #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral") + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 6a85d0c70b..9493cdb45a 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -938,6 +938,7 @@ struct SettingsStoreTests { .openrouter, .perplexity, .abacus, + .mistral, ]) // Move one provider; ensure it's persisted across instances. diff --git a/docs/providers.md b/docs/providers.md index a82898e782..0fe26b2cf1 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI, Mistral)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -40,6 +40,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Ollama | Web settings page via browser cookies (`web`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | | Abacus AI | Browser cookies → compute points + billing API (`web`). | +| Mistral | Console billing API via Ory Kratos session cookies (`web`). | ## Codex - Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -192,4 +193,15 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: none yet. - Details: `docs/abacus.md`. +## Mistral +- Session cookie (`ory_session_*`) from browser auto-import or manual `Cookie:` header. +- CSRF token (`csrftoken` cookie) sent as `X-CSRFTOKEN` header. +- Domain: `admin.mistral.ai`. +- Billing endpoint: `GET https://admin.mistral.ai/api/billing/v2/usage?month=&year=`. +- Returns monthly token usage per model (completion, OCR, audio, connectors, fine-tuning) with pricing. +- Cost computed client-side from token counts × per-model prices included in the response. +- Currency from response (typically EUR). +- Resets at end of calendar month. +- Status: `https://status.mistral.ai` (link only, no auto-polling). + See also: `docs/provider.md` for architecture notes. From a1dfe54d24d2a176c18f7fa965d81cae5faa5f6f Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 25 Apr 2026 03:17:49 +0530 Subject: [PATCH 0241/1239] Update CHANGELOG to include Mistral provider support with monthly spend tracking and cookie import features (#607). --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce625cb23..ecd57e5ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! - Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98! +- Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic! - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! ### Fixes From bb6fcab98cd2a93c4729c3fd34246bad8f0d7c8a Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 25 Apr 2026 12:38:44 +0530 Subject: [PATCH 0242/1239] Add GPT-5.5 pricing --- CHANGELOG.md | 1 + .../Vendored/CostUsage/CostUsagePricing.swift | 10 ++++++++ .../CodexBarTests/CostUsagePricingTests.swift | 24 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd57e5ad6..aba5db5144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! +- Codex: add GPT-5.5 and GPT-5.5 Pro pricing so local cost scanning recognizes the new models. - Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98! - Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic! - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 1055e104af..3f7979604d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -112,6 +112,16 @@ enum CostUsagePricing { outputCostPerToken: 1.8e-4, cacheReadInputCostPerToken: nil, displayLabel: nil), + "gpt-5.5": CodexPricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 3e-5, + cacheReadInputCostPerToken: 5e-7, + displayLabel: nil), + "gpt-5.5-pro": CodexPricing( + inputCostPerToken: 3e-5, + outputCostPerToken: 1.8e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), ] private static let claude: [String: ClaudePricing] = [ diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 4917dc120f..20af0b54bf 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -10,6 +10,8 @@ struct CostUsagePricingTests { #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-pro-2026-03-05") == "gpt-5.4-pro") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-mini-2026-03-17") == "gpt-5.4-mini") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-nano-2026-03-17") == "gpt-5.4-nano") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-2026-04-23") == "gpt-5.5") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-pro-2026-04-23") == "gpt-5.5-pro") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-2026-03-05") == "gpt-5.3-codex") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") } @@ -51,6 +53,28 @@ struct CostUsagePricingTests { #expect(nano != nil) } + @Test + func `codex cost supports gpt55`() { + let cost = CostUsagePricing.codexCostUSD( + model: "openai/gpt-5.5-2026-04-23", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + + #expect(cost == 90 * 5e-6 + 10 * 5e-7 + 5 * 3e-5) + } + + @Test + func `codex cost supports gpt55 pro`() { + let cost = CostUsagePricing.codexCostUSD( + model: "openai/gpt-5.5-pro-2026-04-23", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + + #expect(cost == 100 * 3e-5 + 5 * 1.8e-4) + } + @Test func `codex cost returns zero for research preview model`() { let cost = CostUsagePricing.codexCostUSD( From 00184eac33b9b24c0fc1756e9fcde50a8469f1b4 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 25 Apr 2026 13:49:19 +0530 Subject: [PATCH 0243/1239] Fix duplicate utilization session tabs --- CHANGELOG.md | 1 + .../PlanUtilizationHistoryChartMenuView.swift | 1 + .../PlanUtilizationHistoryStore.swift | 17 ++- .../CodexBar/UsageStore+PlanUtilization.swift | 1 + .../UsageStorePlanUtilizationTests.swift | 101 ++++++++++++++++++ 5 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aba5db5144..8f40e7b6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! ### Fixes +- Codex: ignore invalid zero-minute subscription history so the utilization submenu no longer shows duplicate Session tabs. - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. - Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! - Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch! diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index c82b3740cc..7112b50ce7 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -185,6 +185,7 @@ struct PlanUtilizationHistoryChartMenuView: View { return histories .filter { history in guard !history.entries.isEmpty else { return false } + guard history.windowMinutes > 0 else { return false } guard let allowedNames else { return true } return allowedNames.contains(history.name) } diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 7fd3e5e89e..9bb5f6bc7e 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -129,7 +129,9 @@ struct PlanUtilizationHistoryStore { for provider in UsageProvider.allCases { let fileURL = self.providerFileURL(for: provider) let buckets = providers[provider] ?? PlanUtilizationHistoryBuckets() - guard !buckets.isEmpty else { + let unscoped = Self.sortedHistories(buckets.unscoped) + let accounts = Self.sortedAccounts(buckets.accounts) + guard !unscoped.isEmpty || !accounts.isEmpty else { try? FileManager.default.removeItem(at: fileURL) continue } @@ -137,8 +139,8 @@ struct PlanUtilizationHistoryStore { let payload = ProviderHistoryDocument( version: Self.providerSchemaVersion, preferredAccountKey: buckets.preferredAccountKey, - unscoped: Self.sortedHistories(buckets.unscoped), - accounts: Self.sortedAccounts(buckets.accounts)) + unscoped: unscoped, + accounts: accounts) let data = try encoder.encode(payload) try data.write(to: fileURL, options: Data.WritingOptions.atomic) } @@ -209,7 +211,7 @@ struct PlanUtilizationHistoryStore { } private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] { - histories.sorted { lhs, rhs in + self.sanitizedHistories(histories).sorted { lhs, rhs in if lhs.windowMinutes != rhs.windowMinutes { return lhs.windowMinutes < rhs.windowMinutes } @@ -217,6 +219,13 @@ struct PlanUtilizationHistoryStore { } } + private static func sanitizedHistories(_ histories: [PlanUtilizationSeriesHistory]) + -> [PlanUtilizationSeriesHistory] { + histories.filter { history in + history.windowMinutes > 0 && !history.entries.isEmpty + } + } + private static func defaultDirectoryURL() -> URL? { guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index de23b47823..10eca18b09 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -286,6 +286,7 @@ extension UsageStore { guard let name, let window, let windowMinutes = window.windowMinutes, + windowMinutes > 0, let usedPercent = Self.clampedPercent(window.usedPercent) else { return diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 89f2fd40a2..539786eb75 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -123,6 +123,9 @@ struct UsageStorePlanUtilizationTests { @Test func `native chart shows visible series tabs only`() { let histories = [ + planSeries(name: .session, windowMinutes: 0, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 90), + ]), planSeries(name: .session, windowMinutes: 300, entries: [ planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), ]), @@ -588,6 +591,39 @@ struct UsageStorePlanUtilizationTests { #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.resetsAt == secondaryReset) } + @MainActor + @Test + func `record plan history skips invalid zero minute windows`() async { + let store = Self.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 0, + resetsAt: Date(timeIntervalSince1970: 1_710_000_000), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 44, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_710_086_400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "plus")) + store._setSnapshotForTesting(snapshot, provider: .codex) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let histories = store.planUtilizationHistory(for: .codex) + #expect(findSeries(histories, name: .session, windowMinutes: 0) == nil) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 44) + } + @MainActor @Test func `record plan history keeps semantic codex lanes when durations drift`() async { @@ -901,6 +937,64 @@ struct UsageStorePlanUtilizationTests { #expect(loaded.isEmpty) } + @Test + func `store drops invalid zero minute and empty histories when loading and saving`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let directoryURL = root + .appendingPathComponent("com.steipete.codexbar", isDirectory: true) + .appendingPathComponent("history", isDirectory: true) + let providerURL = directoryURL.appendingPathComponent("codex.json") + let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true) + + let validUnscoped = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 12), + ]) + let validAccount = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 64), + ]) + let document = PersistedFixtureDocument( + version: 1, + preferredAccountKey: "alice", + unscoped: [ + planSeries(name: .session, windowMinutes: 0, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 99), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: []), + validUnscoped, + ], + accounts: [ + "alice": [ + planSeries(name: .session, windowMinutes: 0, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 88), + ]), + validAccount, + ], + "empty": [ + planSeries(name: .weekly, windowMinutes: 10080, entries: []), + ], + ]) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(document).write(to: providerURL, options: Data.WritingOptions.atomic) + + let loaded = store.load() + let loadedBuckets = try #require(loaded[.codex]) + #expect(loadedBuckets.unscoped == [validUnscoped]) + #expect(loadedBuckets.accounts == ["alice": [validAccount]]) + + store.save(loaded) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let rewritten = try decoder.decode(PersistedFixtureDocument.self, from: Data(contentsOf: providerURL)) + #expect(rewritten.unscoped == [validUnscoped]) + #expect(rewritten.accounts == ["alice": [validAccount]]) + } + @Test func `store round trips account buckets with series entries`() { let root = FileManager.default.temporaryDirectory @@ -935,6 +1029,13 @@ struct UsageStorePlanUtilizationTests { } extension UsageStorePlanUtilizationTests { + private struct PersistedFixtureDocument: Codable { + let version: Int + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] + } + private struct FixtureDocument: Decodable { let preferredAccountKey: String? let unscoped: [PlanUtilizationSeriesHistory] From af56fb3797cb5a42bc7e14e8c38b8fbd01ac5bfd Mon Sep 17 00:00:00 2001 From: ratulsarna Date: Sat, 25 Apr 2026 18:36:18 +0530 Subject: [PATCH 0244/1239] Fix Factory session recovery and usage fetch (#792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Clear stale Factory session store * Preserve Factory session recovery * Fix Factory usage request method * Preserve Factory cookie fallbacks * Clarify Droid browser login * Fix Droid login message lint * Update changelog for Droid fixes --------- Co-authored-by: Yusufhan Saçak --- CHANGELOG.md | 1 + .../FactoryProviderImplementation.swift | 7 + .../Factory/FactoryStatusProbe.swift | 75 +++-- .../FactoryStatusProbeFetchTests.swift | 268 +++++++++++++++++- 4 files changed, 326 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f40e7b6ee..4f9ef375b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixes - Codex: ignore invalid zero-minute subscription history so the utilization submenu no longer shows duplicate Session tabs. - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. +- Droid: preserve Factory session fallbacks, use the current usage endpoint, and clarify browser-login messaging (#792). Thanks @JosephDoUrden for the original stale-session fix! - Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! - Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch! diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index d8d2d20245..c4fd16117b 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -82,4 +82,11 @@ struct FactoryProviderImplementation: ProviderImplementation { await context.controller.runFactoryLoginFlow() return true } + + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Open Droid in Browser...", .loginToProvider(url: "https://app.factory.ai")) + } } diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index 2f45886a2a..0a75cf9198 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -402,7 +402,8 @@ public enum FactoryStatusProbeError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .notLoggedIn: - "Not logged in to Factory. Please log in via the CodexBar menu." + "No usable Droid session found. Log in to app.factory.ai in \(factoryCookieImportOrder.loginHint), " + + "then refresh Droid." case let .networkError(msg): "Factory API error: \(msg)" case let .parseFailed(msg): @@ -421,7 +422,7 @@ public actor FactorySessionStore { private var sessionCookies: [HTTPCookie] = [] private var bearerToken: String? private var refreshToken: String? - private let fileURL: URL + private var fileURL: URL private var didLoadFromDisk = false private init() { @@ -444,6 +445,13 @@ public actor FactorySessionStore { return self.sessionCookies } + public func clearCookies() { + self.loadFromDiskIfNeeded() + self.didLoadFromDisk = true + self.sessionCookies = [] + self.saveToDisk() + } + public func setBearerToken(_ token: String?) { self.didLoadFromDisk = true self.bearerToken = token @@ -479,6 +487,22 @@ public actor FactorySessionStore { return !self.sessionCookies.isEmpty || self.bearerToken != nil || self.refreshToken != nil } + func resetInMemoryForTesting() { + self.sessionCookies = [] + self.bearerToken = nil + self.refreshToken = nil + self.didLoadFromDisk = false + } + + func useFileURLForTesting(_ fileURL: URL) { + self.fileURL = fileURL + self.sessionCookies = [] + self.bearerToken = nil + self.refreshToken = nil + self.didLoadFromDisk = false + try? FileManager.default.removeItem(at: fileURL) + } + private func saveToDisk() { let cookieData = self.sessionCookies.compactMap { cookie -> [String: Any]? in guard let props = cookie.properties else { return nil } @@ -516,6 +540,7 @@ public actor FactorySessionStore { guard !payload.isEmpty, let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]) else { + try? FileManager.default.removeItem(at: self.fileURL) return } try? data.write(to: self.fileURL) @@ -659,19 +684,19 @@ public struct FactoryStatusProbe: Sendable { // Filter to only installed browsers to avoid unnecessary keychain prompts let installedChromiumAndFirefox = [.chrome, .firefox].cookieImportCandidates(using: self.browserDetection) - let attempts: [FetchAttemptResult] = await [ - self.attemptStoredCookies(logger: log), - self.attemptStoredBearer(logger: log), - self.attemptStoredRefreshToken(logger: log), - self.attemptLocalStorageTokens(logger: log), - self.attemptBrowserCookies(logger: log, sources: [.safari]), - self.attemptWorkOSCookies(logger: log, sources: [.safari]), - self.attemptBrowserCookies(logger: log, sources: installedChromiumAndFirefox), - self.attemptWorkOSCookies(logger: log, sources: installedChromiumAndFirefox), + let attempts: [() async -> FetchAttemptResult] = [ + { await self.attemptStoredCookies(logger: log) }, + { await self.attemptStoredBearer(logger: log) }, + { await self.attemptStoredRefreshToken(logger: log) }, + { await self.attemptLocalStorageTokens(logger: log) }, + { await self.attemptBrowserCookies(logger: log, sources: [.safari]) }, + { await self.attemptWorkOSCookies(logger: log, sources: [.safari]) }, + { await self.attemptBrowserCookies(logger: log, sources: installedChromiumAndFirefox) }, + { await self.attemptWorkOSCookies(logger: log, sources: installedChromiumAndFirefox) }, ] - for result in attempts { - switch result { + for attempt in attempts { + switch await attempt() { case let .success(snapshot): return snapshot case let .failure(error): @@ -732,8 +757,8 @@ public struct FactoryStatusProbe: Sendable { return try await .success(self.fetchWithCookies(storedCookies, logger: logger)) } catch { if case FactoryStatusProbeError.notLoggedIn = error { - await FactorySessionStore.shared.clearSession() - logger("Stored session invalid, cleared") + await FactorySessionStore.shared.clearCookies() + logger("Stored session cookies invalid, cleared") } else { logger("Stored session failed: \(error.localizedDescription)") } @@ -1064,10 +1089,19 @@ public struct FactoryStatusProbe: Sendable { userId: String?, baseURL: URL) async throws -> FactoryUsageResponse { - let url = baseURL.appendingPathComponent("/api/organization/subscription/usage") + var components = URLComponents( + url: baseURL.appendingPathComponent("/api/organization/subscription/usage"), + resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "useCache", value: "true"), + ] + if let userId { + components?.queryItems?.append(URLQueryItem(name: "userId", value: userId)) + } + let url = components?.url ?? baseURL.appendingPathComponent("/api/organization/subscription/usage") var request = URLRequest(url: url) request.timeoutInterval = self.timeout - request.httpMethod = "POST" + request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("https://app.factory.ai", forHTTPHeaderField: "Origin") @@ -1080,13 +1114,6 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - // Build request body - var body: [String: Any] = ["useCache": true] - if let userId { - body["userId"] = userId - } - request.httpBody = try? JSONSerialization.data(withJSONObject: body) - let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { diff --git a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift index 2f2bf51e21..48f4ccf876 100644 --- a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift +++ b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift @@ -1,9 +1,223 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore @Suite(.serialized) struct FactoryStatusProbeFetchTests { + @Test + func `keeps stored Factory cookies available when cached header is not logged in`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "app.factory.ai", + url.path == "/api/app/auth/me", + request.value(forHTTPHeaderField: "Cookie")?.contains("stale-cache") == true + { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + if url.host == "api.factory.ai", url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/organization/subscription/usage" { + let body = """ + { + "usage": { + "standard": { + "userTokens": 100, + "totalAllowance": 1000 + } + }, + "userId": "user-1" + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let cookie = try #require(HTTPCookie(properties: [ + .domain: "app.factory.ai", + .path: "/", + .name: "session", + .value: "valid-session", + ])) + + let sessionFile = try await Self.isolateFactorySessionStore() + defer { + try? FileManager.default.removeItem(at: sessionFile) + } + + await FactorySessionStore.shared.clearSession() + CookieHeaderCache.store(provider: .factory, cookieHeader: "session=stale-cache", sourceLabel: "Chrome") + await FactorySessionStore.shared.setCookies([cookie]) + await FactorySessionStore.shared.resetInMemoryForTesting() + defer { + CookieHeaderCache.clear(provider: .factory) + } + + let probe = FactoryStatusProbe( + timeout: 0.1, + browserDetection: BrowserDetection( + homeDirectory: "/tmp/codexbar-empty-browser-home", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil })) + + let snapshot = try await probe.fetch() + + #expect(CookieHeaderCache.load(provider: .factory) == nil) + #expect(snapshot.userId == "user-1") + #expect(await FactorySessionStore.shared.getCookies().map(\.value) == ["valid-session"]) + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/organization/subscription/usage?useCache=true", + ]) + await FactorySessionStore.shared.clearSession() + } + + @Test + func `preserves stored Factory refresh token when stored cookies are not logged in`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "app.factory.ai", url.path == "/api/app/auth/me" { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + if url.host == "api.factory.ai", + url.path == "/api/app/auth/me", + request.value(forHTTPHeaderField: "Cookie")?.contains("stale-session") == true + { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + if url.host == "api.workos.com", url.path == "/user_management/authenticate" { + let requestBody = try Self.requestJSONBody(from: request) + guard requestBody["refresh_token"] as? String == "stale-refresh" else { + throw URLError(.userAuthenticationRequired) + } + let body = """ + { + "access_token": "fresh-access", + "refresh_token": "fresh-refresh" + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/organization/subscription/usage" { + let body = """ + { + "usage": { + "standard": { + "userTokens": 100, + "totalAllowance": 1000 + } + }, + "userId": "user-1" + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let cookie = try #require(HTTPCookie(properties: [ + .domain: "app.factory.ai", + .path: "/", + .name: "session", + .value: "stale-session", + ])) + + let sessionFile = try await Self.isolateFactorySessionStore() + defer { + try? FileManager.default.removeItem(at: sessionFile) + } + + await FactorySessionStore.shared.clearSession() + CookieHeaderCache.store(provider: .factory, cookieHeader: "session=stale-cache", sourceLabel: "Chrome") + await FactorySessionStore.shared.setCookies([cookie]) + await FactorySessionStore.shared.setRefreshToken("stale-refresh") + await FactorySessionStore.shared.resetInMemoryForTesting() + defer { + CookieHeaderCache.clear(provider: .factory) + } + + let probe = FactoryStatusProbe( + timeout: 0.1, + browserDetection: BrowserDetection( + homeDirectory: "/tmp/codexbar-empty-browser-home", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil })) + + let snapshot = try await probe.fetch() + + #expect(CookieHeaderCache.load(provider: .factory) == nil) + #expect(snapshot.userId == "user-1") + #expect(await FactorySessionStore.shared.getCookies().isEmpty) + #expect(await FactorySessionStore.shared.getBearerToken() == "fresh-access") + #expect(await FactorySessionStore.shared.getRefreshToken() == "fresh-refresh") + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/app/auth/me", + "GET app.factory.ai/api/app/auth/me", + "POST api.workos.com/user_management/authenticate", + "GET api.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/organization/subscription/usage?useCache=true", + ]) + await FactorySessionStore.shared.clearSession() + } + @Test func `fetches snapshot using cookie header override`() async throws { let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) @@ -12,7 +226,9 @@ struct FactoryStatusProbeFetchTests { URLProtocol.unregisterClass(FactoryStubURLProtocol.self) } FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] } + FactoryStubURLProtocol.requests = [] FactoryStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } @@ -90,10 +306,59 @@ struct FactoryStatusProbeFetchTests { headerFields: ["Content-Type": "application/json"])! return (response, Data(body.utf8)) } + + private static func requestTrace() -> [String] { + FactoryStubURLProtocol.requests.compactMap { request in + guard let url = request.url else { return nil } + let query = url.query.map { "?\($0)" } ?? "" + return "\(request.httpMethod ?? "?") \(url.host ?? "unknown")\(url.path)\(query)" + } + } + + private static func isolateFactorySessionStore() async throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-factory-tests", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("\(UUID().uuidString).json") + await FactorySessionStore.shared.useFileURLForTesting(fileURL) + return fileURL + } + + private static func requestJSONBody(from request: URLRequest) throws -> [String: Any] { + let data = try self.requestBodyData(from: request) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static func requestBodyData(from request: URLRequest) throws -> Data { + if let body = request.httpBody { + return body + } + guard let stream = request.httpBodyStream else { + throw URLError(.badServerResponse) + } + + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw stream.streamError ?? URLError(.cannotDecodeRawData) + } + if count == 0 { + break + } + data.append(buffer, count: count) + } + return data + } } final class FactoryStubURLProtocol: URLProtocol { nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + nonisolated(unsafe) static var requests: [URLRequest] = [] override static func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } @@ -110,6 +375,7 @@ final class FactoryStubURLProtocol: URLProtocol { return } do { + Self.requests.append(self.request) let (response, data) = try handler(self.request) self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) self.client?.urlProtocol(self, didLoad: data) From 0a830cecac791725f52d7097d10e0c1c1f0beca3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 04:10:48 +0100 Subject: [PATCH 0245/1239] chore: prepare 0.23 release --- CHANGELOG.md | 19 +++++----- .../Host/PTY/TTYCommandRunner.swift | 7 +++- .../Providers/Codex/CodexStatusProbe.swift | 5 ++- Sources/CodexBarCore/UsageFetcher.swift | 26 +++++++++---- .../CodexUsageFetcherFallbackTests.swift | 38 ++++++++++++++----- .../CostUsageJsonlPerformanceTests.swift | 3 +- .../FactoryStatusProbeFetchTests.swift | 6 +++ .../OpenAIDashboardWebViewCacheTests.swift | 11 ++++-- .../CodexBarTests/TTYCommandRunnerTests.swift | 2 +- .../UsageStorePlanUtilizationTests.swift | 2 +- 10 files changed, 81 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9ef375b4..aa2c4ae651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,22 @@ # Changelog -## 0.23 — Unreleased +## 0.23 — 2026-04-26 -### Changes +### Highlights +- Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic! - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! -- Codex: add GPT-5.5 and GPT-5.5 Pro pricing so local cost scanning recognizes the new models. - Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98! -- Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic! - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! +- Codex: add GPT-5.5 and GPT-5.5 Pro pricing so local cost scanning recognizes the new models. +- Copilot: show a clearer GitHub Device Flow hint in Settings when the copied device code needs to be pasted into GitHub (#369). Thanks @amoranio! ### Fixes -- Codex: ignore invalid zero-minute subscription history so the utilization submenu no longer shows duplicate Session tabs. -- Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. - Droid: preserve Factory session fallbacks, use the current usage endpoint, and clarify browser-login messaging (#792). Thanks @JosephDoUrden for the original stale-session fix! -- Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! - Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch! +- Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! +- Codex: ignore invalid zero-minute subscription history so the utilization submenu no longer shows duplicate Session tabs. +- CLI: report the app bundle version correctly when the bundled helper is launched through a symlink. +- Codex/Claude: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. ## 0.22 — 2026-04-21 @@ -35,7 +37,6 @@ ### Menu & Settings - Menu: show and handle standard shortcuts for Refresh (⌘R), Settings (⌘,), and Quit (⌘Q) while the status menu is open (#737). Thanks @anirudhvee! -- Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! - Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal! ### Fixes @@ -92,7 +93,7 @@ - Add Perplexity provider support with recurring, bonus, and purchased-credit tracking, Pro/Max plan detection, browser-cookie auto-import, and manual-cookie fallback (#449). Thanks @BeelixGit! - Add OpenCode Go as a separate provider with 5-hour, weekly, and monthly web usage tracking, widget integration, and browser-cookie support. - Claude: fix token and cost inflation caused by cross-file double counting of subagent JSONL logs, fix streaming chunk deduplication, and add `claude-sonnet-4-6` pricing. Thanks @enzonaute for the investigation! -- Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman! +- Cost history: include supported pi session usage in Codex/Claude provider history so provider charts reflect those local runs (#653). Thanks @ngutman! ### Providers & Usage - Perplexity: add recurring, bonus, and purchased-credit tracking; plan detection for Pro/Max; browser-cookie auto-import; and manual-cookie fallback (#449). Thanks @BeelixGit! diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 7608ef11b2..0f3c6b75c1 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -102,6 +102,7 @@ public struct TTYCommandRunner { public var stopOnURL: Bool public var stopOnSubstrings: [String] public var settleAfterStop: TimeInterval + public var forceCodexStatusMode: Bool public init( rows: UInt16 = 50, @@ -116,7 +117,8 @@ public struct TTYCommandRunner { sendOnSubstrings: [String: String] = [:], stopOnURL: Bool = false, stopOnSubstrings: [String] = [], - settleAfterStop: TimeInterval = 0.25) + settleAfterStop: TimeInterval = 0.25, + forceCodexStatusMode: Bool = false) { self.rows = rows self.cols = cols @@ -131,6 +133,7 @@ public struct TTYCommandRunner { self.stopOnURL = stopOnURL self.stopOnSubstrings = stopOnSubstrings self.settleAfterStop = settleAfterStop + self.forceCodexStatusMode = forceCodexStatusMode } } @@ -523,7 +526,7 @@ public struct TTYCommandRunner { let deadline = Date().addingTimeInterval(options.timeout) let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) - let isCodex = (binaryName == "codex") + let isCodex = (binaryName == "codex") || options.forceCodexStatusMode let isCodexStatus = isCodex && trimmed == "/status" var buffer = Data() diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index 13359aedb8..be7fdc8f29 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -216,7 +216,7 @@ public struct CodexStatusProbe { } } else { let runner = TTYCommandRunner() - let script = "/status\n" + let script = "/status" let result = try runner.run( binary: binary, send: script, @@ -225,7 +225,8 @@ public struct CodexStatusProbe { cols: cols, timeout: timeout, extraArgs: ["-s", "read-only", "-a", "untrusted"], - baseEnvironment: self.environment)) + baseEnvironment: self.environment, + forceCodexStatusMode: true)) text = result.text } return try Self.parse(text: text) diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 22ebd19c70..22f53e1c1c 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -594,10 +594,26 @@ private final class CodexRPCClient: @unchecked Sendable { // MARK: - Public fetcher used by the app public struct UsageFetcher: Sendable { + typealias CodexStatusFetcher = @Sendable ([String: String], Bool) async throws -> CodexStatusSnapshot + private let environment: [String: String] + private let codexStatusFetcher: CodexStatusFetcher public init(environment: [String: String] = ProcessInfo.processInfo.environment) { + self.init(environment: environment) { environment, keepCLISessionsAlive in + try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + environment: environment) + .fetch() + } + } + + init( + environment: [String: String], + codexStatusFetcher: @escaping CodexStatusFetcher) + { self.environment = environment + self.codexStatusFetcher = codexStatusFetcher LoginShellPathCache.shared.captureOnce() } @@ -644,10 +660,7 @@ public struct UsageFetcher: Sendable { private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { do { - let status = try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) - .fetch() + let status = try await self.codexStatusFetcher(self.environment, keepCLISessionsAlive) guard let state = CodexReconciledState.fromCLI( primary: Self.makeTTYWindow( percentLeft: status.fiveHourPercentLeft, @@ -694,10 +707,7 @@ public struct UsageFetcher: Sendable { private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { do { - let status = try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) - .fetch() + let status = try await self.codexStatusFetcher(self.environment, keepCLISessionsAlive) guard let credits = status.credits else { throw UsageError.noRateLimitsFound } return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) } catch { diff --git a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift index c581a484e4..4cd2442d91 100644 --- a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift +++ b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift @@ -1,7 +1,8 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore +@Suite(.serialized) struct CodexUsageFetcherFallbackTests { @Test func `CLI usage recovers from RPC decode mismatch body payload`() { @@ -36,7 +37,9 @@ struct CodexUsageFetcherFallbackTests { let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.decodeMismatchMessage) defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } - let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let fetcher = UsageFetcher( + environment: ["CODEX_CLI_PATH": stubCLIPath], + codexStatusFetcher: Self.stubTTYStatus) let snapshot = try await fetcher.loadLatestUsage() #expect(snapshot.primary?.usedPercent == 12) @@ -50,7 +53,9 @@ struct CodexUsageFetcherFallbackTests { let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.decodeMismatchMessage) defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } - let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let fetcher = UsageFetcher( + environment: ["CODEX_CLI_PATH": stubCLIPath], + codexStatusFetcher: Self.stubTTYStatus) let credits = try await fetcher.loadLatestCredits() #expect(credits.remaining == 42) @@ -61,7 +66,9 @@ struct CodexUsageFetcherFallbackTests { let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.partialDecodeBodyMessage) defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } - let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let fetcher = UsageFetcher( + environment: ["CODEX_CLI_PATH": stubCLIPath], + codexStatusFetcher: Self.stubTTYStatus) let snapshot = try await fetcher.loadLatestUsage() #expect(snapshot.primary?.usedPercent == 12) @@ -132,6 +139,21 @@ struct CodexUsageFetcherFallbackTests { } """ + private static func stubTTYStatus( + environment _: [String: String], + keepCLISessionsAlive _: Bool) async throws -> CodexStatusSnapshot + { + CodexStatusSnapshot( + credits: 42, + fiveHourPercentLeft: 88, + weeklyPercentLeft: 75, + fiveHourResetDescription: nil, + weeklyResetDescription: nil, + fiveHourResetsAt: nil, + weeklyResetsAt: nil, + rawText: "Credits: 42 credits\n5h limit: [#####] 88% left\nWeekly limit: [##] 75% left\n") + } + private func makeDecodeMismatchStubCodexCLI( message: String = Self.decodeMismatchBodyMessage) throws -> String @@ -178,12 +200,8 @@ struct CodexUsageFetcherFallbackTests { print(json.dumps(payload), flush=True) else: - for line in sys.stdin: - if "/status" in line: - break - print("Credits: 42 credits", flush=True) - print("5h limit: [#####] 88% left", flush=True) - print("Weekly limit: [##] 75% left", flush=True) + sys.stdout.write("Credits: 42 credits\\n5h limit: [#####] 88% left\\nWeekly limit: [##] 75% left\\n") + sys.stdout.flush() """ let url = FileManager.default.temporaryDirectory .appendingPathComponent("codex-fallback-stub-\(UUID().uuidString)", isDirectory: false) diff --git a/Tests/CodexBarTests/CostUsageJsonlPerformanceTests.swift b/Tests/CodexBarTests/CostUsageJsonlPerformanceTests.swift index 2c15e85ec3..bc3d347ed8 100644 --- a/Tests/CodexBarTests/CostUsageJsonlPerformanceTests.swift +++ b/Tests/CodexBarTests/CostUsageJsonlPerformanceTests.swift @@ -62,7 +62,8 @@ struct CostUsageJsonlPerformanceTests { scanner: scanWithFrontBufferBaseline) let speedup = Double(baselineFastest) / Double(currentFastest) - #expect(speedup >= 5.0) + // Keep the guard high enough to catch regressions without making the full test suite depend on an idle CPU. + #expect(speedup >= 4.0) } } diff --git a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift index 48f4ccf876..660091a5fa 100644 --- a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift +++ b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift @@ -6,6 +6,9 @@ import Testing struct FactoryStatusProbeFetchTests { @Test func `keeps stored Factory cookies available when cached header is not logged in`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) defer { if registered { @@ -102,6 +105,9 @@ struct FactoryStatusProbeFetchTests { @Test func `preserves stored Factory refresh token when stored cookies are not logged in`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) defer { if registered { diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index 1b361879d9..fb88a4103a 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -192,10 +192,13 @@ struct OpenAIDashboardWebViewCacheTests { #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") - try? await Task.sleep(for: .milliseconds(450)) - - let bodyText = try await webView.evaluateJavaScript( - "document.body ? String(document.body.innerText || '') : ''") as? String + var bodyText: String? + let deadline = Date().addingTimeInterval(2) + repeat { + try? await Task.sleep(for: .milliseconds(100)) + bodyText = try await webView.evaluateJavaScript( + "document.body ? String(document.body.innerText || '') : ''") as? String + } while (cache.hasPreservedPageForTesting(for: store) || bodyText?.isEmpty != true) && Date() < deadline #expect(!cache.hasPreservedPageForTesting(for: store), "Expected scheduled expiry to clear preserved page") #expect(bodyText?.isEmpty == true, "Expected scheduled expiry to detach the preserved page to about:blank") diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index 2bfcebc853..1a14b6fe4f 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -180,7 +180,7 @@ struct TTYCommandRunnerEnvTests { binary: scriptURL.path, send: "", options: .init( - timeout: 6, + timeout: 15, // Use LF for portability: some PTY/termios setups do not translate CR → NL for shell reads. sendOnSubstrings: ["trust the files in this folder?": "y\n"], stopOnSubstrings: ["accepted", "rejected"], diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 539786eb75..e05e67f2f4 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -1133,7 +1133,7 @@ func findSeries( } private final class WeeklyLimitResetEventRecorder: @unchecked Sendable { - struct Event: Sendable { + struct Event { let provider: UsageProvider let accountLabel: String? let usedPercent: Double From d40133d8b2ed60156e5482d46da129c28acb7021 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 04:51:53 +0100 Subject: [PATCH 0246/1239] docs: update appcast for 0.23 --- appcast.xml | 68 +++++++++++++++++++++++------------------------------ 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/appcast.xml b/appcast.xml index 635565eac6..a63ac1b25a 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,36 @@ CodexBar + + 0.23 + Sun, 26 Apr 2026 04:51:49 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 58 + 0.23 + 14.0 + CodexBar 0.23 +

Highlights

+
    +
  • Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic!
  • +
  • Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy!
  • +
  • Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98!
  • +
  • Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats!
  • +
  • Codex: add GPT-5.5 and GPT-5.5 Pro pricing so local cost scanning recognizes the new models.
  • +
  • Copilot: show a clearer GitHub Device Flow hint in Settings when the copied device code needs to be pasted into GitHub (#369). Thanks @amoranio!
  • +
+

Fixes

+
    +
  • Droid: preserve Factory session fallbacks, use the current usage endpoint, and clarify browser-login messaging (#792). Thanks @JosephDoUrden for the original stale-session fix!
  • +
  • Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch!
  • +
  • Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman!
  • +
  • Codex: ignore invalid zero-minute subscription history so the utilization submenu no longer shows duplicate Session tabs.
  • +
  • CLI: report the app bundle version correctly when the bundled helper is launched through a symlink.
  • +
  • Codex/Claude: clean up cached CLI status probes during app shutdown so codex -s read-only workers are not orphaned after restart.
  • +
+

View full changelog

+]]>
+ +
0.22 Tue, 21 Apr 2026 01:12:52 +0100 @@ -99,44 +129,6 @@ ]]> - - 0.20 - Wed, 08 Apr 2026 04:42:18 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 55 - 0.20 - 14.0 - CodexBar 0.20 -

Highlights

-
    -
  • Codex: switch between system accounts/profiles without manually logging out and back in. @ratulsarna
  • -
  • Add Perplexity provider support with recurring, bonus, and purchased-credit tracking, Pro/Max plan detection, browser-cookie auto-import, and manual-cookie fallback (#449). Thanks @BeelixGit!
  • -
  • Add OpenCode Go as a separate provider with 5-hour, weekly, and monthly web usage tracking, widget integration, and browser-cookie support.
  • -
  • Claude: fix token and cost inflation caused by cross-file double counting of subagent JSONL logs, fix streaming chunk deduplication, and add claude-sonnet-4-6 pricing. Thanks @enzonaute for the investigation!
  • -
  • Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman!
  • -
-

Providers & Usage

-
    -
  • Perplexity: add recurring, bonus, and purchased-credit tracking; plan detection for Pro/Max; browser-cookie auto-import; and manual-cookie fallback (#449). Thanks @BeelixGit!
  • -
  • OpenCode Go: add a dedicated provider, parse live authenticated workspace Go usage from the web app, keep monthly optional and honor workspace env overrides.
  • -
  • Codex: add workspace attribution for account labels and same-email multi-workspace accounts.
  • -
  • Codex: reconcile live-system and managed accounts by canonical identity, preserve account-scoped usage/history/dashboard state, allow OAuth CLI fallback, and tighten OpenAI web ownership gating so quota and credits only attach to the matching account. Thanks @monterrr and @Rag30 for the initial effort and ideas!
  • -
  • Codex: normalize weekly-only rate limits across OAuth and CLI/RPC so free-plan accounts render as Weekly instead of a fake Session, preserve unknown single-window payloads in the primary lane, hide the empty Session lane in widgets, and accept weekly-only Codex CLI /status/RPC data without failing. @ratulsarna
  • -
  • Codex: refactor the provider end to end into clearer components and better division of responsibilities.
  • -
  • OpenCode: preserve product separation between Zen and Go, improve null/unsupported usage handling, and harden cookie/domain behavior for authenticated web fetches.
  • -
  • Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman!
  • -
-

Menu & Settings

-
    -
  • Codex: add UI for switching the system-level Codex account and promoting a managed account into the live system slot.
  • -
  • Codex: hide display-only OpenAI web extras in widgets and fix buy-credits / credits-only presentation regressions.
  • -
  • Claude: enable “Avoid Keychain prompts” by default, remove the experimental label, and preserve user-action cooldown clearing plus startup bootstrap when Security.framework fallback is still needed.
  • -
  • Fix alignment of menu chart hover coordinates on macOS. Thanks @cuidong233!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 2fd48406c6e1058cad73fe9143cde244497b6d5e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 05:37:40 +0100 Subject: [PATCH 0247/1239] chore: start 0.24 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2c4ae651..ee814c1d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.24 — Unreleased + ## 0.23 — 2026-04-26 ### Highlights diff --git a/version.env b/version.env index 073e2844fa..b8eba37a61 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.23 -BUILD_NUMBER=58 +MARKETING_VERSION=0.24 +BUILD_NUMBER=59 From 1f88336dcf5a2ea485e7d3af4b9ee575a88c4c10 Mon Sep 17 00:00:00 2001 From: leezhuuuuu Date: Sun, 26 Apr 2026 22:49:20 +0800 Subject: [PATCH 0248/1239] Fix Codex workspace account matching Treat provider-backed Codex account identities as email plus workspace account ID instead of assuming the workspace ID is globally unique. Different OpenAI users can belong to the same team workspace, so matching only on providerAccountID caused add-account flows to replace an existing email with the newly authenticated email. Prompt for a workspace when the login exposes multiple OpenAI workspaces, persist the selected workspace ID into the managed auth file, and keep workspace labels cached for display. Reconciliation, visible account projection, and promotion no-op checks now use email-aware provider identity matching so live and managed rows for different emails in the same workspace do not collapse. Add regression coverage for selecting and persisting workspaces, preserving same-workspace accounts with different emails, and avoiding live/managed row merging when provider account IDs match but emails differ. --- .../CodexAccountPromotionPlanning.swift | 29 +++- .../CodexAccountPromotionService.swift | 11 +- .../CodexBar/CodexAccountReconciliation.swift | 6 +- .../CodexBar/ManagedCodexAccountService.swift | 140 +++++++++++++++++- .../CodexBar/PreferencesProvidersPane.swift | 2 + .../StatusItemController+Actions.swift | 2 + .../CodexBarCore/CodexManagedAccounts.swift | 10 +- .../Codex/CodexAccountReconciliation.swift | 26 +++- .../Codex/CodexOpenAIWorkspaceResolver.swift | 27 +++- ...tProviderIdentityReconciliationTests.swift | 48 ++++++ .../ManagedCodexAccountServiceTests.swift | 131 +++++++++++++++- .../ManagedCodexAccountStoreTests.swift | 30 ++++ 12 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift diff --git a/Sources/CodexBar/CodexAccountPromotionPlanning.swift b/Sources/CodexBar/CodexAccountPromotionPlanning.swift index ed54274858..8fd94ea83a 100644 --- a/Sources/CodexBar/CodexAccountPromotionPlanning.swift +++ b/Sources/CodexBar/CodexAccountPromotionPlanning.swift @@ -58,7 +58,11 @@ struct CodexDisplacedLivePreservationPlanner { } if let targetAuthIdentity = context.target.authIdentity, - CodexIdentityMatcher.matches(targetAuthIdentity.identity, liveAuthIdentity.identity) + CodexIdentityMatcher.matches( + targetAuthIdentity.identity, + lhsEmail: targetAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) { return .none(reason: .targetMatchesLiveAuthIdentity) } @@ -96,7 +100,11 @@ struct CodexDisplacedLivePreservationPlanner { { candidates.first { candidate in guard let candidateAuthIdentity = candidate.authIdentity else { return false } - return CodexIdentityMatcher.matches(candidateAuthIdentity.identity, liveAuthIdentity.identity) + return CodexIdentityMatcher.matches( + candidateAuthIdentity.identity, + lhsEmail: candidateAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) } } @@ -108,8 +116,12 @@ struct CodexDisplacedLivePreservationPlanner { switch liveAuthIdentity.identity { case let .providerAccount(id): let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(id) - if let destination = candidates.first(where: { $0.persisted.providerAccountID == providerAccountID }), - let reason = self.providerRepairReason(for: destination) + if let destination = candidates.first(where: { + guard $0.persisted.providerAccountID == providerAccountID else { return false } + guard let liveEmail = liveAuthIdentity.email else { return true } + return $0.persisted.email == liveEmail + }), + let reason = self.providerRepairReason(for: destination) { return (destination, reason) } @@ -149,9 +161,16 @@ struct CodexDisplacedLivePreservationPlanner { let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(id) return candidates.contains { candidate in guard candidate.persisted.providerAccountID == providerAccountID else { return false } + if let liveEmail = liveAuthIdentity.email, candidate.persisted.email != liveEmail { + return false + } guard case .readable = candidate.homeState else { return false } guard let candidateAuthIdentity = candidate.authIdentity else { return false } - return !CodexIdentityMatcher.matches(candidateAuthIdentity.identity, liveAuthIdentity.identity) + return !CodexIdentityMatcher.matches( + candidateAuthIdentity.identity, + lhsEmail: candidateAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) } } diff --git a/Sources/CodexBar/CodexAccountPromotionService.swift b/Sources/CodexBar/CodexAccountPromotionService.swift index c8a4abb73e..c8bf589699 100644 --- a/Sources/CodexBar/CodexAccountPromotionService.swift +++ b/Sources/CodexBar/CodexAccountPromotionService.swift @@ -259,7 +259,12 @@ final class CodexAccountPromotionService { private func convergedActiveSource(for context: PreparedPromotionContext) -> CodexActiveSource? { if let liveAuthIdentity = context.live.authIdentity { let targetIdentity = context.target.authIdentity ?? context.target.persistedIdentity - guard CodexIdentityMatcher.matches(targetIdentity.identity, liveAuthIdentity.identity) else { + guard CodexIdentityMatcher.matches( + targetIdentity.identity, + lhsEmail: targetIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) + else { return nil } @@ -280,7 +285,9 @@ final class CodexAccountPromotionService { guard CodexIdentityMatcher.matches( context.snapshot.runtimeIdentity(for: context.target.persisted), - context.snapshot.runtimeIdentity(for: liveSystemAccount)) + lhsEmail: context.snapshot.runtimeEmail(for: context.target.persisted), + context.snapshot.runtimeIdentity(for: liveSystemAccount), + rhsEmail: liveSystemAccount.email) else { return nil } diff --git a/Sources/CodexBar/CodexAccountReconciliation.swift b/Sources/CodexBar/CodexAccountReconciliation.swift index 782ec12ac2..69d59b3a5b 100644 --- a/Sources/CodexBar/CodexAccountReconciliation.swift +++ b/Sources/CodexBar/CodexAccountReconciliation.swift @@ -102,7 +102,11 @@ extension CodexVisibleAccountProjection { let normalizedEmail = Self.normalizeVisibleEmail(liveSystemAccount.email) let liveIdentity = snapshot.runtimeIdentity(for: liveSystemAccount) if let existingIndex = drafts.firstIndex(where: { draft in - CodexIdentityMatcher.matches(draft.identity, liveIdentity) + CodexIdentityMatcher.matches( + draft.identity, + lhsEmail: draft.email, + liveIdentity, + rhsEmail: normalizedEmail) }) { let existingDraft = drafts[existingIndex] let liveWorkspaceLabel = Self.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel) diff --git a/Sources/CodexBar/ManagedCodexAccountService.swift b/Sources/CodexBar/ManagedCodexAccountService.swift index b28bd58e22..11b0c2b783 100644 --- a/Sources/CodexBar/ManagedCodexAccountService.swift +++ b/Sources/CodexBar/ManagedCodexAccountService.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation @@ -16,11 +17,27 @@ protocol ManagedCodexIdentityReading: Sendable { protocol ManagedCodexWorkspaceResolving: Sendable { func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? + func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] +} + +extension ManagedCodexWorkspaceResolving { + func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] { + [] + } +} + +protocol ManagedCodexWorkspaceSelecting: Sendable { + @MainActor + func selectWorkspace( + email: String, + currentWorkspaceID: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? } enum ManagedCodexAccountServiceError: Error, Equatable { case loginFailed case missingEmail + case workspaceSelectionCancelled case unsafeManagedHome(String) } @@ -102,6 +119,65 @@ struct DefaultManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { workspaceAccountID: normalizedProviderAccountID, workspaceLabel: cachedLabel) } + + func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] { + let env = CodexHomeScope.scopedEnvironment( + base: ProcessInfo.processInfo.environment, + codexHome: homePath) + guard let credentials = try? CodexOAuthCredentialsStore.load(env: env), + let identities = try? await CodexOpenAIWorkspaceResolver.listWorkspaces(credentials: credentials) + else { + return [] + } + + for identity in identities { + try? self.workspaceCache.store(identity) + } + return identities + } +} + +struct CodexWorkspaceAlertSelector: ManagedCodexWorkspaceSelecting { + @MainActor + func selectWorkspace( + email: String, + currentWorkspaceID: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? + { + guard workspaces.count > 1 else { return workspaces.first } + + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26), pullsDown: false) + let sortedWorkspaces = workspaces.sorted { lhs, rhs in + self.workspaceTitle(lhs) < self.workspaceTitle(rhs) + } + for workspace in sortedWorkspaces { + popup.addItem(withTitle: self.workspaceTitle(workspace)) + popup.lastItem?.representedObject = workspace.workspaceAccountID + } + if let currentWorkspaceID, + let selectedIndex = sortedWorkspaces.firstIndex(where: { $0.workspaceAccountID == currentWorkspaceID }) + { + popup.selectItem(at: selectedIndex) + } + + let alert = NSAlert() + alert.messageText = "Choose Codex workspace" + alert.informativeText = "CodexBar found multiple workspaces for \(email). Choose the one to add." + alert.alertStyle = .informational + alert.accessoryView = popup + alert.addButton(withTitle: "Add Workspace") + alert.addButton(withTitle: "Cancel") + + guard alert.runModal() == .alertFirstButtonReturn else { + return nil + } + let selectedWorkspaceID = popup.selectedItem?.representedObject as? String + return sortedWorkspaces.first { $0.workspaceAccountID == selectedWorkspaceID } + } + + private func workspaceTitle(_ workspace: CodexOpenAIWorkspaceIdentity) -> String { + workspace.workspaceLabel ?? workspace.workspaceAccountID + } } @MainActor @@ -111,6 +187,7 @@ final class ManagedCodexAccountService { private let loginRunner: any ManagedCodexLoginRunning private let identityReader: any ManagedCodexIdentityReading private let workspaceResolver: any ManagedCodexWorkspaceResolving + private let workspaceSelector: any ManagedCodexWorkspaceSelecting private let fileManager: FileManager init( @@ -119,6 +196,7 @@ final class ManagedCodexAccountService { loginRunner: any ManagedCodexLoginRunning, identityReader: any ManagedCodexIdentityReading, workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(), + workspaceSelector: any ManagedCodexWorkspaceSelecting = CodexWorkspaceAlertSelector(), fileManager: FileManager = .default) { self.store = store @@ -126,6 +204,7 @@ final class ManagedCodexAccountService { self.loginRunner = loginRunner self.identityReader = identityReader self.workspaceResolver = workspaceResolver + self.workspaceSelector = workspaceSelector self.fileManager = fileManager } @@ -136,6 +215,7 @@ final class ManagedCodexAccountService { loginRunner: DefaultManagedCodexLoginRunner(), identityReader: DefaultManagedCodexIdentityReader(), workspaceResolver: DefaultManagedCodexWorkspaceResolver(), + workspaceSelector: CodexWorkspaceAlertSelector(), fileManager: fileManager) } @@ -160,18 +240,23 @@ final class ManagedCodexAccountService { else { throw ManagedCodexAccountServiceError.missingEmail } - let providerAccountID: String? = switch identity.identity { + let authenticatedProviderAccountID: String? = switch identity.identity { case let .providerAccount(id): ManagedCodexAccount.normalizeProviderAccountID(id) case .emailOnly, .unresolved: nil } - let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let providerAccountID { - await self.workspaceResolver.resolveWorkspaceIdentity( + let selectedWorkspace = try await self.selectedWorkspaceIdentity( + email: rawEmail, + homePath: homeURL.path, + authenticatedProviderAccountID: authenticatedProviderAccountID) + let providerAccountID = selectedWorkspace?.workspaceAccountID ?? authenticatedProviderAccountID + let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let selectedWorkspace { + selectedWorkspace + } else { + await self.resolvedWorkspaceIdentity( homePath: homeURL.path, providerAccountID: providerAccountID) - } else { - nil } let now = Date().timeIntervalSince1970 @@ -245,6 +330,51 @@ final class ManagedCodexAccountService { } } + private func selectedWorkspaceIdentity( + email: String, + homePath: String, + authenticatedProviderAccountID: String?) async throws -> CodexOpenAIWorkspaceIdentity? + { + let workspaces = await self.workspaceResolver.availableWorkspaceIdentities(homePath: homePath) + guard workspaces.count > 1 else { + return workspaces.first { $0.workspaceAccountID == authenticatedProviderAccountID } + } + guard let selected = await self.workspaceSelector.selectWorkspace( + email: email, + currentWorkspaceID: authenticatedProviderAccountID, + workspaces: workspaces) + else { + throw ManagedCodexAccountServiceError.workspaceSelectionCancelled + } + try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath) + return selected + } + + private func resolvedWorkspaceIdentity( + homePath: String, + providerAccountID: String?) async -> CodexOpenAIWorkspaceIdentity? + { + guard let providerAccountID else { return nil } + return await self.workspaceResolver.resolveWorkspaceIdentity( + homePath: homePath, + providerAccountID: providerAccountID) + } + + private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws { + let env = CodexHomeScope.scopedEnvironment( + base: ProcessInfo.processInfo.environment, + codexHome: homePath) + let credentials = try CodexOAuthCredentialsStore.load(env: env) + try CodexOAuthCredentialsStore.save( + CodexOAuthCredentials( + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + idToken: credentials.idToken, + accountId: workspaceID, + lastRefresh: credentials.lastRefresh), + env: env) + } + private func reconciledExistingAccount( authenticatedEmail: String, providerAccountID: String?, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index a5738086c0..19e50a9242 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -611,6 +611,8 @@ struct ProvidersPane: View { case .missingEmail: "Codex login completed, but no account email was available. Try again after confirming " + "the account is fully signed in." + case .workspaceSelectionCancelled: + "CodexBar found multiple workspaces, but no workspace was selected." case let .unsafeManagedHome(path): "CodexBar refused to modify an unexpected managed home path: \(path)" } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index e9fcf6f66a..44fa579507 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -292,6 +292,8 @@ extension StatusItemController { case .missingEmail: "Codex login completed, but no account email was available. " + "Try again after confirming the account is fully signed in." + case .workspaceSelectionCancelled: + "CodexBar found multiple workspaces, but no workspace was selected." case let .unsafeManagedHome(path): "CodexBar refused to modify an unexpected managed home path: \(path)" } diff --git a/Sources/CodexBarCore/CodexManagedAccounts.swift b/Sources/CodexBarCore/CodexManagedAccounts.swift index cdd104a438..2833544d51 100644 --- a/Sources/CodexBarCore/CodexManagedAccounts.swift +++ b/Sources/CodexBarCore/CodexManagedAccounts.swift @@ -86,7 +86,9 @@ public struct ManagedCodexAccountSet: Codable, Sendable { public func account(email: String, providerAccountID: String? = nil) -> ManagedCodexAccount? { let normalizedEmail = ManagedCodexAccount.normalizeEmail(email) if let normalizedProviderAccountID = ManagedCodexAccount.normalizeProviderAccountID(providerAccountID), - let exactMatch = self.accounts.first(where: { $0.providerAccountID == normalizedProviderAccountID }) + let exactMatch = self.accounts.first(where: { + $0.email == normalizedEmail && $0.providerAccountID == normalizedProviderAccountID + }) { return exactMatch } @@ -104,7 +106,7 @@ public struct ManagedCodexAccountSet: Codable, Sendable { private static func sanitizedAccounts(_ accounts: [ManagedCodexAccount]) -> [ManagedCodexAccount] { var seenIDs: Set = [] - var seenProviderAccountIDs: Set = [] + var seenProviderAccountKeys: Set = [] var seenLegacyEmails: Set = [] var sanitized: [ManagedCodexAccount] = [] sanitized.reserveCapacity(accounts.count) @@ -112,7 +114,9 @@ public struct ManagedCodexAccountSet: Codable, Sendable { for account in accounts { guard seenIDs.insert(account.id).inserted else { continue } if let providerAccountID = account.providerAccountID { - guard seenProviderAccountIDs.insert(providerAccountID).inserted else { continue } + guard seenProviderAccountKeys.insert("\(account.email)\u{0}\(providerAccountID)").inserted else { + continue + } } else { guard seenLegacyEmails.insert(account.email).inserted else { continue } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift index c8cb607951..532dbf520f 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift @@ -44,7 +44,9 @@ public enum CodexActiveSourceResolver { guard let liveSystemAccount else { return false } return CodexIdentityMatcher.matches( snapshot.runtimeIdentity(for: storedAccount), - snapshot.runtimeIdentity(for: liveSystemAccount)) + lhsEmail: snapshot.runtimeEmail(for: storedAccount), + snapshot.runtimeIdentity(for: liveSystemAccount), + rhsEmail: liveSystemAccount.email) } } @@ -155,7 +157,11 @@ public struct DefaultCodexAccountReconciler { let matchingStoredAccountForLiveSystemAccount = liveSystemAccount.flatMap { liveAccount in accounts.accounts.first { account in guard let runtimeAccount = runtimeAccounts[account.id] else { return false } - return CodexIdentityMatcher.matches(runtimeAccount.identity, self.runtimeIdentity(for: liveAccount)) + return CodexIdentityMatcher.matches( + runtimeAccount.identity, + lhsEmail: runtimeAccount.email, + self.runtimeIdentity(for: liveAccount), + rhsEmail: liveAccount.email) } } @@ -234,6 +240,22 @@ public enum CodexIdentityMatcher { } } + public static func matches( + _ lhs: CodexIdentity, + lhsEmail: String?, + _ rhs: CodexIdentity, + rhsEmail: String?) -> Bool + { + guard self.matches(lhs, rhs) else { return false } + guard case .providerAccount = lhs, case .providerAccount = rhs else { return true } + guard let normalizedLeftEmail = CodexIdentityResolver.normalizeEmail(lhsEmail), + let normalizedRightEmail = CodexIdentityResolver.normalizeEmail(rhsEmail) + else { + return true + } + return normalizedLeftEmail == normalizedRightEmail + } + public static func normalized(_ identity: CodexIdentity, fallbackEmail: String) -> CodexIdentity { switch identity { case .providerAccount: diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift index 6887b4ce95..fabf412a35 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift @@ -44,13 +44,29 @@ public enum CodexOpenAIWorkspaceResolver { return nil } + let identities = try await self.listWorkspaces(credentials: credentials, session: session) + if let identity = identities.first(where: { $0.workspaceAccountID == workspaceAccountID }) { + return identity + } + + return CodexOpenAIWorkspaceIdentity( + workspaceAccountID: workspaceAccountID, + workspaceLabel: nil) + } + + public static func listWorkspaces( + credentials: CodexOAuthCredentials, + session: URLSession = .shared) async throws -> [CodexOpenAIWorkspaceIdentity] + { var request = URLRequest(url: self.accountsURL) request.httpMethod = "GET" request.timeoutInterval = 20 request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization") request.setValue("codex-cli", forHTTPHeaderField: "User-Agent") request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(workspaceAccountID, forHTTPHeaderField: "ChatGPT-Account-Id") + if let workspaceAccountID = normalizeWorkspaceAccountID(credentials.accountId) { + request.setValue(workspaceAccountID, forHTTPHeaderField: "ChatGPT-Account-Id") + } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse, @@ -60,17 +76,12 @@ public enum CodexOpenAIWorkspaceResolver { } let decoded = try JSONDecoder().decode(AccountsResponse.self, from: data) - if let account = decoded.items.first(where: { - Self.normalizeWorkspaceAccountID($0.id) == workspaceAccountID - }) { + return decoded.items.compactMap { account in + guard let workspaceAccountID = self.normalizeWorkspaceAccountID(account.id) else { return nil } return CodexOpenAIWorkspaceIdentity( workspaceAccountID: workspaceAccountID, workspaceLabel: self.resolveWorkspaceLabel(from: account)) } - - return CodexOpenAIWorkspaceIdentity( - workspaceAccountID: workspaceAccountID, - workspaceLabel: nil) } public static func normalizeWorkspaceAccountID(_ value: String?) -> String? { diff --git a/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift b/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift new file mode 100644 index 0000000000..79517f800e --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift @@ -0,0 +1,48 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexAccountProviderIdentityReconciliationTests { + @Test + func `same provider account id with different email does not merge live and managed rows`() { + let stored = ManagedCodexAccount( + id: UUID(), + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + managedHomePath: "/tmp/managed-a", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let live = ObservedSystemCodexAccount( + email: "mich.aelfmk5542@gmail.com", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "team-4107")) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [stored], + activeStoredAccount: stored, + liveSystemAccount: live, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: stored.id), + hasUnreadableAddedAccountStore: false, + storedAccountRuntimeIdentities: [stored.id: .providerAccount(id: "team-4107")], + storedAccountRuntimeEmails: [stored.id: "mi.chaelfmk5542@gmail.com"]) + + let resolution = CodexActiveSourceResolver.resolve(from: snapshot) + let projection = CodexVisibleAccountProjection.make(from: snapshot) + + #expect(resolution.resolvedSource == .managedAccount(id: stored.id)) + #expect(projection.visibleAccounts.count == 2) + #expect(projection.visibleAccounts.map(\.email).sorted() == [ + "mi.chaelfmk5542@gmail.com", + "mich.aelfmk5542@gmail.com", + ]) + #expect(projection.activeVisibleAccountID == "mi.chaelfmk5542@gmail.com") + #expect(projection.liveVisibleAccountID == "mich.aelfmk5542@gmail.com") + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift index 48c0063afb..834c76693d 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift @@ -113,6 +113,101 @@ struct ManagedCodexAccountServiceTests { #expect(FileManager.default.fileExists(atPath: storedTeam.managedHomePath)) } + @Test + func `same workspace provider id with different emails does not overwrite existing account`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let existingHome = root.appendingPathComponent("accounts/existing", isDirectory: true) + try FileManager.default.createDirectory(at: existingHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let existingID = try #require(UUID(uuidString: "10101010-2020-3030-4040-505050505050")) + let existingAccount = ManagedCodexAccount( + id: existingID, + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + managedHomePath: existingHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [existingAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "team-4107"), email: "mich.aelfmk5542@gmail.com", plan: "Team"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver(identities: [ + "team-4107": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "team-4107", + workspaceLabel: "4107"), + ])) + + let added = try await service.authenticateManagedAccount() + + let original = try #require( + store.snapshot.account(email: "mi.chaelfmk5542@gmail.com", providerAccountID: "team-4107")) + let newAccount = try #require( + store.snapshot.account(email: "mich.aelfmk5542@gmail.com", providerAccountID: "team-4107")) + #expect(store.snapshot.accounts.count == 2) + #expect(original.id == existingID) + #expect(original.managedHomePath == existingHome.path) + #expect(newAccount.id == added.id) + #expect(newAccount.id != existingID) + #expect(newAccount.workspaceLabel == "4107") + #expect(FileManager.default.fileExists(atPath: existingHome.path)) + #expect(FileManager.default.fileExists(atPath: newAccount.managedHomePath)) + } + + @Test + func `selected workspace is persisted and used as account identity`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [])) + let workspaces = [ + CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-personal", + workspaceLabel: "Personal"), + CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-team", + workspaceLabel: "Team"), + ] + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: WritingManagedCodexLoginRunner( + credentials: CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: nil, + accountId: "workspace-personal", + lastRefresh: nil)), + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "workspace-personal"), email: "alice@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver( + identities: Dictionary(uniqueKeysWithValues: workspaces.map { ($0.workspaceAccountID, $0) }), + availableIdentities: workspaces), + workspaceSelector: StubManagedCodexWorkspaceSelector(selectedWorkspaceID: "workspace-team")) + + let account = try await service.authenticateManagedAccount() + let credentials = try CodexOAuthCredentialsStore.load(env: ["CODEX_HOME": account.managedHomePath]) + + #expect(account.providerAccountID == "workspace-team") + #expect(account.workspaceLabel == "Team") + #expect(credentials.accountId == "workspace-team") + #expect(store.snapshot.accounts.count == 1) + } + @Test func `reauth keeps previous home when store write fails`() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -750,6 +845,19 @@ private struct StubManagedCodexLoginRunner: ManagedCodexLoginRunning { result: CodexLoginRunner.Result(outcome: .success, output: "ok")) } +private struct WritingManagedCodexLoginRunner: ManagedCodexLoginRunning { + let credentials: CodexOAuthCredentials + + func run(homePath: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + do { + try CodexOAuthCredentialsStore.save(self.credentials, env: ["CODEX_HOME": homePath]) + return CodexLoginRunner.Result(outcome: .success, output: "ok") + } catch { + return CodexLoginRunner.Result(outcome: .failed(status: 1), output: String(describing: error)) + } + } +} + private enum TestManagedCodexAccountStoreError: Error, Equatable { case writeFailed } @@ -787,9 +895,14 @@ private final class StubManagedCodexIdentityReader: ManagedCodexIdentityReading, private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { let identities: [String: CodexOpenAIWorkspaceIdentity] + let availableIdentities: [CodexOpenAIWorkspaceIdentity] - init(identities: [String: CodexOpenAIWorkspaceIdentity] = [:]) { + init( + identities: [String: CodexOpenAIWorkspaceIdentity] = [:], + availableIdentities: [CodexOpenAIWorkspaceIdentity] = []) + { self.identities = identities + self.availableIdentities = availableIdentities } func resolveWorkspaceIdentity( @@ -798,4 +911,20 @@ private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { self.identities[providerAccountID] } + + func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] { + self.availableIdentities + } +} + +private struct StubManagedCodexWorkspaceSelector: ManagedCodexWorkspaceSelecting { + let selectedWorkspaceID: String? + + func selectWorkspace( + email _: String, + currentWorkspaceID _: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? + { + workspaces.first { $0.workspaceAccountID == self.selectedWorkspaceID } + } } diff --git a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift index b07720ff08..274761cf53 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift @@ -204,6 +204,36 @@ func `FileManagedCodexAccountStore keeps same email rows when hydrated provider #expect(loaded.account(email: "user@example.com", providerAccountID: "account-beta")?.id == secondID) } +@Test +func `managed account set keeps same provider account I D when emails differ`() { + let firstID = UUID() + let secondID = UUID() + let first = ManagedCodexAccount( + id: firstID, + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + managedHomePath: "/tmp/managed-home-1", + createdAt: 10, + updatedAt: 20, + lastAuthenticatedAt: nil) + let second = ManagedCodexAccount( + id: secondID, + email: "mich.aelfmk5542@gmail.com", + providerAccountID: "team-4107", + managedHomePath: "/tmp/managed-home-2", + createdAt: 30, + updatedAt: 40, + lastAuthenticatedAt: nil) + + let set = ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [first, second]) + + #expect(set.accounts.count == 2) + #expect(set.account(email: "mi.chaelfmk5542@gmail.com", providerAccountID: "team-4107")?.id == firstID) + #expect(set.account(email: "mich.aelfmk5542@gmail.com", providerAccountID: "team-4107")?.id == secondID) +} + @Test func `FileManagedCodexAccountStore hydrates provider account I D from id token when account field is absent`() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) From 166f1cd18e170a6aa065b06e020a7a3d28777a09 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 28 Apr 2026 01:03:34 +0530 Subject: [PATCH 0249/1239] Update changelog for Codex workspace account matching --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee814c1d26..1a05f99212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.24 — Unreleased +### Fixes +- Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! + ## 0.23 — 2026-04-26 ### Highlights From 4839ca3003c825ef482c2c6ecabe4eda7350f3da Mon Sep 17 00:00:00 2001 From: hello-amed <79072382+hello-amed@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:54:12 +1000 Subject: [PATCH 0250/1239] feat: add claudePeakHoursEnabled setting and integrate into UsageMenu (#611) * feat: add claudePeakHoursEnabled setting and integrate into UsageMenuCardView and ClaudeProviderImplementation - Introduced `claudePeakHoursEnabled` property in SettingsStore and SettingsStoreState. - Updated UsageMenuCardView to utilize the new setting for displaying peak hours status. - Added toggle for peak hours visibility in ClaudeProviderImplementation. - Ensured persistence of the setting in user defaults. * Resolved PR feedback: - Updated `claudePeakHoursEnabled` to default to true in the UsageMenuCardView model. - Added tests to verify peak hours note visibility when the setting is enabled and disabled. * Update ProvidersPane to include claudePeakHoursEnabled in UsageMenuCardView model * Refine subtitle for peak hours indicator in ClaudeProviderImplementation * Refactor peak hours calculation in ClaudePeakHours to improve accuracy and readability. Introduced a new method for determining the next peak start time and updated related tests for consistency. * refactor: update MenuCardModelTests to use CodexConsumerProjection for credits handling * fix: adjust date handling in ClaudePeakHours and enhance tests for second precision * Fix Claude peak hours lint --------- Co-authored-by: Ratul Sarna --- Sources/CodexBar/MenuCardView.swift | 8 + .../CodexBar/PreferencesProvidersPane.swift | 1 + .../Claude/ClaudeProviderImplementation.swift | 16 ++ Sources/CodexBar/SettingsStore+Defaults.swift | 8 + Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../CodexBar/StatusItemController+Menu.swift | 1 + .../Providers/Claude/ClaudePeakHours.swift | 83 +++++++++ .../CodexBarTests/ClaudePeakHoursTests.swift | 159 ++++++++++++++++++ .../MenuCardOptionalUsageModelTests.swift | 140 +++++++++++++++ 10 files changed, 419 insertions(+) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift create mode 100644 Tests/CodexBarTests/ClaudePeakHoursTests.swift create mode 100644 Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c97aed64b1..297dd71bd8 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -670,6 +670,7 @@ extension UsageMenuCardView.Model { let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool + let claudePeakHoursEnabled: Bool let weeklyPace: UsagePace? let now: Date @@ -694,6 +695,7 @@ extension UsageMenuCardView.Model { sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, + claudePeakHoursEnabled: Bool = true, weeklyPace: UsagePace? = nil, now: Date) { @@ -717,6 +719,7 @@ extension UsageMenuCardView.Model { self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo + self.claudePeakHoursEnabled = claudePeakHoursEnabled self.weeklyPace = weeklyPace self.now = now } @@ -788,6 +791,11 @@ extension UsageMenuCardView.Model { return notes } + if input.provider == .claude, input.claudePeakHoursEnabled { + let peakStatus = ClaudePeakHours.status(at: input.now) + return [peakStatus.label] + } + guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 19e50a9242..8cf0ff608d 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -579,6 +579,7 @@ struct ProvidersPane: View { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, hidePersonalInfo: self.settings.hidePersonalInfo, + claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, now: now) return UsageMenuCardView.Model.make(input) diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index cc85f8fa02..da82c17344 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -26,6 +26,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeOAuthKeychainPromptMode _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled + _ = settings.claudePeakHoursEnabled } @MainActor @@ -77,6 +78,10 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.claudeOAuthPromptFreeCredentialsEnabled = enabled }) + let peakHoursBinding = Binding( + get: { context.settings.claudePeakHoursEnabled }, + set: { context.settings.claudePeakHoursEnabled = $0 }) + return [ ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", @@ -89,6 +94,17 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "claude-peak-hours", + title: "Show peak hours indicator", + subtitle: "Show whether Claude is in peak usage hours.", + binding: peakHoursBinding, + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ] } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index ab32e57975..c4705bcc69 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -257,6 +257,14 @@ extension SettingsStore { } } + var claudePeakHoursEnabled: Bool { + get { self.defaultsState.claudePeakHoursEnabled } + set { + self.defaultsState.claudePeakHoursEnabled = newValue + self.userDefaults.set(newValue, forKey: "claudePeakHoursEnabled") + } + } + var showOptionalCreditsAndExtraUsage: Bool { get { self.defaultsState.showOptionalCreditsAndExtraUsage } set { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index b006f6d0eb..6d3e76e4f1 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -263,6 +263,7 @@ extension SettingsStore { let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false + let claudePeakHoursEnabled = userDefaults.object(forKey: "claudePeakHoursEnabled") as? Bool ?? true let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true if creditsExtrasDefault == nil { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") } @@ -308,6 +309,7 @@ extension SettingsStore { claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, + claudePeakHoursEnabled: claudePeakHoursEnabled, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index a65fb45d51..c28de8f631 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -26,6 +26,7 @@ struct SettingsDefaultsState { var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? var claudeWebExtrasEnabledRaw: Bool + var claudePeakHoursEnabled: Bool var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 48a295e5e2..f3c9247c35 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1437,6 +1437,7 @@ extension StatusItemController { sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, + claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, now: now) return UsageMenuCardView.Model.make(input) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift new file mode 100644 index 0000000000..166c331ad6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift @@ -0,0 +1,83 @@ +import Foundation + +public enum ClaudePeakHours: Sendable { + private static let peakTimeZone = TimeZone(identifier: "America/New_York")! + private static let peakStartHour = 8 + private static let peakEndHour = 14 + + public struct Status: Sendable, Equatable { + public let isPeak: Bool + public let label: String + } + + public static func status(at date: Date) -> Status { + let calendar = self.calendar() + let date = calendar.dateInterval(of: .minute, for: date)?.start ?? date + let components = calendar.dateComponents([.hour, .minute, .weekday], from: date) + + guard let hour = components.hour, + let minute = components.minute, + let weekday = components.weekday + else { + return Status(isPeak: false, label: "Off-peak") + } + + let isWeekday = weekday >= 2 && weekday <= 6 + let nowMinutes = hour * 60 + minute + let peakStartMinutes = self.peakStartHour * 60 + let peakEndMinutes = self.peakEndHour * 60 + let isInPeakWindow = nowMinutes >= peakStartMinutes && nowMinutes < peakEndMinutes + + if isWeekday, isInPeakWindow { + let remaining = peakEndMinutes - nowMinutes + return Status( + isPeak: true, + label: "Peak · ends in \(self.formatDuration(minutes: remaining))") + } + + let nextPeak = self.nextPeakStart(after: date, calendar: calendar) + let seconds = nextPeak.timeIntervalSince(date) + let minutes = max(Int(seconds / 60), 0) + return Status( + isPeak: false, + label: "Off-peak · peak in \(self.formatDuration(minutes: minutes))") + } + + private static func nextPeakStart(after date: Date, calendar: Calendar) -> Date { + guard let todayPeak = calendar.date( + bySettingHour: self.peakStartHour, + minute: 0, + second: 0, + of: date) else { return date } + + let anchor = todayPeak > date ? todayPeak : calendar.date(byAdding: .day, value: 1, to: todayPeak) ?? date + let weekday = calendar.component(.weekday, from: anchor) + + let skip = switch weekday { + case 1: 1 + case 7: 2 + default: 0 + } + + if skip == 0 { return anchor } + return calendar.date(byAdding: .day, value: skip, to: anchor) ?? anchor + } + + private static func formatDuration(minutes: Int) -> String { + let h = minutes / 60 + let m = minutes % 60 + if h == 0 { + return "\(m)m" + } + if m == 0 { + return "\(h)h" + } + return "\(h)h \(m)m" + } + + private static func calendar() -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = self.peakTimeZone + return cal + } +} diff --git a/Tests/CodexBarTests/ClaudePeakHoursTests.swift b/Tests/CodexBarTests/ClaudePeakHoursTests.swift new file mode 100644 index 0000000000..5abb93346e --- /dev/null +++ b/Tests/CodexBarTests/ClaudePeakHoursTests.swift @@ -0,0 +1,159 @@ +import CodexBarCore +import Foundation +import Testing + +struct ClaudePeakHoursTests { + private static let eastern = TimeZone(identifier: "America/New_York")! + + private func date( + year: Int = 2026, + month: Int = 3, + day: Int, + hour: Int, + minute: Int = 0, + second: Int = 0) -> Date + { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = Self.eastern + return cal.date(from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second))! + } + + @Test + func weekdayMorningBeforePeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1h") + } + + @Test + func weekdayJustBeforePeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 45)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 15m") + } + + @Test + func weekdayPeakStart() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 6h") + } + + @Test + func weekdayMidPeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 11, minute: 30)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 2h 30m") + } + + @Test + func weekdayPeakEndBoundary() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 13, minute: 59)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 1m") + } + + @Test + func weekdayAfterPeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 14)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 18h") + } + + @Test + func weekdayLateEvening() { + let status = ClaudePeakHours.status(at: self.date(day: 26, hour: 23)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 9h") + } + + @Test + func saturdayMorning() { + let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 10)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 46h") + } + + @Test + func sundayEvening() { + let status = ClaudePeakHours.status(at: self.date(day: 29, hour: 21)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 11h") + } + + @Test + func fridayAfterPeak() { + let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 15)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 65h") + } + + @Test + func fridayPeak() { + let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 12)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 2h") + } + + @Test + func springForwardWeekend() { + let status = ClaudePeakHours.status(at: self.date(day: 7, hour: 10)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 45h") + } + + @Test + func mondayMidnight() { + let status = ClaudePeakHours.status(at: self.date(day: 23, hour: 0)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 8h") + } + + @Test + func peakWithMinuteGranularity() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 12, minute: 15)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 1h 45m") + } + + @Test + func saturdayMidnight() { + let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 0)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 56h") + } + + @Test + func weekdayJustBeforePeakWithSeconds() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 45, second: 30)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 15m") + } + + @Test + func weekdayOneMinuteBeforePeakWithSeconds() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 59, second: 30)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1m") + } + + @Test + func weekdayLastSecondBeforePeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 59, second: 59)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1m") + } + + @Test + func weekdayPeakStartWithSeconds() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8, minute: 0, second: 30)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 6h") + } +} diff --git a/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift b/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift new file mode 100644 index 0000000000..a6faa8694f --- /dev/null +++ b/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift @@ -0,0 +1,140 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardOptionalUsageModelTests { + @Test + func `hides codex credits when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let credits = CreditsSnapshot(remaining: 12, events: [], updatedAt: now) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: credits, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: true, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: credits, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == nil) + } + + @Test + func `claude model shows peak hours note when enabled`() throws { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = try #require(TimeZone(identifier: "America/New_York")) + let now = try #require(cal.date(from: DateComponents(year: 2026, month: 3, day: 25, hour: 10))) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + claudePeakHoursEnabled: true, + now: now)) + + #expect(model.usageNotes.count == 1) + #expect(model.usageNotes.first?.contains("Peak") == true) + } + + @Test + func `claude model hides peak hours note when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + claudePeakHoursEnabled: false, + now: now)) + + #expect(model.usageNotes.isEmpty) + } +} From eb8cc9db8477aac04ed93229fe186e48cf12cbf5 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 28 Apr 2026 10:32:36 +0530 Subject: [PATCH 0251/1239] Add Claude peak-hours note to changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a05f99212..dd708ac54e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.24 — Unreleased +### Providers & Usage +- Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! + ### Fixes - Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! From c676e165f0518e48a2a894fa9ebd01cca1ac7daf Mon Sep 17 00:00:00 2001 From: iam-brain <94809115+iam-brain@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:37:46 -0400 Subject: [PATCH 0252/1239] Introduce vertical cost detail list in cost utilization submenu (#513) * Introduce vertical cost detail list * Preserve cost detail ranking --------- Co-authored-by: Ratul Sarna --- .../CodexBar/CostHistoryChartMenuView.swift | 143 +++++++++++++----- 1 file changed, 108 insertions(+), 35 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index fec1135dc3..6dec067698 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -20,6 +20,18 @@ struct CostHistoryChartMenuView: View { } } + private struct DetailRow: Identifiable { + let id: String + let title: String + let subtitle: String? + let accentColor: Color + } + + private struct DetailContent { + let primary: String + let rows: [DetailRow] + } + private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? @@ -88,22 +100,48 @@ struct CostHistoryChartMenuView: View { } } - let detail = self.detailLines(model: model) - VStack(alignment: .leading, spacing: 0) { + let detail = self.detailContent(model: model) + VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - Text(detail.secondary ?? " ") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - .opacity(detail.secondary == nil ? 0 : 1) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(height: Self.detailRowHeight, alignment: .leading) + } + ForEach(0.. Double { maxValue * 0.05 @@ -150,6 +193,7 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 + var maxRenderedBreakdownRows = 0 for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } @@ -158,6 +202,7 @@ struct CostHistoryChartMenuView: View { pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) + maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) if let cur = peak { if costUSD > cur.costUSD { peak = (entry.date, costUSD) } } else { @@ -181,7 +226,8 @@ struct CostHistoryChartMenuView: View { axisDates: axisDates, barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, - maxCostUSD: maxCostUSD) + maxCostUSD: maxCostUSD, + maxRenderedBreakdownRows: maxRenderedBreakdownRows) } private static func barColor(for provider: UsageProvider) -> Color { @@ -211,6 +257,18 @@ struct CostHistoryChartMenuView: View { return model.pointsByDateKey[key] } + private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int { + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 } + return min(breakdown.count, self.maxVisibleDetailLines) + } + + private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat { + guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + + (CGFloat(maxBreakdownRows) * self.detailRowHeight) + + (CGFloat(maxBreakdownRows) * self.detailSpacing) + } + private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDateKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } @@ -286,41 +344,56 @@ struct CostHistoryChartMenuView: View { return best?.key } - private func detailLines(model: Model) -> (primary: String, secondary: String?) { + private func detailContent(model: Model) -> DetailContent { guard let key = self.selectedDateKey, let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return DetailContent(primary: "Hover a bar for details", rows: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = UsageFormatter.usdString(point.costUSD) - if let tokens = point.totalTokens { - let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + let primary = if let tokens = point.totalTokens { + "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + } else { + "\(dayLabel): \(cost)" } - let primary = "\(dayLabel): \(cost)" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } - private func topModelsText(key: String, model: Model) -> String? { - guard let entry = model.entriesByDateKey[key] else { return nil } - guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } - let parts = breakdown - .compactMap { item -> String? in - let name = UsageFormatter.modelDisplayName(item.modelName) - guard let detail = UsageFormatter.modelCostDetail( - item.modelName, - costUSD: item.costUSD, - totalTokens: item.totalTokens) - else { return nil } - return "\(name) \(detail)" + private func breakdownRows(key: String, model: Model) -> [DetailRow] { + guard let entry = model.entriesByDateKey[key] else { return [] } + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } + + return breakdown + .sorted { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + + return lhs.modelName > rhs.modelName } - .prefix(3) - guard !parts.isEmpty else { return nil } - return "Top: \(parts.joined(separator: " · "))" + .prefix(Self.maxVisibleDetailLines) + .enumerated() + .map { index, item in + DetailRow( + id: "\(item.modelName)-\(index)", + title: UsageFormatter.modelDisplayName(item.modelName), + subtitle: UsageFormatter.modelCostDetail( + item.modelName, + costUSD: item.costUSD, + totalTokens: item.totalTokens), + accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + } + + private static func breakdownAccentOpacity(for index: Int) -> Double { + let opacity = 0.75 - (Double(index) * 0.12) + return max(0.3, opacity) } } From 26316483a851398bcd0bdf19d2e26057e994f968 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 28 Apr 2026 23:12:23 +0530 Subject: [PATCH 0253/1239] Add cost history detail changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd708ac54e..c30b03a162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Providers & Usage - Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! +- Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! ### Fixes - Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! From b6b77b4b8ea803b671dea666bc76135e6af0c057 Mon Sep 17 00:00:00 2001 From: Willy Date: Tue, 28 Apr 2026 23:55:56 -0500 Subject: [PATCH 0254/1239] feat: add DeepSeek provider with credit balance tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements GET https://api.deepseek.com/user/balance and surfaces credit balance (paid vs. granted breakdown) in the menu bar. - Reads DEEPSEEK_API_KEY (primary) or DEEPSEEK_KEY (fallback) from env or Settings → Providers - Balance shown in identity card (loginMethod); account-unavailable and zero-balance states handled with appropriate messages - SVG whale icon (ProviderIcon-deepseek.svg, MIT) using currentColor - Disabled by default (defaultEnabled: false) - Wired into TokenAccountSupportCatalog, SettingsStore+MenuObservation, UsageStore+Accessors, and ProviderConfigEnvironment - 21 tests covering parse, error, currency, and env-var priority Closes #795 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 1 + .../DeepSeekProviderImplementation.swift | 57 +++++ .../DeepSeek/DeepSeekSettingsStore.swift | 16 ++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-deepseek.svg | 3 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/UsageStore+Accessors.swift | 2 + Sources/CodexBar/UsageStore.swift | 51 +++-- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../Config/ProviderConfigEnvironment.swift | 2 + .../CodexBarCore/Logging/LogCategories.swift | 2 + .../DeepSeek/DeepSeekProviderDescriptor.swift | 70 ++++++ .../DeepSeek/DeepSeekSettingsReader.swift | 34 +++ .../DeepSeek/DeepSeekUsageFetcher.swift | 190 ++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderTokenResolver.swift | 12 + .../CodexBarCore/Providers/Providers.swift | 2 + .../TokenAccountSupportCatalog+Data.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../DeepSeekSettingsReaderTests.swift | 73 ++++++ .../DeepSeekUsageFetcherTests.swift | 209 ++++++++++++++++++ .../ProviderConfigEnvironmentTests.swift | 29 +++ docs/deepseek.md | 40 ++++ docs/providers.md | 10 +- 27 files changed, 805 insertions(+), 17 deletions(-) create mode 100644 Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-deepseek.svg create mode 100644 Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift create mode 100644 Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift create mode 100644 Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift create mode 100644 docs/deepseek.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c30b03a162..0b3a19d039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.24 — Unreleased ### Providers & Usage +- DeepSeek: add provider support with API key balance tracking, paid vs. granted credit breakdown, and CLI support (#795). - Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! - Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! diff --git a/README.md b/README.md index d0e0474de1..607d5970e4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking. - [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers. - [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking. +- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown). - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift new file mode 100644 index 0000000000..13d7d83f34 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift @@ -0,0 +1,57 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct DeepSeekProviderImplementation: ProviderImplementation { + let id: UsageProvider = .deepseek + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.deepSeekAPIToken + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if DeepSeekSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + context.settings.ensureDeepSeekAPITokenLoaded() + return !context.settings.deepSeekAPIToken + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "deepseek-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Generate one at platform.deepseek.com/api_keys.", + kind: .secure, + placeholder: "sk-...", + binding: context.stringBinding(\.deepSeekAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "deepseek-open-api-keys", + title: "Open API Keys", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://platform.deepseek.com/api_keys") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: { context.settings.ensureDeepSeekAPITokenLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift new file mode 100644 index 0000000000..ea624be221 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift @@ -0,0 +1,16 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var deepSeekAPIToken: String { + get { self.configSnapshot.providerConfig(for: .deepseek)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .deepseek) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .deepseek, field: "apiKey", value: newValue) + } + } + + func ensureDeepSeekAPITokenLoaded() {} +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 1b4950ec33..199a62f2d3 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -40,6 +40,7 @@ enum ProviderImplementationRegistry { case .perplexity: PerplexityProviderImplementation() case .abacus: AbacusProviderImplementation() case .mistral: MistralProviderImplementation() + case .deepseek: DeepSeekProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg b/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg new file mode 100644 index 0000000000..5a2f55bfee --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 73786c47ff..106f2a57dc 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -70,6 +70,7 @@ extension SettingsStore { _ = self.ollamaCookieHeader _ = self.copilotAPIToken _ = self.warpAPIToken + _ = self.deepSeekAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern _ = self.selectedMenuProvider diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 9c3c759e9e..5ce176c049 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -56,6 +56,8 @@ extension UsageStore { return ZaiSettingsError.missingToken.errorDescription case .openrouter: return OpenRouterSettingsError.missingToken.errorDescription + case .deepseek: + return DeepSeekUsageError.missingCredentials.errorDescription case .perplexity: return PerplexityAPIError.missingToken.errorDescription case .minimax: diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3704feedaf..9c9ce32410 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -785,13 +785,17 @@ extension UsageStore { let ollamaCookieHeader = self.settings.ollamaCookieHeader let processEnvironment = self.environmentBase let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey - let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty ?? true) let openRouterHasEnvToken = OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil let openRouterEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( base: processEnvironment, provider: .openrouter, config: self.settings.providerConfig(for: .openrouter)) + let deepSeekConfigToken = self.settings.providerConfig(for: .deepseek)?.sanitizedAPIKey + let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil + let deepSeekEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .deepseek, + config: self.settings.providerConfig(for: .deepseek)) let codexFetcher = self.codexFetcher let browserDetection = self.browserDetection let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() @@ -864,23 +868,22 @@ extension UsageStore { ollamaCookieSource: ollamaCookieSource, ollamaCookieHeader: ollamaCookieHeader) case .openrouter: - let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) - let hasAny = resolution != nil - let source: String = if resolution == nil { - "none" - } else if openRouterHasConfigToken, openRouterHasEnvToken { - "settings-config (overrides env)" - } else if openRouterHasConfigToken { - "settings-config" - } else { - resolution?.source.rawValue ?? "environment" - } - return "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + return Self.apiKeyDebugLine( + label: "OPENROUTER_API_KEY", + resolution: ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment), + configToken: openRouterConfigToken, + hasEnvToken: openRouterHasEnvToken) case .warp: let resolution = ProviderTokenResolver.warpResolution() 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.deepseekResolution(environment: deepSeekEnvironment), + configToken: deepSeekConfigToken, + hasEnvToken: deepSeekHasEnvToken) case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, .jetbrains, .perplexity, .abacus, .mistral: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" @@ -1003,6 +1006,26 @@ extension UsageStore { #endif } + nonisolated private static func apiKeyDebugLine( + label: String, + resolution: ProviderTokenResolution?, + configToken: String?, + hasEnvToken: Bool) -> String + { + let hasAny = resolution != nil + let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let source: String = if resolution == nil { + "none" + } else if hasConfigToken, hasEnvToken { + "settings-config (overrides env)" + } else if hasConfigToken { + "settings-config" + } else { + resolution?.source.rawValue ?? "environment" + } + return "\(label)=\(hasAny ? "present" : "missing") source=\(source)" + } + private static func debugCursorLog( browserDetection: BrowserDetection, cursorCookieSource: ProviderCookieSource, diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 5d4f9cba22..0e1b5b2c8b 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -200,7 +200,7 @@ struct TokenAccountCLIContext { mistral: ProviderSettingsSnapshot.MistralProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: + case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, .deepseek: return nil } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 6620ae879d..71e438e56a 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -31,6 +31,8 @@ public enum ProviderConfigEnvironment { } case .openrouter: env[OpenRouterSettingsReader.envKey] = apiKey + case .deepseek: + env[DeepSeekSettingsReader.apiKeyEnvironmentKey] = apiKey default: break } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index fba33c0ce6..ae1d10a349 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -20,6 +20,8 @@ public enum LogCategories { public static let copilotTokenStore = "copilot-token-store" public static let creditsPurchase = "creditsPurchase" public static let cursorLogin = "cursor-login" + public static let deepSeekSettings = "deepseek-settings" + public static let deepSeekUsage = "deepseek-usage" public static let geminiProbe = "gemini-probe" public static let keychainCache = "keychain-cache" public static let keychainMigration = "keychain-migration" diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift new file mode 100644 index 0000000000..56db228896 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -0,0 +1,70 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum DeepSeekProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .deepseek, + metadata: ProviderMetadata( + id: .deepseek, + displayName: "DeepSeek", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show DeepSeek usage", + cliName: "deepseek", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.deepseek.com/usage", + statusPageURL: nil, + statusLinkURL: "https://status.deepseek.com"), + branding: ProviderBranding( + iconStyle: .deepseek, + iconResourceName: "ProviderIcon-deepseek", + color: ProviderColor(red: 0.32, green: 0.49, blue: 0.94)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "DeepSeek per-day cost history is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DeepSeekAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "deepseek", + aliases: ["deep-seek", "ds"], + versionDetector: nil)) + } +} + +struct DeepSeekAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "deepseek.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw DeepSeekUsageError.missingCredentials + } + let usage = try await DeepSeekUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.deepseekToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift new file mode 100644 index 0000000000..6f622d3321 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct DeepSeekSettingsReader: Sendable { + public static let apiKeyEnvironmentKey = "DEEPSEEK_API_KEY" + public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "DEEPSEEK_KEY"] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift new file mode 100644 index 0000000000..46b2727906 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -0,0 +1,190 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +public struct DeepSeekBalanceResponse: Decodable, Sendable { + public let isAvailable: Bool + public let balanceInfos: [DeepSeekBalanceInfo] + + enum CodingKeys: String, CodingKey { + case isAvailable = "is_available" + case balanceInfos = "balance_infos" + } +} + +public struct DeepSeekBalanceInfo: Decodable, Sendable { + public let currency: String + public let totalBalance: String + public let grantedBalance: String + public let toppedUpBalance: String + + enum CodingKeys: String, CodingKey { + case currency + case totalBalance = "total_balance" + case grantedBalance = "granted_balance" + case toppedUpBalance = "topped_up_balance" + } +} + +// MARK: - Domain snapshot + +public struct DeepSeekUsageSnapshot: Sendable { + public let isAvailable: Bool + public let currency: String + public let totalBalance: Double + public let grantedBalance: Double + public let toppedUpBalance: Double + public let updatedAt: Date + + public init( + isAvailable: Bool, + currency: String, + totalBalance: Double, + grantedBalance: Double, + toppedUpBalance: Double, + updatedAt: Date) + { + self.isAvailable = isAvailable + self.currency = currency + self.totalBalance = totalBalance + self.grantedBalance = grantedBalance + self.toppedUpBalance = toppedUpBalance + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let symbol = self.currency == "CNY" ? "¥" : "$" + + let loginMethod: String + if !self.isAvailable { + loginMethod = "Account unavailable" + } else if self.totalBalance <= 0 { + loginMethod = "\(symbol)0.00 — add credits at platform.deepseek.com" + } else { + let total = String(format: "\(symbol)%.2f", self.totalBalance) + let paid = String(format: "\(symbol)%.2f", self.toppedUpBalance) + let granted = String(format: "\(symbol)%.2f", self.grantedBalance) + loginMethod = "\(total) (Paid: \(paid) / Granted: \(granted))" + } + + let identity = ProviderIdentitySnapshot( + providerID: .deepseek, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum DeepSeekUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing DeepSeek API key." + case let .networkError(message): + "DeepSeek network error: \(message)" + case let .apiError(message): + "DeepSeek API error: \(message)" + case let .parseFailed(message): + "Failed to parse DeepSeek response: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct DeepSeekUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.deepSeekUsage) + private static let balanceURL = URL(string: "https://api.deepseek.com/user/balance")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage(apiKey: String) async throws -> DeepSeekUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DeepSeekUsageError.missingCredentials + } + + var request = URLRequest(url: self.balanceURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw DeepSeekUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("DeepSeek API returned \(httpResponse.statusCode): \(body)") + throw DeepSeekUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("DeepSeek API response: \(jsonString)") + } + + return try Self.parseSnapshot(data: data) + } + + static func _parseSnapshotForTesting(_ data: Data) throws -> DeepSeekUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> DeepSeekUsageSnapshot { + let decoded: DeepSeekBalanceResponse + do { + decoded = try JSONDecoder().decode(DeepSeekBalanceResponse.self, from: data) + } catch { + throw DeepSeekUsageError.parseFailed(error.localizedDescription) + } + + // Prefer USD; fall back to first available entry. + let info = decoded.balanceInfos.first { $0.currency == "USD" } + ?? decoded.balanceInfos.first + + guard let info else { + return DeepSeekUsageSnapshot( + isAvailable: false, + currency: "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + updatedAt: Date()) + } + + guard + let total = Double(info.totalBalance), + let granted = Double(info.grantedBalance), + let toppedUp = Double(info.toppedUpBalance) + else { + throw DeepSeekUsageError.parseFailed("Non-numeric balance value in response.") + } + + return DeepSeekUsageSnapshot( + isAvailable: decoded.isAvailable, + currency: info.currency, + totalBalance: total, + grantedBalance: granted, + toppedUpBalance: toppedUp, + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d98204b8ef..fa1e5c137b 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -80,6 +80,7 @@ public enum ProviderDescriptorRegistry { .perplexity: PerplexityProviderDescriptor.descriptor, .abacus: AbacusProviderDescriptor.descriptor, .mistral: MistralProviderDescriptor.descriptor, + .deepseek: DeepSeekProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 85113cc262..fcd53f90bf 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -71,6 +71,18 @@ public enum ProviderTokenResolver { self.perplexityResolution(environment: environment)?.token } + public static func deepseekToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.deepseekResolution(environment: environment)?.token + } + + public static func deepseekResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DeepSeekSettingsReader.apiKey(environment: environment)) + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 22493fa0f5..923be6669a 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -30,6 +30,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case perplexity case abacus case mistral + case deepseek } // swiftformat:enable sortDeclarations @@ -62,6 +63,7 @@ public enum IconStyle: Sendable, CaseIterable { case perplexity case abacus case mistral + case deepseek case combined } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 893be0d5a4..a4ce33fb9e 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -9,6 +9,13 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: "sessionKey"), + .deepseek: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple DeepSeek API keys.", + placeholder: "Paste API key…", + injection: .environment(key: DeepSeekSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), .zai: TokenAccountSupport( title: "API tokens", subtitle: "Stored in the CodexBar config file.", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 69cc02db61..0450d30929 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -236,7 +236,7 @@ enum CostUsageScanner { case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus, - .mistral: + .mistral, .deepseek: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index ce60108dee..3d94e9d302 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -78,6 +78,7 @@ enum ProviderChoice: String, AppEnum { case .perplexity: return nil // Perplexity not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets case .mistral: return nil // Mistral not yet supported in widgets + case .deepseek: return nil // DeepSeek not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 00c791aa8b..8c1dc69058 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -284,6 +284,7 @@ private struct ProviderSwitchChip: View { case .perplexity: "Pplx" case .abacus: "Abacus" case .mistral: "Mistral" + case .deepseek: "DeepSeek" } } } @@ -647,6 +648,8 @@ enum WidgetColors { Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) case .mistral: Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange + case .deepseek: + Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255) } } } diff --git a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift new file mode 100644 index 0000000000..0aba8b4a83 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Testing + +struct DeepSeekSettingsReaderTests { + @Test + func `reads DEEPSEEK_API_KEY`() { + let env = ["DEEPSEEK_API_KEY": "sk-abc123"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-abc123") + } + + @Test + func `falls back to DEEPSEEK_KEY`() { + let env = ["DEEPSEEK_KEY": "sk-fallback"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-fallback") + } + + @Test + func `DEEPSEEK_API_KEY takes priority over DEEPSEEK_KEY`() { + let env = ["DEEPSEEK_API_KEY": "sk-primary", "DEEPSEEK_KEY": "sk-secondary"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-primary") + } + + @Test + func `trims whitespace`() { + let env = ["DEEPSEEK_API_KEY": " sk-trimmed "] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-trimmed") + } + + @Test + func `strips double quotes`() { + let env = ["DEEPSEEK_API_KEY": "\"sk-quoted\""] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-quoted") + } + + @Test + func `strips single quotes`() { + let env = ["DEEPSEEK_KEY": "'sk-single'"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-single") + } + + @Test + func `returns nil when no key present`() { + #expect(DeepSeekSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `returns nil for empty key`() { + let env = ["DEEPSEEK_API_KEY": ""] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only key`() { + let env = ["DEEPSEEK_API_KEY": " "] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) + } +} + +struct DeepSeekProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["DEEPSEEK_API_KEY": "sk-resolve-test"] + let resolution = ProviderTokenResolver.deepseekResolution(environment: env) + #expect(resolution?.token == "sk-resolve-test") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when key absent`() { + let resolution = ProviderTokenResolver.deepseekResolution(environment: [:]) + #expect(resolution == nil) + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift new file mode 100644 index 0000000000..320777d16e --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekUsageFetcherTests { + @Test + func `parses USD balance response`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "50.00", + "granted_balance": "10.00", + "topped_up_balance": "40.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.isAvailable == true) + #expect(snapshot.currency == "USD") + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.grantedBalance == 10.0) + #expect(snapshot.toppedUpBalance == 40.0) + } + + @Test + func `parses CNY balance response`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "110.00", + "granted_balance": "10.00", + "topped_up_balance": "100.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currency == "CNY") + #expect(snapshot.totalBalance == 110.0) + #expect(snapshot.toppedUpBalance == 100.0) + } + + @Test + func `prefers USD when both currencies present`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "100.00", + "granted_balance": "0.00", + "topped_up_balance": "100.00" + }, + { + "currency": "USD", + "total_balance": "20.00", + "granted_balance": "5.00", + "topped_up_balance": "15.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currency == "USD") + #expect(snapshot.totalBalance == 20.0) + } + + @Test + func `depleted icon when is_available false`() throws { + let json = """ + { + "is_available": false, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "0.00", + "granted_balance": "0.00", + "topped_up_balance": "0.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.isAvailable == false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod == "Account unavailable") + } + + @Test + func `full bar when balance available`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "5.00", + "granted_balance": "0.00", + "topped_up_balance": "5.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod?.contains("$5.00") == true) + } + + @Test + func `throws on malformed balance string`() { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "not-a-number", + "granted_balance": "0.00", + "topped_up_balance": "0.00" + } + ] + } + """ + #expect { + _ = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `empty balance_infos returns unavailable snapshot`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.isAvailable == false) + #expect(snapshot.totalBalance == 0.0) + } + + @Test + func `throws on invalid JSON root`() { + let json = "[{ \"is_available\": true }]" + #expect { + _ = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `balance description includes paid and granted breakdown`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "50.00", + "granted_balance": "10.00", + "topped_up_balance": "40.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + let loginMethod = usage.identity?.loginMethod ?? "" + #expect(loginMethod.contains("$50.00")) + #expect(loginMethod.contains("$40.00")) + #expect(loginMethod.contains("$10.00")) + } + + @Test + func `CNY balance uses yen symbol`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "100.00", + "granted_balance": "0.00", + "topped_up_balance": "100.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + let loginMethod = usage.identity?.loginMethod ?? "" + #expect(loginMethod.contains("¥")) + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 665bf0c76d..0b16b997aa 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -39,6 +39,22 @@ struct ProviderConfigEnvironmentTests { #expect(env[OpenRouterSettingsReader.envKey] == "or-token") } + @Test + func `applies API key override for deepseek`() { + let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepseek, + config: config) + + let key = DeepSeekSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == "ds-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "ds-token") + } + @Test func `applies API key override for kilo`() { let config = ProviderConfig(id: .kilo, apiKey: "kilo-token") @@ -63,6 +79,19 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.openRouterToken(environment: env) == "config-token") } + @Test + func `deepseek config override wins over environment token`() { + let config = ProviderConfig(id: .deepseek, apiKey: "config-token") + let envKey = DeepSeekSettingsReader.apiKeyEnvironmentKeys[0] + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [envKey: "env-token"], + provider: .deepseek, + config: config) + + #expect(env[envKey] == "config-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "config-token") + } + @Test func `leaves environment when API key missing`() { let config = ProviderConfig(id: .zai, apiKey: nil) diff --git a/docs/deepseek.md b/docs/deepseek.md new file mode 100644 index 0000000000..b116a9bb85 --- /dev/null +++ b/docs/deepseek.md @@ -0,0 +1,40 @@ +--- +summary: "DeepSeek provider data sources: API key + balance endpoint." +read_when: + - Adding or tweaking DeepSeek balance parsing + - Updating API key handling + - Documenting new provider behavior +--- + +# DeepSeek provider + +DeepSeek is API-only. Balance is reported by `GET https://api.deepseek.com/user/balance`, +so CodexBar only needs a valid API key to show your remaining credit balance. + +## Data sources + +1. **API key** stored in `~/.codexbar/config.json` or supplied via `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY`. + CodexBar stores the key in config after you paste it in Settings → Providers → DeepSeek. +2. **Balance endpoint** + - `GET https://api.deepseek.com/user/balance` + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - Response contains `is_available`, and a `balance_infos` array with per-currency entries + (`total_balance`, `granted_balance`, `topped_up_balance`). + +## Usage details + +- The menu card shows total balance with the paid vs. granted breakdown: + e.g. `$50.00 (Paid: $40.00 / Granted: $10.00)`. +- Granted credits are promotional and may expire; topped-up credits are user-paid and do not expire. +- When multiple currencies are present, USD is shown preferentially. +- `is_available: false` from the API dims the icon and shows "Account unavailable". +- There is no session or weekly window — DeepSeek does not expose per-window quota via API. +- Settings config takes precedence over environment variables when both are present. + +## Key files + +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift` (descriptor + fetch strategy) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift` (HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift` (env var resolution) +- `Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift` (settings field + activation logic) +- `Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift` (SettingsStore extension) diff --git a/docs/providers.md b/docs/providers.md index 0fe26b2cf1..5db9fe8748 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI, Mistral)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI, Mistral, DeepSeek)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -41,6 +41,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | OpenRouter | API token (config, overrides env) → credits API (`api`). | | Abacus AI | Browser cookies → compute points + billing API (`web`). | | Mistral | Console billing API via Ory Kratos session cookies (`web`). | +| DeepSeek | API key (config, overrides env) → balance endpoint (`api`). | ## Codex - Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -204,4 +205,11 @@ until the session is invalid, to avoid repeated Keychain prompts. - Resets at end of calendar month. - Status: `https://status.mistral.ai` (link only, no auto-polling). +## DeepSeek +- API key via Settings (`~/.codexbar/config.json`) or `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY` env var. +- `GET https://api.deepseek.com/user/balance`. +- Shows total balance with paid vs. granted breakdown; USD preferred when multiple currencies present. +- Status: `https://status.deepseek.com` (link only, no auto-polling). +- Details: `docs/deepseek.md`. + See also: `docs/provider.md` for architecture notes. From baf5a2bd9c3b8c3c1ca7bd7f634a481420a5f940 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 14:46:33 +0530 Subject: [PATCH 0255/1239] Fix DeepSeek balance display --- Sources/CodexBar/MenuCardView.swift | 4 +-- Sources/CodexBar/MenuDescriptor.swift | 8 +++--- Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/CLIRenderer.swift | 2 +- .../DeepSeek/DeepSeekUsageFetcher.swift | 25 +++++++++++++------ .../DeepSeekUsageFetcherTests.swift | 24 ++++++++++-------- 6 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 297dd71bd8..02ab642c62 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1071,7 +1071,7 @@ extension UsageMenuCardView.Model { { primaryResetText = openRouterQuotaDetail } - if input.provider == .warp || input.provider == .kilo, + if input.provider == .warp || input.provider == .kilo || input.provider == .deepseek, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1083,7 +1083,7 @@ extension UsageMenuCardView.Model { { primaryDetailText = detail } - if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil { + if input.provider == .warp || input.provider == .kilo || input.provider == .deepseek, primary.resetsAt == nil { primaryResetText = nil } // Abacus: show credits as detail, compute pace on the primary monthly window diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 46e0a2c6ae..cc757055ba 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -146,8 +146,10 @@ struct MenuDescriptor { if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle if let primary = snap.primary { - let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus { - // Warp/Kilo/Abacus primary uses resetDescription for non-reset detail + let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus || + provider == .deepseek + { + // Some providers use resetDescription for non-reset detail // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. RateWindow( usedPercent: primary.usedPercent, @@ -163,7 +165,7 @@ struct MenuDescriptor { window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) - if provider == .warp || provider == .kilo || provider == .abacus, + if provider == .warp || provider == .kilo || provider == .abacus || provider == .deepseek, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 9c9ce32410..b59823a022 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1006,7 +1006,7 @@ extension UsageStore { #endif } - nonisolated private static func apiKeyDebugLine( + private nonisolated static func apiKeyDebugLine( label: String, resolution: ProviderTokenResolution?, configToken: String?, diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index e22260c8d9..7ae00397e5 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -199,7 +199,7 @@ enum CLIRenderer { now: Date, lines: inout [String]) { - if provider == .warp || provider == .kilo || provider == .mistral { + if provider == .warp || provider == .kilo || provider == .mistral || provider == .deepseek { if let reset = self.resetLineForDetailBackedWindow(window: window, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift index 46b2727906..2aafbb5673 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -58,26 +58,35 @@ public struct DeepSeekUsageSnapshot: Sendable { public func toUsageSnapshot() -> UsageSnapshot { let symbol = self.currency == "CNY" ? "¥" : "$" - let loginMethod: String - if !self.isAvailable { - loginMethod = "Account unavailable" - } else if self.totalBalance <= 0 { - loginMethod = "\(symbol)0.00 — add credits at platform.deepseek.com" + let balanceDetail: String + let usedPercent: Double + if self.totalBalance <= 0 { + balanceDetail = "\(symbol)0.00 — add credits at platform.deepseek.com" + usedPercent = 100 + } else if !self.isAvailable { + balanceDetail = "Balance unavailable for API calls" + usedPercent = 100 } else { let total = String(format: "\(symbol)%.2f", self.totalBalance) let paid = String(format: "\(symbol)%.2f", self.toppedUpBalance) let granted = String(format: "\(symbol)%.2f", self.grantedBalance) - loginMethod = "\(total) (Paid: \(paid) / Granted: \(granted))" + balanceDetail = "\(total) (Paid: \(paid) / Granted: \(granted))" + usedPercent = 0 } let identity = ProviderIdentitySnapshot( providerID: .deepseek, accountEmail: nil, accountOrganization: nil, - loginMethod: loginMethod) + loginMethod: nil) + let balanceWindow = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: balanceDetail) return UsageSnapshot( - primary: nil, + primary: balanceWindow, secondary: nil, tertiary: nil, providerCost: nil, diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift index 320777d16e..59f384ced1 100644 --- a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -74,7 +74,7 @@ struct DeepSeekUsageFetcherTests { } @Test - func `depleted icon when is_available false`() throws { + func `zero balance prompts top up even when unavailable`() throws { let json = """ { "is_available": false, @@ -91,8 +91,9 @@ struct DeepSeekUsageFetcherTests { let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) #expect(snapshot.isAvailable == false) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary == nil) - #expect(usage.identity?.loginMethod == "Account unavailable") + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "$0.00 — add credits at platform.deepseek.com") + #expect(usage.identity?.loginMethod == nil) } @Test @@ -112,8 +113,9 @@ struct DeepSeekUsageFetcherTests { """ let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary == nil) - #expect(usage.identity?.loginMethod?.contains("$5.00") == true) + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription?.contains("$5.00") == true) + #expect(usage.identity?.loginMethod == nil) } @Test @@ -180,10 +182,10 @@ struct DeepSeekUsageFetcherTests { """ let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) let usage = snapshot.toUsageSnapshot() - let loginMethod = usage.identity?.loginMethod ?? "" - #expect(loginMethod.contains("$50.00")) - #expect(loginMethod.contains("$40.00")) - #expect(loginMethod.contains("$10.00")) + let detail = usage.primary?.resetDescription ?? "" + #expect(detail.contains("$50.00")) + #expect(detail.contains("$40.00")) + #expect(detail.contains("$10.00")) } @Test @@ -203,7 +205,7 @@ struct DeepSeekUsageFetcherTests { """ let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) let usage = snapshot.toUsageSnapshot() - let loginMethod = usage.identity?.loginMethod ?? "" - #expect(loginMethod.contains("¥")) + let detail = usage.primary?.resetDescription ?? "" + #expect(detail.contains("¥")) } } From d4dcfee268d6a7ee7ddd5a2060f514192df60f85 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 14:55:51 +0530 Subject: [PATCH 0256/1239] Use token accounts for DeepSeek keys --- .../DeepSeekProviderImplementation.swift | 36 +++---------------- .../DeepSeek/DeepSeekSettingsStore.swift | 16 --------- .../SettingsStore+MenuObservation.swift | 1 - .../Config/ProviderConfigEnvironment.swift | 2 -- .../ProviderConfigEnvironmentTests.swift | 12 +++---- ...kenAccountEnvironmentPrecedenceTests.swift | 32 +++++++++++++++++ 6 files changed, 42 insertions(+), 57 deletions(-) delete mode 100644 Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift index 13d7d83f34..e72ec76f08 100644 --- a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift @@ -1,8 +1,6 @@ -import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation -import SwiftUI @ProviderImplementationRegistration struct DeepSeekProviderImplementation: ProviderImplementation { @@ -14,44 +12,18 @@ struct DeepSeekProviderImplementation: ProviderImplementation { } @MainActor - func observeSettings(_ settings: SettingsStore) { - _ = settings.deepSeekAPIToken - } + func observeSettings(_: SettingsStore) {} @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { if DeepSeekSettingsReader.apiKey(environment: context.environment) != nil { return true } - context.settings.ensureDeepSeekAPITokenLoaded() - return !context.settings.deepSeekAPIToken - .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return !context.settings.tokenAccounts(for: .deepseek).isEmpty } @MainActor - func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - [ - ProviderSettingsFieldDescriptor( - id: "deepseek-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Generate one at platform.deepseek.com/api_keys.", - kind: .secure, - placeholder: "sk-...", - binding: context.stringBinding(\.deepSeekAPIToken), - actions: [ - ProviderSettingsActionDescriptor( - id: "deepseek-open-api-keys", - title: "Open API Keys", - style: .link, - isVisible: nil, - perform: { - if let url = URL(string: "https://platform.deepseek.com/api_keys") { - NSWorkspace.shared.open(url) - } - }), - ], - isVisible: nil, - onActivate: { context.settings.ensureDeepSeekAPITokenLoaded() }), - ] + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] } } diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift deleted file mode 100644 index ea624be221..0000000000 --- a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CodexBarCore -import Foundation - -extension SettingsStore { - var deepSeekAPIToken: String { - get { self.configSnapshot.providerConfig(for: .deepseek)?.sanitizedAPIKey ?? "" } - set { - self.updateProviderConfig(provider: .deepseek) { entry in - entry.apiKey = self.normalizedConfigValue(newValue) - } - self.logSecretUpdate(provider: .deepseek, field: "apiKey", value: newValue) - } - } - - func ensureDeepSeekAPITokenLoaded() {} -} diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 106f2a57dc..73786c47ff 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -70,7 +70,6 @@ extension SettingsStore { _ = self.ollamaCookieHeader _ = self.copilotAPIToken _ = self.warpAPIToken - _ = self.deepSeekAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern _ = self.selectedMenuProvider diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 71e438e56a..6620ae879d 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -31,8 +31,6 @@ public enum ProviderConfigEnvironment { } case .openrouter: env[OpenRouterSettingsReader.envKey] = apiKey - case .deepseek: - env[DeepSeekSettingsReader.apiKeyEnvironmentKey] = apiKey default: break } diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 0b16b997aa..1ff6b0c843 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -40,7 +40,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func `applies API key override for deepseek`() { + func `ignores legacy API key override for deepseek`() { let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -51,8 +51,8 @@ struct ProviderConfigEnvironmentTests { #expect(key != nil) guard let key else { return } - #expect(env[key] == "ds-token") - #expect(ProviderTokenResolver.deepseekToken(environment: env) == "ds-token") + #expect(env[key] == nil) + #expect(ProviderTokenResolver.deepseekToken(environment: env) == nil) } @Test @@ -80,7 +80,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func `deepseek config override wins over environment token`() { + func `deepseek config override leaves environment token alone`() { let config = ProviderConfig(id: .deepseek, apiKey: "config-token") let envKey = DeepSeekSettingsReader.apiKeyEnvironmentKeys[0] let env = ProviderConfigEnvironment.applyAPIKeyOverride( @@ -88,8 +88,8 @@ struct ProviderConfigEnvironmentTests { provider: .deepseek, config: config) - #expect(env[envKey] == "config-token") - #expect(ProviderTokenResolver.deepseekToken(environment: env) == "config-token") + #expect(env[envKey] == "env-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "env-token") } @Test diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 4143529596..2390110bcd 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -24,6 +24,21 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ZaiSettingsReader.apiTokenKey] != "config-token") } + @Test + func `deepseek token account injects environment in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-deepseek-app") + settings.addTokenAccount(provider: .deepseek, label: "Account 1", token: "account-token") + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .deepseek, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + @Test func `token account environment overrides config API key in CLI environment builder`() throws { let config = CodexBarConfig( @@ -45,6 +60,23 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ZaiSettingsReader.apiTokenKey] != "config-token") } + @Test + func `deepseek token account injects environment in CLI environment builder`() throws { + let config = CodexBarConfig(providers: []) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = ProviderTokenAccount( + id: UUID(), + label: "Account 1", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + + let env = tokenContext.environment(base: [:], provider: .deepseek, account: account) + + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + @Test func `ollama token account selection forces manual cookie source in CLI settings snapshot`() throws { let accounts = ProviderTokenAccountData( From 4081454fe1b6e09b0c365e91c1274d44934af45d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 16:03:59 +0530 Subject: [PATCH 0257/1239] Add DeepSeek to provider order fixture --- Tests/CodexBarTests/SettingsStoreTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 9493cdb45a..3a3866ec4f 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -939,6 +939,7 @@ struct SettingsStoreTests { .perplexity, .abacus, .mistral, + .deepseek, ]) // Move one provider; ensure it's persisted across instances. From af375de65c67f7c0183011e63513248e8e11aacc Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 16:23:06 +0530 Subject: [PATCH 0258/1239] Include DeepSeek token accounts in debug output --- Sources/CodexBar/UsageStore.swift | 19 ++++++++++----- .../UsageStorePathDebugTests.swift | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index b59823a022..120c75a2e5 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -790,12 +790,13 @@ extension UsageStore { base: processEnvironment, provider: .openrouter, config: self.settings.providerConfig(for: .openrouter)) - let deepSeekConfigToken = self.settings.providerConfig(for: .deepseek)?.sanitizedAPIKey let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil - let deepSeekEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( + let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil + let deepSeekEnvironment = ProviderRegistry.makeEnvironment( base: processEnvironment, provider: .deepseek, - config: self.settings.providerConfig(for: .deepseek)) + settings: self.settings, + tokenOverride: nil) let codexFetcher = self.codexFetcher let browserDetection = self.browserDetection let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() @@ -882,8 +883,9 @@ extension UsageStore { return Self.apiKeyDebugLine( label: "DEEPSEEK_API_KEY", resolution: ProviderTokenResolver.deepseekResolution(environment: deepSeekEnvironment), - configToken: deepSeekConfigToken, - hasEnvToken: deepSeekHasEnvToken) + configToken: nil, + hasEnvToken: deepSeekHasEnvToken, + hasTokenAccount: deepSeekHasTokenAccount) case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, .jetbrains, .perplexity, .abacus, .mistral: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" @@ -1010,12 +1012,17 @@ extension UsageStore { label: String, resolution: ProviderTokenResolution?, configToken: String?, - hasEnvToken: Bool) -> String + hasEnvToken: Bool, + hasTokenAccount: Bool = false) -> String { let hasAny = resolution != nil let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) let source: String = if resolution == nil { "none" + } else if hasTokenAccount, hasEnvToken { + "settings-token-account (overrides env)" + } else if hasTokenAccount { + "settings-token-account" } else if hasConfigToken, hasEnvToken { "settings-config (overrides env)" } else if hasConfigToken { diff --git a/Tests/CodexBarTests/UsageStorePathDebugTests.swift b/Tests/CodexBarTests/UsageStorePathDebugTests.swift index 11aabaf55b..856fc178cb 100644 --- a/Tests/CodexBarTests/UsageStorePathDebugTests.swift +++ b/Tests/CodexBarTests/UsageStorePathDebugTests.swift @@ -29,4 +29,27 @@ struct UsageStorePathDebugTests { #expect(store.pathDebugInfo != .empty) #expect(store.pathDebugInfo.effectivePATH.isEmpty == false) } + + @Test + func `deepseek debug log includes selected token account`() async throws { + let suite = "UsageStorePathDebugTests-deepseek-debug-token-account" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.addTokenAccount(provider: .deepseek, label: "Primary", token: "sk-deepseek-test") + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + let debugLog = await store.debugLog(for: UsageProvider.deepseek) + + #expect(debugLog == "DEEPSEEK_API_KEY=present source=settings-token-account") + } } From 6a442eb962bbc053f1221838b2870d24139074dd Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 18:55:02 +0530 Subject: [PATCH 0259/1239] Update DeepSeek changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3a19d039..299b68f27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.24 — Unreleased ### Providers & Usage -- DeepSeek: add provider support with API key balance tracking, paid vs. granted credit breakdown, and CLI support (#795). +- DeepSeek: add provider support with token-account balance tracking, paid vs. granted credit breakdown, and CLI support (#811). Thanks @willytop8! - Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! - Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! From c5908a2faf3fdd950410bbf7a45a45a8759dfe0c Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 30 Apr 2026 13:01:44 +0530 Subject: [PATCH 0260/1239] Add Swift concurrency crash review guidance --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a4c8e630a0..8db4d4681c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ - Per user request: after every edit (code or docs), rebuild and restart using `./Scripts/compile_and_run.sh` so the running app reflects the latest changes. - Release script: keep it in the foreground; do not background it—wait until it finishes. - Release keys: find in `~/.profile` if missing (Sparkle + App Store Connect). +- Swift concurrency: treat sibling `async let` tasks as a review red flag when one child is required and another is optional/best-effort. Prefer sequential awaits or a drained `withThrowingTaskGroup` that surfaces required failures and explicitly contains optional failures; crash stacks mentioning `swift_task_dealloc` or `asyncLet_finish_after_task_completion` should trigger an audit of nearby `async let` usage. - Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`. - Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.). - Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.*** From 33fb652ce6cd4bcb4bc46f5a363992a284b23a9b Mon Sep 17 00:00:00 2001 From: Felipe Camus Date: Wed, 29 Apr 2026 18:20:41 -0400 Subject: [PATCH 0261/1239] fix(cursor): parse enterprise overall/pooled usage Decoder ignored individualUsage.overall and teamUsage.pooled, so Enterprise/Team accounts fell through to planPercentUsed=0 and the menu showed "100% remaining". Personal cap (overall) now wins; shared pool (pooled) is the last-resort fallback. Existing plan, on-demand, and legacy /api/usage paths unchanged. --- .../Providers/Cursor/CursorStatusProbe.swift | 102 ++++++++++- .../CursorStatusProbeTests.swift | 167 ++++++++++++++++++ 2 files changed, 264 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 4dee402a20..e9fe1c7b6c 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -201,6 +201,38 @@ public struct CursorUsageSummary: Codable, Sendable { public struct CursorIndividualUsage: Codable, Sendable { public let plan: CursorPlanUsage? public let onDemand: CursorOnDemandUsage? + /// Enterprise / team-member personal cap. Reported by Cursor when the account is part of a team or + /// enterprise plan with an individual quota. Values follow the same cents-based units as `plan`. + public let overall: CursorOverallUsage? + + public init( + plan: CursorPlanUsage? = nil, + onDemand: CursorOnDemandUsage? = nil, + overall: CursorOverallUsage? = nil) + { + self.plan = plan + self.onDemand = onDemand + self.overall = overall + } +} + +/// Personal cap reported under `individualUsage.overall` for Enterprise/Team members. +/// Mirrors the shape of `CursorOnDemandUsage`; values are in cents. +public struct CursorOverallUsage: Codable, Sendable { + public let enabled: Bool? + /// Usage in cents (e.g., 7384 = $73.84) + public let used: Int? + /// Limit in cents (e.g., 10000 = $100.00). `nil` indicates the API omitted a numeric cap. + public let limit: Int? + /// Remaining in cents. + public let remaining: Int? + + public init(enabled: Bool? = nil, used: Int? = nil, limit: Int? = nil, remaining: Int? = nil) { + self.enabled = enabled + self.used = used + self.limit = limit + self.remaining = remaining + } } public struct CursorPlanUsage: Codable, Sendable { @@ -235,6 +267,31 @@ public struct CursorOnDemandUsage: Codable, Sendable { public struct CursorTeamUsage: Codable, Sendable { public let onDemand: CursorOnDemandUsage? + /// Shared team/enterprise pool counted across all members. Same cents-based units as the other usage blocks. + public let pooled: CursorPooledUsage? + + public init(onDemand: CursorOnDemandUsage? = nil, pooled: CursorPooledUsage? = nil) { + self.onDemand = onDemand + self.pooled = pooled + } +} + +/// Shared team/enterprise pool reported under `teamUsage.pooled`. Values are in cents. +public struct CursorPooledUsage: Codable, Sendable { + public let enabled: Bool? + /// Pool usage in cents. + public let used: Int? + /// Pool limit in cents. `nil` indicates an unlimited or unreported pool. + public let limit: Int? + /// Pool remaining in cents. + public let remaining: Int? + + public init(enabled: Bool? = nil, used: Int? = nil, limit: Int? = nil, remaining: Int? = nil) { + self.enabled = enabled + self.used = used + self.limit = limit + self.remaining = remaining + } } // MARK: - Cursor Usage API Models (Legacy Request-Based Plans) @@ -966,8 +1023,6 @@ public struct CursorStatusProbe: Sendable { // Use plan.limit directly - breakdown.total represents total *used* credits, not the limit. let planUsedRaw = Double(summary.individualUsage?.plan?.used ?? 0) let planLimitRaw = Double(summary.individualUsage?.plan?.limit ?? 0) - let planUsed = planUsedRaw / 100.0 - let planLimit = planLimitRaw / 100.0 func normPct(_ value: Double?) -> Double? { guard let v = value else { return nil } if v < 0 { return 0 } @@ -984,9 +1039,23 @@ public struct CursorStatusProbe: Sendable { let autoPercent = normPct(summary.individualUsage?.plan?.autoPercentUsed) let apiPercent = normPct(summary.individualUsage?.plan?.apiPercentUsed) - // Headline "Total" should prefer Cursor's provided totalPercentUsed when available. plan.limit is often - // the subscription price in cents, so used/limit can diverge from the dashboard usage bars. - // If totalPercentUsed is absent, fall back to averaging the Auto/API lane percents. + // Enterprise / team-member personal cap (cents). Reported under `individualUsage.overall` for accounts + // that don't get a `plan` block. Falls through to existing logic when absent so non-enterprise paths + // are untouched. + let overallUsedRaw = (summary.individualUsage?.overall?.used).map(Double.init) + let overallLimitRaw = (summary.individualUsage?.overall?.limit).map(Double.init) + + // Shared team/enterprise pool (cents). Last-resort fallback when no individual data is available. + let pooledUsedRaw = (summary.teamUsage?.pooled?.used).map(Double.init) + let pooledLimitRaw = (summary.teamUsage?.pooled?.limit).map(Double.init) + + // Headline "Total" precedence: + // 1. `individualUsage.plan.totalPercentUsed` (existing behavior for Pro/Hobby/etc.) + // 2. averaged `auto` + `api` lane percents (existing behavior) + // 3. either lane alone (existing behavior) + // 4. `individualUsage.plan` ratio (existing behavior) + // 5. NEW: `individualUsage.overall` ratio (Enterprise/Team personal cap) + // 6. NEW: `teamUsage.pooled` ratio (last resort when no individual data is reported) let planPercentUsed: Double = if let totalPercentUsed = summary.individualUsage?.plan?.totalPercentUsed { normalizeTotalPercent(totalPercentUsed) } else if let autoUsed = autoPercent, let apiUsed = apiPercent { @@ -997,10 +1066,33 @@ public struct CursorStatusProbe: Sendable { max(0, min(100, autoUsed)) } else if planLimitRaw > 0 { (planUsedRaw / planLimitRaw) * 100 + } else if let used = overallUsedRaw, let limit = overallLimitRaw, limit > 0 { + normalizeTotalPercent((used / limit) * 100) + } else if let used = pooledUsedRaw, let limit = pooledLimitRaw, limit > 0 { + normalizeTotalPercent((used / limit) * 100) } else { 0 } + // USD figures: prefer the source the headline ultimately came from. When `plan` is missing but + // `overall` or `pooled` carry the cents, surface those so the on-demand display and downstream + // consumers see real dollar amounts instead of zeros. + let planUsed: Double + let planLimit: Double + if planLimitRaw > 0 || planUsedRaw > 0 { + planUsed = planUsedRaw / 100.0 + planLimit = planLimitRaw / 100.0 + } else if let usedCents = overallUsedRaw, let limitCents = overallLimitRaw { + planUsed = usedCents / 100.0 + planLimit = limitCents / 100.0 + } else if let usedCents = pooledUsedRaw, let limitCents = pooledLimitRaw { + planUsed = usedCents / 100.0 + planLimit = limitCents / 100.0 + } else { + planUsed = 0 + planLimit = 0 + } + let onDemandUsed = Double(summary.individualUsage?.onDemand?.used ?? 0) / 100.0 let onDemandLimit: Double? = summary.individualUsage?.onDemand?.limit.map { Double($0) / 100.0 } diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index e34277f9fb..defb676616 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -96,6 +96,55 @@ struct CursorStatusProbeTests { #expect(summary.individualUsage?.plan?.totalPercentUsed == 50.0) } + @Test + func `parses enterprise overall and pooled usage summary`() throws { + // Live Cursor Enterprise payload (sanitized). The Pro/Hobby `plan` block is absent; + // instead Cursor reports `individualUsage.overall` (personal cap) and `teamUsage.pooled` + // (shared team pool). Both blocks use cents like the existing `plan` block. + let json = """ + { + "billingCycleStart": "2026-04-01T00:00:00.000Z", + "billingCycleEnd": "2026-05-01T00:00:00.000Z", + "membershipType": "enterprise", + "limitType": "team", + "isUnlimited": false, + "individualUsage": { + "overall": { + "enabled": true, + "used": 7384, + "limit": 10000, + "remaining": 2616 + } + }, + "teamUsage": { + "onDemand": { + "enabled": true, + "used": 0, + "limit": null, + "remaining": null + }, + "pooled": { + "enabled": true, + "used": 12725135, + "limit": 28122000, + "remaining": 15396865 + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let summary = try JSONDecoder().decode(CursorUsageSummary.self, from: data) + + #expect(summary.membershipType == "enterprise") + #expect(summary.limitType == "team") + #expect(summary.individualUsage?.plan == nil) + #expect(summary.individualUsage?.overall?.used == 7384) + #expect(summary.individualUsage?.overall?.limit == 10000) + #expect(summary.individualUsage?.overall?.remaining == 2616) + #expect(summary.teamUsage?.pooled?.used == 12_725_135) + #expect(summary.teamUsage?.pooled?.limit == 28_122_000) + } + // MARK: - User Info Parsing @Test @@ -317,6 +366,124 @@ struct CursorStatusProbeTests { #expect(snapshot.toUsageSnapshot().primary?.remainingPercent == 99.55897435897436) } + @Test + func `enterprise overall drives headline percent and dollars`() throws { + // Regression: Cursor Enterprise/Team accounts ship `individualUsage.overall` instead of + // `individualUsage.plan`. Without a model for `overall`, the parser used to report 0% + // (i.e. the menu showed "100% remaining"). The personal cap must take precedence over + // any team pool, and USD figures must reflect the same source. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: "2026-04-01T00:00:00.000Z", + billingCycleEnd: "2026-05-01T00:00:00.000Z", + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: nil, + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: CursorOnDemandUsage(enabled: true, used: 0, limit: nil, remaining: nil), + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // Headline: $73.84 / $100 → 73.84% (matches Cursor's own dashboard). + // Allow a tiny tolerance for floating-point division (7384/10000 * 100 ≈ 73.83999…). + #expect(abs(snapshot.planPercentUsed - 73.84) < 0.0001) + #expect(snapshot.planUsedUSD == 73.84) + #expect(snapshot.planLimitUSD == 100.0) + // Lane percents stay nil because Cursor doesn't ship Auto/API breakdown for Enterprise overall. + #expect(snapshot.autoPercentUsed == nil) + #expect(snapshot.apiPercentUsed == nil) + // Headline must NOT pick up the team pool's 45.25% — personal cap wins. + let primaryPercent = try #require(snapshot.toUsageSnapshot().primary?.usedPercent) + #expect(abs(primaryPercent - 73.84) < 0.0001) + } + + @Test + func `enterprise pooled fallback used when no individual data`() { + // When Cursor only reports a shared team pool (no `plan`, no `overall`) we should still surface + // a non-zero headline so the menu reflects pool consumption rather than appearing "all clear". + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: nil, + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // 12_725_135 / 28_122_000 ≈ 45.2497...% — accept anything in (45.0, 45.5) to keep the + // assertion robust to floating-point precision. + #expect(snapshot.planPercentUsed > 45.0) + #expect(snapshot.planPercentUsed < 45.5) + #expect(snapshot.planUsedUSD == 127_251.35) + #expect(snapshot.planLimitUSD == 281_220.0) + } + + @Test + func `existing plan block still wins over overall and pooled`() { + // Guard against future drift: when Cursor sends both legacy `plan` and the newer `overall` + // blocks, the existing percent precedence must remain intact. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "pro", + limitType: "user", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: CursorPlanUsage( + enabled: true, + used: 1500, + limit: 5000, + remaining: 3500, + breakdown: nil, + autoPercentUsed: nil, + apiPercentUsed: nil, + totalPercentUsed: 30.0), + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // `plan.totalPercentUsed` wins; `overall` and `pooled` are ignored when `plan` is present. + #expect(snapshot.planPercentUsed == 30.0) + #expect(snapshot.planUsedUSD == 15.0) + #expect(snapshot.planLimitUSD == 50.0) + } + @Test func `converts snapshot to usage snapshot`() { let snapshot = CursorStatusSnapshot( From 7219c21ac7846adac5cd7965a109af674ca95bfb Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 30 Apr 2026 13:28:02 +0530 Subject: [PATCH 0262/1239] Split Cursor enterprise tests --- .../CursorEnterpriseUsageTests.swift | 169 ++++++++++++++++++ .../CursorStatusProbeTests.swift | 167 ----------------- 2 files changed, 169 insertions(+), 167 deletions(-) create mode 100644 Tests/CodexBarTests/CursorEnterpriseUsageTests.swift diff --git a/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift b/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift new file mode 100644 index 0000000000..c6b5dd19bc --- /dev/null +++ b/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CursorEnterpriseUsageTests { + @Test + func `parses enterprise overall and pooled usage summary`() throws { + // Live Cursor Enterprise payload (sanitized). The Pro/Hobby `plan` block is absent; + // instead Cursor reports `individualUsage.overall` (personal cap) and `teamUsage.pooled` + // (shared team pool). Both blocks use cents like the existing `plan` block. + let json = """ + { + "billingCycleStart": "2026-04-01T00:00:00.000Z", + "billingCycleEnd": "2026-05-01T00:00:00.000Z", + "membershipType": "enterprise", + "limitType": "team", + "isUnlimited": false, + "individualUsage": { + "overall": { + "enabled": true, + "used": 7384, + "limit": 10000, + "remaining": 2616 + } + }, + "teamUsage": { + "onDemand": { + "enabled": true, + "used": 0, + "limit": null, + "remaining": null + }, + "pooled": { + "enabled": true, + "used": 12725135, + "limit": 28122000, + "remaining": 15396865 + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let summary = try JSONDecoder().decode(CursorUsageSummary.self, from: data) + + #expect(summary.membershipType == "enterprise") + #expect(summary.limitType == "team") + #expect(summary.individualUsage?.plan == nil) + #expect(summary.individualUsage?.overall?.used == 7384) + #expect(summary.individualUsage?.overall?.limit == 10000) + #expect(summary.individualUsage?.overall?.remaining == 2616) + #expect(summary.teamUsage?.pooled?.used == 12_725_135) + #expect(summary.teamUsage?.pooled?.limit == 28_122_000) + } + + @Test + func `enterprise overall drives headline percent and dollars`() throws { + // Regression: Cursor Enterprise/Team accounts ship `individualUsage.overall` instead of + // `individualUsage.plan`. Without a model for `overall`, the parser used to report 0% + // (i.e. the menu showed "100% remaining"). The personal cap must take precedence over + // any team pool, and USD figures must reflect the same source. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: "2026-04-01T00:00:00.000Z", + billingCycleEnd: "2026-05-01T00:00:00.000Z", + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: nil, + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: CursorOnDemandUsage(enabled: true, used: 0, limit: nil, remaining: nil), + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // Headline: $73.84 / $100 -> 73.84% (matches Cursor's own dashboard). + // Allow a tiny tolerance for floating-point division (7384/10000 * 100). + #expect(abs(snapshot.planPercentUsed - 73.84) < 0.0001) + #expect(snapshot.planUsedUSD == 73.84) + #expect(snapshot.planLimitUSD == 100.0) + #expect(snapshot.autoPercentUsed == nil) + #expect(snapshot.apiPercentUsed == nil) + + let primaryPercent = try #require(snapshot.toUsageSnapshot().primary?.usedPercent) + #expect(abs(primaryPercent - 73.84) < 0.0001) + } + + @Test + func `enterprise pooled fallback used when no individual data`() { + // When Cursor only reports a shared team pool (no `plan`, no `overall`) we should still surface + // a non-zero headline so the menu reflects pool consumption rather than appearing "all clear". + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: nil, + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + #expect(snapshot.planPercentUsed > 45.0) + #expect(snapshot.planPercentUsed < 45.5) + #expect(snapshot.planUsedUSD == 127_251.35) + #expect(snapshot.planLimitUSD == 281_220.0) + } + + @Test + func `existing plan block still wins over overall and pooled`() { + // Guard against future drift: when Cursor sends both legacy `plan` and the newer `overall` + // blocks, the existing percent precedence must remain intact. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "pro", + limitType: "user", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: CursorPlanUsage( + enabled: true, + used: 1500, + limit: 5000, + remaining: 3500, + breakdown: nil, + autoPercentUsed: nil, + apiPercentUsed: nil, + totalPercentUsed: 30.0), + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + #expect(snapshot.planPercentUsed == 30.0) + #expect(snapshot.planUsedUSD == 15.0) + #expect(snapshot.planLimitUSD == 50.0) + } +} diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index defb676616..e34277f9fb 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -96,55 +96,6 @@ struct CursorStatusProbeTests { #expect(summary.individualUsage?.plan?.totalPercentUsed == 50.0) } - @Test - func `parses enterprise overall and pooled usage summary`() throws { - // Live Cursor Enterprise payload (sanitized). The Pro/Hobby `plan` block is absent; - // instead Cursor reports `individualUsage.overall` (personal cap) and `teamUsage.pooled` - // (shared team pool). Both blocks use cents like the existing `plan` block. - let json = """ - { - "billingCycleStart": "2026-04-01T00:00:00.000Z", - "billingCycleEnd": "2026-05-01T00:00:00.000Z", - "membershipType": "enterprise", - "limitType": "team", - "isUnlimited": false, - "individualUsage": { - "overall": { - "enabled": true, - "used": 7384, - "limit": 10000, - "remaining": 2616 - } - }, - "teamUsage": { - "onDemand": { - "enabled": true, - "used": 0, - "limit": null, - "remaining": null - }, - "pooled": { - "enabled": true, - "used": 12725135, - "limit": 28122000, - "remaining": 15396865 - } - } - } - """ - let data = try #require(json.data(using: .utf8)) - let summary = try JSONDecoder().decode(CursorUsageSummary.self, from: data) - - #expect(summary.membershipType == "enterprise") - #expect(summary.limitType == "team") - #expect(summary.individualUsage?.plan == nil) - #expect(summary.individualUsage?.overall?.used == 7384) - #expect(summary.individualUsage?.overall?.limit == 10000) - #expect(summary.individualUsage?.overall?.remaining == 2616) - #expect(summary.teamUsage?.pooled?.used == 12_725_135) - #expect(summary.teamUsage?.pooled?.limit == 28_122_000) - } - // MARK: - User Info Parsing @Test @@ -366,124 +317,6 @@ struct CursorStatusProbeTests { #expect(snapshot.toUsageSnapshot().primary?.remainingPercent == 99.55897435897436) } - @Test - func `enterprise overall drives headline percent and dollars`() throws { - // Regression: Cursor Enterprise/Team accounts ship `individualUsage.overall` instead of - // `individualUsage.plan`. Without a model for `overall`, the parser used to report 0% - // (i.e. the menu showed "100% remaining"). The personal cap must take precedence over - // any team pool, and USD figures must reflect the same source. - let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - .parseUsageSummary( - CursorUsageSummary( - billingCycleStart: "2026-04-01T00:00:00.000Z", - billingCycleEnd: "2026-05-01T00:00:00.000Z", - membershipType: "enterprise", - limitType: "team", - isUnlimited: false, - autoModelSelectedDisplayMessage: nil, - namedModelSelectedDisplayMessage: nil, - individualUsage: CursorIndividualUsage( - plan: nil, - onDemand: nil, - overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), - teamUsage: CursorTeamUsage( - onDemand: CursorOnDemandUsage(enabled: true, used: 0, limit: nil, remaining: nil), - pooled: CursorPooledUsage( - enabled: true, - used: 12_725_135, - limit: 28_122_000, - remaining: 15_396_865))), - userInfo: nil, - rawJSON: nil) - - // Headline: $73.84 / $100 → 73.84% (matches Cursor's own dashboard). - // Allow a tiny tolerance for floating-point division (7384/10000 * 100 ≈ 73.83999…). - #expect(abs(snapshot.planPercentUsed - 73.84) < 0.0001) - #expect(snapshot.planUsedUSD == 73.84) - #expect(snapshot.planLimitUSD == 100.0) - // Lane percents stay nil because Cursor doesn't ship Auto/API breakdown for Enterprise overall. - #expect(snapshot.autoPercentUsed == nil) - #expect(snapshot.apiPercentUsed == nil) - // Headline must NOT pick up the team pool's 45.25% — personal cap wins. - let primaryPercent = try #require(snapshot.toUsageSnapshot().primary?.usedPercent) - #expect(abs(primaryPercent - 73.84) < 0.0001) - } - - @Test - func `enterprise pooled fallback used when no individual data`() { - // When Cursor only reports a shared team pool (no `plan`, no `overall`) we should still surface - // a non-zero headline so the menu reflects pool consumption rather than appearing "all clear". - let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - .parseUsageSummary( - CursorUsageSummary( - billingCycleStart: nil, - billingCycleEnd: nil, - membershipType: "enterprise", - limitType: "team", - isUnlimited: false, - autoModelSelectedDisplayMessage: nil, - namedModelSelectedDisplayMessage: nil, - individualUsage: nil, - teamUsage: CursorTeamUsage( - onDemand: nil, - pooled: CursorPooledUsage( - enabled: true, - used: 12_725_135, - limit: 28_122_000, - remaining: 15_396_865))), - userInfo: nil, - rawJSON: nil) - - // 12_725_135 / 28_122_000 ≈ 45.2497...% — accept anything in (45.0, 45.5) to keep the - // assertion robust to floating-point precision. - #expect(snapshot.planPercentUsed > 45.0) - #expect(snapshot.planPercentUsed < 45.5) - #expect(snapshot.planUsedUSD == 127_251.35) - #expect(snapshot.planLimitUSD == 281_220.0) - } - - @Test - func `existing plan block still wins over overall and pooled`() { - // Guard against future drift: when Cursor sends both legacy `plan` and the newer `overall` - // blocks, the existing percent precedence must remain intact. - let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - .parseUsageSummary( - CursorUsageSummary( - billingCycleStart: nil, - billingCycleEnd: nil, - membershipType: "pro", - limitType: "user", - isUnlimited: false, - autoModelSelectedDisplayMessage: nil, - namedModelSelectedDisplayMessage: nil, - individualUsage: CursorIndividualUsage( - plan: CursorPlanUsage( - enabled: true, - used: 1500, - limit: 5000, - remaining: 3500, - breakdown: nil, - autoPercentUsed: nil, - apiPercentUsed: nil, - totalPercentUsed: 30.0), - onDemand: nil, - overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), - teamUsage: CursorTeamUsage( - onDemand: nil, - pooled: CursorPooledUsage( - enabled: true, - used: 12_725_135, - limit: 28_122_000, - remaining: 15_396_865))), - userInfo: nil, - rawJSON: nil) - - // `plan.totalPercentUsed` wins; `overall` and `pooled` are ignored when `plan` is present. - #expect(snapshot.planPercentUsed == 30.0) - #expect(snapshot.planUsedUSD == 15.0) - #expect(snapshot.planLimitUSD == 50.0) - } - @Test func `converts snapshot to usage snapshot`() { let snapshot = CursorStatusSnapshot( From 3ce6e2ac96b8f4abdddbfbf35acba317fc4f8780 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 30 Apr 2026 13:39:28 +0530 Subject: [PATCH 0263/1239] Update Cursor Enterprise changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299b68f27a..33cc139356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! ### Fixes +- Cursor: show Enterprise/Team usage from personal caps and shared pools instead of reporting 100% remaining (#813). Thanks @fcamus00! - Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! ## 0.23 — 2026-04-26 From cbd59562addcbcce55e4bb086ad2b3577470b48c Mon Sep 17 00:00:00 2001 From: xiaoqianWX Date: Fri, 1 May 2026 05:15:06 +0800 Subject: [PATCH 0264/1239] Improve OpenAI dashboard refresh --- .../Codex/CodexConsumerProjection.swift | 26 +- .../StatusItemController+HostedSubmenus.swift | 3 +- .../CodexBar/StatusItemController+Menu.swift | 4 +- .../UsageBreakdownChartMenuView.swift | 2 +- .../CodexBar/UsageStore+HistoricalPace.swift | 5 +- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 72 ++++- .../CodexBarCore/OpenAIDashboardModels.swift | 33 +- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 296 ++++++++++++++++-- .../OpenAIWeb/OpenAIDashboardParser.swift | 50 ++- .../OpenAIDashboardScrapeScript.swift | 232 ++++++++++---- .../OpenAIDashboardWebViewCache.swift | 24 +- .../CodexManagedOpenAIWebRefreshTests.swift | 64 ++++ .../CodexUserFacingErrorTests.swift | 22 ++ ...enAIDashboardFetcherCreditsWaitTests.swift | 77 +++++ .../OpenAIDashboardModelsTests.swift | 94 ++++++ .../OpenAIDashboardParserTests.swift | 31 ++ 16 files changed, 924 insertions(+), 111 deletions(-) create mode 100644 Tests/CodexBarTests/OpenAIDashboardModelsTests.swift diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index 15d2a63b97..9e49fb8b98 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -24,6 +24,15 @@ struct CodexUIErrorMapper { return "OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again." } + if self.looksOpenAIWebTimeout(lower: lower) { + return "OpenAI web refresh timed out. Refresh OpenAI cookies and try again." + } + + if self.looksOpenAIWebNetworkError(lower: lower) { + return "OpenAI web refresh hit a network error. " + + "Check your connection, then refresh OpenAI cookies and try again." + } + if self.looksInternalTransport(lower: lower) { return "Codex usage is temporarily unavailable. Try refreshing." } @@ -60,6 +69,10 @@ struct CodexUIErrorMapper { || lower.contains("codex credits are still loading") || lower.contains("codex account changed; importing browser cookies") || lower.contains("codex session expired. sign in again.") + || lower.contains("openai web refresh timed out. refresh openai cookies and try again.") + || lower.contains( + "openai web refresh hit a network error. " + + "check your connection, then refresh openai cookies and try again.") || lower.contains("codex usage is temporarily unavailable. try refreshing.") } @@ -84,6 +97,15 @@ struct CodexUIErrorMapper { || lower.contains("get http://") || lower.contains("returned invalid data") } + + private static func looksOpenAIWebTimeout(lower: String) -> Bool { + lower.contains("nsurlerrordomain") + && (lower.contains("timed out") || lower.contains("error -1001")) + } + + private static func looksOpenAIWebNetworkError(lower: String) -> Bool { + lower.contains("nsurlerrordomain") + } } struct CodexConsumerProjection { @@ -194,10 +216,12 @@ struct CodexConsumerProjection { [] } + let displayableUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard?.usageBreakdown ?? []) let canShowBuyCredits = surface == .liveCard let hasUsageBreakdown = surface == .liveCard && dashboardVisibility == .attached - && !(dashboard?.usageBreakdown ?? []).isEmpty + && !displayableUsageBreakdown.isEmpty let hasCreditsHistory = surface == .liveCard && dashboardVisibility == .attached && !(dashboard?.dailyBreakdown ?? []).isEmpty diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index c716636b27..4334b9b327 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -62,7 +62,8 @@ extension StatusItemController { @discardableResult func appendUsageBreakdownChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + let breakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? []) guard !breakdown.isEmpty else { return false } if !Self.menuCardRenderingEnabled { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f3c9247c35..4537ec4515 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1295,7 +1295,9 @@ extension StatusItemController { } private func makeUsageBreakdownSubmenu() -> NSMenu? { - guard !(self.store.openAIDashboard?.usageBreakdown ?? []).isEmpty else { return nil } + let breakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? []) + guard !breakdown.isEmpty else { return nil } return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageBreakdownChartID) } diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index ee0ccaa65f..5c490cab00 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -146,7 +146,7 @@ struct UsageBreakdownChartMenuView: View { private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { - let sorted = breakdown + let sorted = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) .sorted { lhs, rhs in lhs.day < rhs.day } var points: [Point] = [] diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index cc26cd0bf5..0d6666c024 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -98,7 +98,9 @@ extension UsageStore { { guard self.settings.historicalTrackingEnabled else { return } guard authorityDecision.allowedEffects.contains(.historicalBackfill) else { return } - guard !dashboard.usageBreakdown.isEmpty else { return } + let usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard.usageBreakdown) + guard !usageBreakdown.isEmpty else { return } let codexSnapshot = self.snapshots[.codex] let ownership = self.codexOwnershipContext(preferredEmail: attachedAccountEmail) @@ -128,7 +130,6 @@ extension UsageStore { } let historyStore = self.historicalUsageHistoryStore - let usageBreakdown = dashboard.usageBreakdown Task.detached(priority: .utility) { [weak self] in _ = await historyStore.backfillCodexWeeklyFromUsageBreakdown( usageBreakdown, diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 53b6e20160..32a4de7a7b 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -29,7 +29,7 @@ extension UsageStore { } private static let openAIWebRefreshMultiplier: TimeInterval = 5 - private static let openAIWebPrimaryFetchTimeout: TimeInterval = 15 + private static let openAIWebPrimaryFetchTimeout: TimeInterval = 25 private static let openAIWebRetryFetchTimeout: TimeInterval = 8 private static let openAIWebPostImportFetchTimeout: TimeInterval = 25 @@ -494,6 +494,13 @@ extension UsageStore { latestCookieImportStatus: &latestCookieImportStatus, logger: log) } catch { + if Self.isOpenAIDashboardTimeout(error) { + await self.retryOpenAIDashboardAfterTimeout( + context: context, + latestCookieImportStatus: &latestCookieImportStatus, + logger: log) + return + } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: context.targetEmail, @@ -506,6 +513,56 @@ extension UsageStore { } } + private func retryOpenAIDashboardAfterTimeout( + context: OpenAIDashboardRefreshContext, + latestCookieImportStatus: inout String?, + logger: @escaping (String) -> Void) async + { + let targetEmail = self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, + allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) + var effectiveEmail = targetEmail + let imported = await self.importOpenAIDashboardCookiesIfNeeded( + targetEmail: targetEmail, + force: true, + preferCachedCookieHeader: true) + latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() + if await self.abortOpenAIDashboardRetryAfterImportFailure( + importedEmail: imported, + targetEmail: targetEmail, + expectedGuard: context.expectedGuard, + cookieImportStatus: latestCookieImportStatus, + refreshTaskToken: context.refreshTaskToken) + { + return + } + if let imported { + effectiveEmail = imported + } + do { + let dash = try await self.loadLatestOpenAIDashboard( + accountEmail: effectiveEmail, + logger: logger, + timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + await self.applyOpenAIDashboard( + dash, + targetEmail: effectiveEmail, + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + allowCodexUsageBackfill: context.allowCodexUsageBackfill) + } catch { + let message = self.preferredOpenAIDashboardFailureMessage( + error: error, + targetEmail: targetEmail, + cookieImportStatus: latestCookieImportStatus) + await self.applyOpenAIDashboardFailure( + message: message, + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + routingTargetEmail: targetEmail) + } + } + private func retryOpenAIDashboardAfterNoData( body: String, context: OpenAIDashboardRefreshContext, @@ -752,6 +809,11 @@ extension UsageStore { return error.localizedDescription } + private static func isOpenAIDashboardTimeout(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut + } + private func abortOpenAIDashboardRetryAfterImportFailure( importedEmail: String?, targetEmail: String?, @@ -855,7 +917,11 @@ extension UsageStore { return false } - func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? { + func importOpenAIDashboardCookiesIfNeeded( + targetEmail: String?, + force: Bool, + preferCachedCookieHeader: Bool? = nil) async -> String? + { if await self.openAIWebCookieImportShouldFailClosed() { return nil } @@ -921,7 +987,7 @@ extension UsageStore { result = try await importer.importBestCookies( intoAccountEmail: normalizedTarget, allowAnyAccount: allowAnyAccount, - preferCachedCookieHeader: !force, + preferCachedCookieHeader: preferCachedCookieHeader ?? !force, cacheScope: cacheScope, logger: log) case .off: diff --git a/Sources/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index 702f1582a6..7d776a2403 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -36,7 +36,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { self.codeReviewLimit = codeReviewLimit self.creditEvents = creditEvents self.dailyBreakdown = dailyBreakdown - self.usageBreakdown = usageBreakdown + self.usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: usageBreakdown) self.creditsPurchaseURL = creditsPurchaseURL self.primaryLimit = primaryLimit self.secondaryLimit = secondaryLimit @@ -72,9 +72,11 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { [OpenAIDashboardDailyBreakdown].self, forKey: .dailyBreakdown) ?? Self.makeDailyBreakdown(from: self.creditEvents, maxDays: 30) - self.usageBreakdown = try container.decodeIfPresent( + let decodedUsageBreakdown = try container.decodeIfPresent( [OpenAIDashboardDailyBreakdown].self, forKey: .usageBreakdown) ?? [] + self.usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: decodedUsageBreakdown) self.creditsPurchaseURL = try container.decodeIfPresent(String.self, forKey: .creditsPurchaseURL) self.primaryLimit = try container.decodeIfPresent(RateWindow.self, forKey: .primaryLimit) self.secondaryLimit = try container.decodeIfPresent(RateWindow.self, forKey: .secondaryLimit) @@ -145,6 +147,33 @@ public struct OpenAIDashboardDailyBreakdown: Codable, Equatable, Sendable { self.services = services self.totalCreditsUsed = totalCreditsUsed } + + public static func isSkillUsageService(_ service: String) -> Bool { + service + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("skillusage:") + } + + public static func removingSkillUsageServices( + from breakdown: [OpenAIDashboardDailyBreakdown]) + -> [OpenAIDashboardDailyBreakdown] + { + breakdown.compactMap { day in + guard !day.services.isEmpty else { + return day.totalCreditsUsed > 0 ? day : nil + } + + let services = day.services.filter { !self.isSkillUsageService($0.service) } + guard !services.isEmpty else { return nil } + + let total = services.reduce(0) { $0 + $1.creditsUsed } + return OpenAIDashboardDailyBreakdown( + day: day.day, + services: services, + totalCreditsUsed: total) + } + } } public struct OpenAIDashboardServiceUsage: Codable, Equatable, Sendable { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 71ab766287..9877948fb9 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -20,6 +20,8 @@ public struct OpenAIDashboardFetcher { } private let usageURL = URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")! + private nonisolated static let dashboardAcceptLanguage = "en-US,en;q=0.9" + private nonisolated static let dashboardUsageAPIURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! public init() {} @@ -43,6 +45,7 @@ public struct OpenAIDashboardFetcher { } private struct DashboardSnapshotComponents { + let signedInEmail: String? let scrape: ScrapeResult let codeReview: Double? let codeReviewLimit: RateWindow? @@ -58,7 +61,7 @@ public struct OpenAIDashboardFetcher { -> OpenAIDashboardSnapshot { OpenAIDashboardSnapshot( - signedInEmail: components.scrape.signedInEmail, + signedInEmail: components.signedInEmail, codeReviewRemainingPercent: components.codeReview, codeReviewLimit: components.codeReviewLimit, creditEvents: components.events, @@ -72,6 +75,17 @@ public struct OpenAIDashboardFetcher { updatedAt: Date()) } + struct DashboardAPIData: Sendable { + let primaryLimit: RateWindow? + let secondaryLimit: RateWindow? + let creditsRemaining: Double? + let accountPlan: String? + + var hasUsageData: Bool { + self.primaryLimit != nil || self.secondaryLimit != nil || self.creditsRemaining != nil + } + } + public struct ProbeResult: Sendable { public let href: String? public let loginRequired: Bool @@ -134,6 +148,12 @@ public struct OpenAIDashboardFetcher { timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) + let preflight = await Self.fetchDashboardAPIPreflight( + websiteDataStore: websiteDataStore, + logger: { logger?($0) }) + let apiData = preflight.apiData + let verifiedSignedInEmail = preflight.verifiedSignedInEmail + let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, @@ -176,41 +196,49 @@ public struct OpenAIDashboardFetcher { // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if let href = scrape.href, !Self.isUsageRoute(href) { - _ = webView.load(URLRequest(url: self.usageURL)) + _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue } - if scrape.loginRequired { - if debugDumpHTML, let html = scrape.bodyHTML { - Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) - } - throw FetchError.loginRequired - } - - if scrape.cloudflareInterstitial { - if debugDumpHTML, let html = scrape.bodyHTML { - Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) - } - throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") - } + try Self.throwIfBlockingScrapeState(scrape, debugDumpHTML: debugDumpHTML, logger: log) let bodyText = scrape.bodyText ?? "" let codeReview = OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText) let events = OpenAIDashboardParser.parseCreditEvents(rows: scrape.rows) let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: events, maxDays: 30) let usageBreakdown = scrape.usageBreakdown - let rateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) + let parsedRateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) + let rateLimits = ( + primary: apiData?.primaryLimit ?? parsedRateLimits.primary, + secondary: apiData?.secondaryLimit ?? parsedRateLimits.secondary) let codeReviewLimit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: bodyText) - let creditsRemaining = OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) - let accountPlan = scrape.bodyHTML.flatMap(OpenAIDashboardParser.parsePlanFromHTML) + let parsedCreditsRemaining = OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) + let creditsRemaining = apiData?.creditsRemaining ?? parsedCreditsRemaining + let parsedAccountPlan = scrape.bodyHTML.flatMap(OpenAIDashboardParser.parsePlanFromHTML) + let accountPlan = parsedAccountPlan + ?? apiData?.accountPlan + let hasParsedUsageLimits = parsedRateLimits.primary != nil || parsedRateLimits.secondary != nil let hasUsageLimits = rateLimits.primary != nil || rateLimits.secondary != nil + let signedInEmail = Self.firstNonEmpty(scrape.signedInEmail, verifiedSignedInEmail) + let hasDashboardPageData = Self.hasReturnableDashboardData( + codeReview: codeReview, + events: events, + usageBreakdown: usageBreakdown, + hasUsageLimits: hasParsedUsageLimits, + creditsRemaining: parsedCreditsRemaining) + let hasDashboardPageSignal = Self.hasAnyDashboardSignal( + hasReturnableData: hasDashboardPageData, + creditsHeaderPresent: scrape.creditsHeaderPresent) + let hasReturnableData = Self.hasReturnableDashboardData( + codeReview: codeReview, + events: events, + usageBreakdown: usageBreakdown, + hasUsageLimits: hasUsageLimits, + creditsRemaining: creditsRemaining) if codeReview != nil, codeReviewFirstSeenAt == nil { codeReviewFirstSeenAt = Date() } - if anyDashboardSignalAt == nil, - codeReview != nil || !usageBreakdown.isEmpty || scrape.creditsHeaderPresent || - hasUsageLimits || creditsRemaining != nil - { + if anyDashboardSignalAt == nil, hasDashboardPageSignal { anyDashboardSignalAt = Date() } if codeReview != nil, usageBreakdown.isEmpty, @@ -225,7 +253,8 @@ public struct OpenAIDashboardFetcher { log("credits purchase url: \(purchaseURL)") } if events.isEmpty, - codeReview != nil || !usageBreakdown.isEmpty || hasUsageLimits || creditsRemaining != nil + hasReturnableData, + hasDashboardPageSignal { log( "credits header present=\(scrape.creditsHeaderPresent) " + @@ -255,9 +284,7 @@ public struct OpenAIDashboardFetcher { } } - if codeReview != nil || !events.isEmpty || !usageBreakdown - .isEmpty || hasUsageLimits || creditsRemaining != nil - { + if hasReturnableData, hasDashboardPageSignal { // The usage breakdown chart is hydrated asynchronously. When code review is already present, // give it a moment to populate so the menu can show it. if codeReview != nil, usageBreakdown.isEmpty { @@ -268,6 +295,7 @@ public struct OpenAIDashboardFetcher { } } return Self.makeDashboardSnapshot(.init( + signedInEmail: signedInEmail, scrape: scrape, codeReview: codeReview, codeReviewLimit: codeReviewLimit, @@ -345,6 +373,23 @@ public struct OpenAIDashboardFetcher { return false } + nonisolated static func hasReturnableDashboardData( + codeReview: Double?, + events: [CreditEvent], + usageBreakdown: [OpenAIDashboardDailyBreakdown], + hasUsageLimits: Bool, + creditsRemaining: Double?) -> Bool + { + codeReview != nil || !events.isEmpty || !usageBreakdown.isEmpty || hasUsageLimits || creditsRemaining != nil + } + + nonisolated static func hasAnyDashboardSignal( + hasReturnableData: Bool, + creditsHeaderPresent: Bool) -> Bool + { + hasReturnableData || creditsHeaderPresent + } + public func clearSessionData(accountEmail: String?) async { let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) @@ -389,7 +434,7 @@ public struct OpenAIDashboardFetcher { if let href = scrape.href, !Self.isUsageRoute(href) { usageRouteSeenAt = nil dashboardSignalSeenAt = nil - _ = webView.load(URLRequest(url: self.usageURL)) + _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue } @@ -508,7 +553,8 @@ public struct OpenAIDashboardFetcher { if let raw = dict["usageBreakdownJSON"] as? String, !raw.isEmpty { do { let decoder = JSONDecoder() - usageBreakdown = try decoder.decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + let decoded = try decoder.decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: decoded) } catch { // Best-effort parse; ignore errors to avoid blocking other dashboard data. usageBreakdown = [] @@ -550,6 +596,26 @@ public struct OpenAIDashboardFetcher { didScrollToCredits: (dict["didScrollToCredits"] as? Bool) ?? false) } + private static func throwIfBlockingScrapeState( + _ scrape: ScrapeResult, + debugDumpHTML: Bool, + logger: (String) -> Void) throws + { + if scrape.loginRequired { + if debugDumpHTML, let html = scrape.bodyHTML { + self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) + } + throw FetchError.loginRequired + } + + if scrape.cloudflareInterstitial { + if debugDumpHTML, let html = scrape.bodyHTML { + self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) + } + throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") + } + } + private func makeWebView( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, @@ -587,6 +653,180 @@ public struct OpenAIDashboardFetcher { || path.hasSuffix("codex/cloud/settings/analytics") } + nonisolated static func usageURLRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + return request + } + + nonisolated static func dashboardUsageAPIRequest(cookieHeader: String) -> URLRequest { + var request = URLRequest(url: Self.dashboardUsageAPIURL) + request.httpMethod = "GET" + request.timeoutInterval = 4 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + return request + } + + nonisolated static func dashboardIdentityAPIRequest(url: URL, cookieHeader: String) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 2 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + return request + } + + nonisolated static func dashboardAPIData(from response: CodexUsageResponse) -> DashboardAPIData { + DashboardAPIData( + primaryLimit: self.rateWindow(from: response.rateLimit?.primaryWindow), + secondaryLimit: self.rateWindow(from: response.rateLimit?.secondaryWindow), + creditsRemaining: response.credits?.balance, + accountPlan: response.planType?.rawValue) + } + + private static func fetchDashboardAPIPreflight( + websiteDataStore: WKWebsiteDataStore, + logger: @escaping (String) -> Void) + async -> (apiData: DashboardAPIData?, verifiedSignedInEmail: String?) + { + let cookieHeader = await self.chatGPTCookieHeader(in: websiteDataStore) + let apiData = await self.fetchDashboardUsageAPI(cookieHeader: cookieHeader, logger: logger) + let verifiedEmail: String? = if apiData?.hasUsageData == true { + await self.fetchSignedInEmailFromAPI(cookieHeader: cookieHeader, logger: logger) + } else { + nil + } + + if apiData?.hasUsageData == true, verifiedEmail != nil { + logger("usage api supplied verified dashboard data; continuing WebView scrape") + } + return (apiData, verifiedEmail) + } + + private static func fetchDashboardUsageAPI( + websiteDataStore: WKWebsiteDataStore, + logger: @escaping (String) -> Void) async -> DashboardAPIData? + { + let cookieHeader = await self.chatGPTCookieHeader(in: websiteDataStore) + return await self.fetchDashboardUsageAPI(cookieHeader: cookieHeader, logger: logger) + } + + private static func fetchDashboardUsageAPI( + cookieHeader: String, + logger: @escaping (String) -> Void) async -> DashboardAPIData? + { + guard !cookieHeader.isEmpty else { return nil } + + do { + let (data, response) = try await URLSession.shared.data( + for: self.dashboardUsageAPIRequest(cookieHeader: cookieHeader)) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger("usage api status=\(status)") + guard status >= 200, status < 300 else { return nil } + let decoded = try JSONDecoder().decode(CodexUsageResponse.self, from: data) + let result = self.dashboardAPIData(from: decoded) + if result.hasUsageData { + logger("usage api supplied language-independent rate/credit data") + } + return result + } catch { + logger("usage api unavailable: \(error.localizedDescription)") + return nil + } + } + + private static func fetchSignedInEmailFromAPI( + cookieHeader: String, + logger: @escaping (String) -> Void) async -> String? + { + guard !cookieHeader.isEmpty else { return nil } + + let endpoints = [ + URL(string: "https://chatgpt.com/backend-api/me"), + URL(string: "https://chatgpt.com/api/auth/session"), + ].compactMap(\.self) + + for url in endpoints { + do { + let (data, response) = try await URLSession.shared.data( + for: self.dashboardIdentityAPIRequest(url: url, cookieHeader: cookieHeader)) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger("identity api \(url.path) status=\(status)") + guard status >= 200, status < 300 else { continue } + if let email = self.findFirstEmail(inJSONData: data) { + return email.trimmingCharacters(in: .whitespacesAndNewlines) + } + } catch { + logger("identity api \(url.path) unavailable: \(error.localizedDescription)") + } + } + + return nil + } + + private static func chatGPTCookieHeader(in store: WKWebsiteDataStore) async -> String { + let cookies = await withCheckedContinuation { continuation in + store.httpCookieStore.getAllCookies { cookies in + continuation.resume(returning: cookies) + } + } + + return cookies + .filter { $0.domain.lowercased().contains("chatgpt.com") } + .map { "\($0.name)=\($0.value)" } + .joined(separator: "; ") + } + + nonisolated static func findFirstEmail(inJSONData data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } + var queue: [Any] = [json] + var seen = 0 + while !queue.isEmpty, seen < 2000 { + let current = queue.removeFirst() + seen += 1 + if let string = current as? String, string.contains("@") { + return string + } + if let dictionary = current as? [String: Any] { + for (key, value) in dictionary { + if key.lowercased() == "email", + let string = value as? String, + string.contains("@") + { + return string + } + queue.append(value) + } + } else if let array = current as? [Any] { + queue.append(contentsOf: array) + } + } + return nil + } + + private nonisolated static func rateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let window else { return nil } + let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) + return RateWindow( + usedPercent: Double(window.usedPercent), + windowMinutes: window.limitWindowSeconds / 60, + resetsAt: resetDate, + resetDescription: UsageFormatter.resetDescription(from: resetDate)) + } + + private nonisolated static func firstNonEmpty(_ candidates: String?...) -> String? { + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed?.isEmpty == false { return trimmed } + } + return nil + } + private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) { let stamp = Int(Date().timeIntervalSince1970) let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift index d9be82bbdb..bb18722867 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift @@ -153,11 +153,49 @@ public enum OpenAIDashboardParser { } private static func parseCreditsUsed(_ text: String) -> Double { - let cleaned = text - .replacingOccurrences(of: ",", with: "") - .replacingOccurrences(of: "credits", with: "", options: .caseInsensitive) - .trimmingCharacters(in: .whitespacesAndNewlines) - return Double(cleaned) ?? 0 + guard let raw = self.firstNumberToken(in: text) else { return 0 } + let token = raw + .replacingOccurrences(of: "\u{00A0}", with: "") + .replacingOccurrences(of: "\u{202F}", with: "") + .replacingOccurrences(of: " ", with: "") + let hasComma = token.contains(",") + let hasDot = token.contains(".") + if hasComma, hasDot { + return TextParsing.firstNumber(pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, text: token) ?? 0 + } + if hasComma { + if self.usesLocalizedDecimalCommaCreditLabel(text) { + return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0 + } + if token.range(of: #"^\d{1,3}(,\d{3})+$"#, options: .regularExpression) != nil { + return Double(token.replacingOccurrences(of: ",", with: "")) ?? 0 + } + return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0 + } + return Double(token) ?? 0 + } + + private static func usesLocalizedDecimalCommaCreditLabel(_ text: String) -> Bool { + text + .lowercased() + .contains("crédit") + } + + private static func firstNumberToken(in text: String) -> String? { + guard let regex = try? NSRegularExpression( + pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, + options: []) + else { + return nil + } + let range = NSRange(text.startIndex..= 2, + let tokenRange = Range(match.range(at: 1), in: text) + else { + return nil + } + return String(text[tokenRange]) } // MARK: - Private @@ -294,6 +332,7 @@ public enum OpenAIDashboardParser { private static func isFiveHourLimitLine(_ line: String) -> Bool { let lower = line.lowercased() if lower.contains("5h") { return true } + if lower.range(of: #"\b5\s*h\b"#, options: .regularExpression) != nil { return true } if lower.contains("5-hour") { return true } if lower.contains("5 hour") { return true } return false @@ -305,6 +344,7 @@ public enum OpenAIDashboardParser { if lower.contains("7-day") { return true } if lower.contains("7 day") { return true } if lower.contains("7d") { return true } + if lower.range(of: #"\b7\s*d\b"#, options: .regularExpression) != nil { return true } return false } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift index 06d7dea612..54129eba5b 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift @@ -227,9 +227,14 @@ let openAIDashboardScrapeScript = """ } return null; }; + const isSkillUsageServiceKey = (raw) => { + const key = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase(); + return key.startsWith('skillusage:'); + }; const displayNameForUsageServiceKey = (raw) => { const key = raw === null || raw === undefined ? '' : String(raw).trim(); if (!key) return key; + if (isSkillUsageServiceKey(key)) return null; if (key.toUpperCase() === key && key.length <= 6) return key; const lower = key.toLowerCase(); if (lower === 'cli') return 'CLI'; @@ -237,20 +242,46 @@ let openAIDashboardScrapeScript = """ const words = lower.replace(/[_-]+/g, ' ').split(' ').filter(Boolean); return words.map(w => w.length <= 2 ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(' '); }; - const usageBreakdownJSON = (() => { - try { - if (window.__codexbarUsageBreakdownJSON) return window.__codexbarUsageBreakdownJSON; - - const sections = Array.from(document.querySelectorAll('section')); - const usageSection = sections.find(s => { - const h2 = s.querySelector('h2'); - return h2 && textOf(h2).toLowerCase().startsWith('usage breakdown'); - }); - if (!usageSection) return null; - - const legendMap = {}; + const isLikelyCodexUsageService = (raw) => { + const service = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase(); + return ( + service === 'cli' || + service === 'desktop' || + service === 'desktop app' || + service === 'vscode' || + service === 'vs code' || + service === 'unknown' || + (service.includes('github') && service.includes('review')) + ); + }; + const usageChartRootForPath = (path) => { + if (!path || !path.closest) return null; + return ( + path.closest('.recharts-wrapper') || + path.closest('svg.recharts-surface') || + path.closest('section') || + path.parentElement || + null + ); + }; + const uniqueUsageChartRoots = (paths) => { + const roots = []; + for (const path of paths) { + const root = usageChartRootForPath(path); + if (root && !roots.includes(root)) roots.push(root); + } + return roots; + }; + const legendMapForUsageChartRoot = (root) => { + const legendMap = {}; + const scopes = [ + root, + root && root.parentElement, + root && root.closest ? root.closest('section') : null + ].filter(Boolean); + for (const scope of scopes) { try { - const legendItems = Array.from(usageSection.querySelectorAll('div[title]')); + const legendItems = Array.from(scope.querySelectorAll('div[title]')); for (const item of legendItems) { const title = item.getAttribute('title') ? String(item.getAttribute('title')).trim() : ''; const square = item.querySelector('div[style*=\"background-color\"]'); @@ -261,11 +292,90 @@ let openAIDashboardScrapeScript = """ if (title && hex) legendMap[hex] = title; } } catch {} + if (Object.keys(legendMap).length > 0) break; + } + return legendMap; + }; + const parseUsageBreakdownFromChartPaths = (paths, legendMap) => { + const totalsByDay = {}; // day -> service -> value + const addValue = (day, service, value) => { + if (!day || !service || isSkillUsageServiceKey(service)) return false; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return false; + if (!totalsByDay[day]) totalsByDay[day] = {}; + totalsByDay[day][service] = (totalsByDay[day][service] || 0) + value; + return true; + }; + let pointCount = 0; + for (const path of paths) { + const meta = barMetaFromElement(path) || barMetaFromElement(path.parentElement) || null; + if (!meta) continue; + + const payload = meta.payload || null; + const day = dayKeyFromPayload(payload); + if (!day) continue; + + const valuesObj = (payload && payload.values && typeof payload.values === 'object') ? payload.values : null; + if (valuesObj) { + for (const [k, v] of Object.entries(valuesObj)) { + const service = displayNameForUsageServiceKey(k); + if (addValue(day, service, v)) pointCount++; + } + continue; + } + + let value = null; + if (typeof meta.value === 'number' && Number.isFinite(meta.value)) value = meta.value; + if (value === null && typeof meta.value === 'string') { + const v = parseFloat(meta.value.replace(/,/g, '')); + if (Number.isFinite(v)) value = v; + } + if (value === null) continue; - const totalsByDay = {}; // day -> service -> value - const paths = Array.from(usageSection.querySelectorAll('g.recharts-bar-rectangle path.recharts-rectangle')); + const fill = parseHexColor(meta.fill || path.getAttribute('fill')); + const service = + (fill && legendMap[fill]) || + (typeof meta.name === 'string' && meta.name) || + null; + if (addValue(day, service, value)) pointCount++; + } + + const dayKeys = Object.keys(totalsByDay) + .filter(day => Object.keys(totalsByDay[day] || {}).length > 0) + .sort((a, b) => b.localeCompare(a)) + .slice(0, 30); + const breakdown = dayKeys.map(day => { + const servicesMap = totalsByDay[day] || {}; + const services = Object.keys(servicesMap).map(service => ({ + service, + creditsUsed: servicesMap[service] + })).sort((a, b) => { + if (a.creditsUsed === b.creditsUsed) return a.service.localeCompare(b.service); + return b.creditsUsed - a.creditsUsed; + }); + const totalCreditsUsed = services.reduce((sum, s) => sum + (Number(s.creditsUsed) || 0), 0); + return { day, services, totalCreditsUsed }; + }); + const services = Array.from(new Set(breakdown.flatMap(day => day.services.map(service => service.service)))); + const totalCreditsUsed = breakdown.reduce((sum, day) => sum + (Number(day.totalCreditsUsed) || 0), 0); + const likelyCodexServiceCount = services.filter(isLikelyCodexUsageService).length; + return { + breakdown, + pointCount, + services, + totalCreditsUsed, + likelyCodexServiceCount, + score: likelyCodexServiceCount * 1000 + services.length * 100 + pointCount + totalCreditsUsed / 1000 + }; + }; + const usageBreakdownJSON = (() => { + try { + if (window.__codexbarUsageBreakdownJSON) return window.__codexbarUsageBreakdownJSON; + + const paths = Array.from(document.querySelectorAll('g.recharts-bar-rectangle path.recharts-rectangle')); let debug = { pathCount: paths.length, + chartCount: 0, + candidateSummaries: [], sampleReactKeys: null, sampleMetaKeys: null, samplePayloadKeys: null, @@ -292,59 +402,29 @@ let openAIDashboardScrapeScript = """ } } } catch {} - for (const path of paths) { - const meta = barMetaFromElement(path) || barMetaFromElement(path.parentElement) || null; - if (!meta) continue; - - const payload = meta.payload || null; - const day = dayKeyFromPayload(payload); - if (!day) continue; - - const valuesObj = (payload && payload.values && typeof payload.values === 'object') ? payload.values : null; - if (valuesObj) { - if (!totalsByDay[day]) totalsByDay[day] = {}; - for (const [k, v] of Object.entries(valuesObj)) { - if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) continue; - const service = displayNameForUsageServiceKey(k); - if (!service) continue; - totalsByDay[day][service] = (totalsByDay[day][service] || 0) + v; - } - continue; - } - - let value = null; - if (typeof meta.value === 'number' && Number.isFinite(meta.value)) value = meta.value; - if (value === null && typeof meta.value === 'string') { - const v = parseFloat(meta.value.replace(/,/g, '')); - if (Number.isFinite(v)) value = v; - } - if (value === null) continue; - - const fill = parseHexColor(meta.fill || path.getAttribute('fill')); - const service = - (fill && legendMap[fill]) || - (typeof meta.name === 'string' && meta.name) || - null; - if (!service) continue; - - if (!totalsByDay[day]) totalsByDay[day] = {}; - totalsByDay[day][service] = (totalsByDay[day][service] || 0) + value; - } - const dayKeys = Object.keys(totalsByDay).sort((a, b) => b.localeCompare(a)).slice(0, 30); - const breakdown = dayKeys.map(day => { - const servicesMap = totalsByDay[day] || {}; - const services = Object.keys(servicesMap).map(service => ({ - service, - creditsUsed: servicesMap[service] - })).sort((a, b) => { - if (a.creditsUsed === b.creditsUsed) return a.service.localeCompare(b.service); - return b.creditsUsed - a.creditsUsed; - }); - const totalCreditsUsed = services.reduce((sum, s) => sum + (Number(s.creditsUsed) || 0), 0); - return { day, services, totalCreditsUsed }; - }); + const roots = uniqueUsageChartRoots(paths); + debug.chartCount = roots.length; + const candidates = roots.map(root => { + const chartPaths = paths.filter(path => usageChartRootForPath(path) === root); + const parsed = parseUsageBreakdownFromChartPaths(chartPaths, legendMapForUsageChartRoot(root)); + return { + root, + pathCount: chartPaths.length, + ...parsed + }; + }).filter(candidate => candidate.breakdown.length > 0); + candidates.sort((a, b) => b.score - a.score); + debug.candidateSummaries = candidates.slice(0, 6).map(candidate => ({ + pathCount: candidate.pathCount, + dayCount: candidate.breakdown.length, + pointCount: candidate.pointCount, + serviceCount: candidate.services.length, + likelyCodexServiceCount: candidate.likelyCodexServiceCount, + services: candidate.services.slice(0, 8) + })); + const breakdown = candidates[0] ? candidates[0].breakdown : []; const json = (breakdown.length > 0) ? JSON.stringify(breakdown) : null; window.__codexbarUsageBreakdownJSON = json; window.__codexbarUsageBreakdownDebug = json ? null : JSON.stringify(debug); @@ -395,6 +475,16 @@ let openAIDashboardScrapeScript = """ let didScrollToCredits = false; let rows = []; try { + const looksLikeCreditsEventRow = (cells) => { + if (!cells || cells.length < 3) return false; + const first = String(cells[0] || ''); + const amount = String(cells[2] || ''); + return /\\d{4}|\\d{1,2}[\\/.\\-]\\d{1,2}/.test(first) && /\\d/.test(amount); + }; + const allTableRows = () => Array.from(document.querySelectorAll('tbody tr')).map(tr => { + const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td)); + return cells; + }).filter(looksLikeCreditsEventRow); const headings = Array.from(document.querySelectorAll('h1,h2,h3')); const header = headings.find(h => textOf(h).toLowerCase() === 'credits usage history'); if (header) { @@ -411,6 +501,9 @@ let openAIDashboardScrapeScript = """ const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td)); return cells; }).filter(r => r.length >= 3); + if (rows.length === 0) { + rows = allTableRows(); + } if (rows.length === 0 && !window.__codexbarDidScrollToCredits) { window.__codexbarDidScrollToCredits = true; // If the table is virtualized/lazy-loaded, we need to scroll to trigger rendering even if the @@ -422,6 +515,13 @@ let openAIDashboardScrapeScript = """ didScrollToCredits = true; } } else if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) { + rows = allTableRows(); + if (rows.length > 0) { + creditsHeaderPresent = true; + creditsHeaderInViewport = true; + } + } + if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) { // The credits history section often isn't part of the DOM until you scroll down. Nudge the page // once so subsequent scrapes can find the header and rows. window.__codexbarDidScrollToCredits = true; diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 1a90d28162..3706bc3126 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -98,6 +98,22 @@ final class OpenAIDashboardWebViewCache { } })(); """ + private let preferredLanguageScript = """ + (() => { + const define = (target, name, value) => { + try { + Object.defineProperty(target, name, { + get: () => value, + configurable: true + }); + } catch {} + }; + define(Navigator.prototype, 'language', 'en-US'); + define(Navigator.prototype, 'languages', ['en-US', 'en']); + define(navigator, 'language', 'en-US'); + define(navigator, 'languages', ['en-US', 'en']); + })(); + """ private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) { entry.isBusy = false @@ -431,6 +447,12 @@ final class OpenAIDashboardWebViewCache { private func makeWebView(websiteDataStore: WKWebsiteDataStore) -> (WKWebView, OffscreenWebViewHost) { let config = WKWebViewConfiguration() config.websiteDataStore = websiteDataStore + let userContentController = WKUserContentController() + userContentController.addUserScript(WKUserScript( + source: self.preferredLanguageScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false)) + config.userContentController = userContentController if #available(macOS 14.0, *) { config.preferences.inactiveSchedulingPolicy = .suspend } @@ -471,7 +493,7 @@ final class OpenAIDashboardWebViewCache { webView.navigationDelegate = delegate webView.codexNavigationDelegate = delegate delegate.armTimeout(seconds: timeout) - _ = webView.load(URLRequest(url: usageURL)) + _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) } } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index cb8ccb90de..36f3fe9026 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -111,6 +111,68 @@ struct CodexManagedOpenAIWebRefreshTests { #expect(store.lastOpenAIDashboardError == ManagedDashboardTestError.networkTimeout.localizedDescription) } + @Test + func `navigation timeout imports cookies and retries dashboard refresh`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-timeout-import-retry") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingManagedOpenAIDashboardLoader() + let importTracker = OpenAIDashboardImportCallTracker() + store._test_openAIDashboardLoaderOverride = { _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + _ = await importTracker.recordCall() + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() + let refreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + } + await blocker.waitUntilStarted(count: 1) + + await blocker.resumeNext(with: .failure(URLError(.timedOut))) + await importTracker.waitUntilCalls(count: 1) + await blocker.waitUntilStarted(count: 2) + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 90, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await refreshTask.value + + #expect(await blocker.startedCount() == 2) + #expect(store.openAIDashboard?.creditsRemaining == 25) + #expect(store.lastOpenAIDashboardError == nil) + } + @Test func `reset open A I web state blocks stale in flight dashboard completion`() async { let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-reset-invalidates-task") @@ -224,6 +286,8 @@ struct CodexManagedOpenAIWebRefreshTests { @Test func `post import retry timeout exceeds normal retry timeout`() { + #expect(UsageStore.openAIWebDashboardFetchTimeout(didImportCookies: false) == 25) + #expect(UsageStore.openAIWebDashboardFetchTimeout(didImportCookies: true) == 25) #expect(UsageStore.openAIWebRetryDashboardFetchTimeout(afterCookieImport: false) == 8) #expect(UsageStore.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true) == 25) } diff --git a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift index be6f954e29..d265dedf46 100644 --- a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift +++ b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift @@ -74,6 +74,28 @@ struct CodexUserFacingErrorTests { "OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again.") } + @Test + func `open A I web timeout becomes retry guidance`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-openai-web-timeout") + store.lastOpenAIDashboardError = "The operation couldn’t be completed. (NSURLErrorDomain error -1001.)" + + #expect( + store.userFacingLastOpenAIDashboardError == + "OpenAI web refresh timed out. Refresh OpenAI cookies and try again.") + } + + @Test + func `open A I web network error becomes connection guidance`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-openai-web-network") + store.lastOpenAIDashboardError = "The operation couldn’t be completed. (NSURLErrorDomain error -1004.)" + let expected = [ + "OpenAI web refresh hit a network error.", + "Check your connection, then refresh OpenAI cookies and try again.", + ].joined(separator: " ") + + #expect(store.userFacingLastOpenAIDashboardError == expected) + } + @Test func `non codex providers keep raw errors`() { let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-non-codex") diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index b234a2c325..d778d78072 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -216,4 +216,81 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex")) #expect(!OpenAIDashboardFetcher.isUsageRoute(nil)) } + + @Test + func `dashboard requests prefer English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")) + let request = OpenAIDashboardFetcher.usageURLRequest(url: url) + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api request carries cookies and English localization`() { + let request = OpenAIDashboardFetcher.dashboardUsageAPIRequest(cookieHeader: "a=b") + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/usage") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `identity api request carries cookies and English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/backend-api/me")) + let request = OpenAIDashboardFetcher.dashboardIdentityAPIRequest(url: url, cookieHeader: "a=b") + + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/me") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api data maps language independent rate limits and credits`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { + "used_percent": 12, + "reset_at": 1700003600, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 34, + "reset_at": 1700604800, + "limit_window_seconds": 604800 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": 42.5 + } + } + """ + let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + let data = OpenAIDashboardFetcher.dashboardAPIData(from: response) + + #expect(data.primaryLimit?.usedPercent == 12) + #expect(data.primaryLimit?.windowMinutes == 300) + #expect(data.secondaryLimit?.usedPercent == 34) + #expect(data.secondaryLimit?.windowMinutes == 10080) + #expect(data.creditsRemaining == 42.5) + #expect(data.accountPlan == "pro") + #expect(data.hasUsageData) + } + + @Test + func `find first email searches nested api payloads`() { + let json = """ + { + "accounts": [ + { "profile": { "name": "Test" } }, + { "profile": { "email": "nested@example.com" } } + ] + } + """ + + #expect(OpenAIDashboardFetcher.findFirstEmail(inJSONData: Data(json.utf8)) == "nested@example.com") + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift new file mode 100644 index 0000000000..b6547e14fe --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +import Testing + +struct OpenAIDashboardModelsTests { + @Test + func `removes skill usage services from usage breakdown`() { + let breakdown = [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + OpenAIDashboardServiceUsage(service: "Skillusage:imagegen", creditsUsed: 7), + OpenAIDashboardServiceUsage(service: " skillusage:github:github ", creditsUsed: 2), + ], + totalCreditsUsed: 19), + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [ + OpenAIDashboardServiceUsage(service: "Skillusage:deep Research", creditsUsed: 3), + ], + totalCreditsUsed: 3), + ] + + let filtered = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) + + #expect(filtered == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + ], + totalCreditsUsed: 10), + ]) + } + + @Test + func `snapshot initializer sanitizes usage breakdown`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + OpenAIDashboardServiceUsage(service: "Skillusage:pdf Renderer", creditsUsed: 6), + ], + totalCreditsUsed: 10), + ], + creditsPurchaseURL: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + ], + totalCreditsUsed: 4), + ]) + } + + @Test + func `snapshot decoder drops empty zero usage buckets`() throws { + let json = """ + { + "signedInEmail": "codex@example.com", + "codeReviewRemainingPercent": null, + "creditEvents": [], + "dailyBreakdown": [], + "usageBreakdown": [ + { "day": "2026-04-30", "services": [], "totalCreditsUsed": 0 }, + { "day": "2026-04-29", "services": [], "totalCreditsUsed": 4 } + ], + "creditsPurchaseURL": null, + "updatedAt": "2026-04-30T19:27:07Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [], + totalCreditsUsed: 4), + ]) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift index 3fbe446757..03c73d2f3c 100644 --- a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift @@ -84,6 +84,17 @@ struct OpenAIDashboardParserTests { #expect(limits.secondary?.windowMinutes == 10080) } + @Test + func `parses spaced five hour limit label`() { + let body = """ + Limite 5 h + 72 % restant + """ + let limits = OpenAIDashboardParser.parseRateLimits(bodyText: body) + #expect(abs((limits.primary?.usedPercent ?? 0) - 28) < 0.001) + #expect(limits.primary?.windowMinutes == 300) + } + @Test func `parses plan from client bootstrap`() { let html = """ @@ -126,6 +137,26 @@ struct OpenAIDashboardParserTests { #expect(abs((events.last?.creditsUsed ?? 0) - 506.235) < 0.0001) } + @Test + func `parses credit event amount with localized credit label`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "397,205 crédits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(abs((events.first?.creditsUsed ?? 0) - 397.205) < 0.0001) + } + + @Test + func `parses credit event amount with english comma thousands`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "1,234 credits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(events.first?.creditsUsed == 1234) + } + @Test func `builds daily breakdown from events`() throws { let calendar = Calendar(identifier: .gregorian) From 737c6ca2bbfb7f971fcbd59b3cbbb167a4ab6143 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 2 May 2026 01:34:14 +0530 Subject: [PATCH 0265/1239] Improve Codex dashboard refresh resilience --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33cc139356..41a03c6b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes - Cursor: show Enterprise/Team usage from personal caps and shared pools instead of reporting 100% remaining (#813). Thanks @fcamus00! - Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! +- Codex: make OpenAI dashboard refreshes handle non-English pages, lazy-loaded credits history, timeout retries, and unrelated Skillusage rows (#825). Thanks @xiaoqianWX! ## 0.23 — 2026-04-26 From ec2005bd04d4cc2ba0432df85a9a277f1a6a8986 Mon Sep 17 00:00:00 2001 From: Alasdair McCall Date: Fri, 1 May 2026 22:53:07 +0100 Subject: [PATCH 0266/1239] Add Copilot multi-account support (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Copilot to token account support catalog Register .copilot in TokenAccountSupportCatalog with environment-based injection (COPILOT_API_TOKEN), matching the existing env key used by ProviderConfigEnvironment and ProviderTokenResolver. Uses requiresManualCookieSource: false since Copilot authenticates via OAuth, not cookies. Co-Authored-By: Claude Opus 4.6 * feat: add GitHub username fetch to CopilotUsageFetcher Add fetchGitHubUsername(token:) static method that calls GET /user with the OAuth token to retrieve the GitHub login. Used for auto-labeling token accounts during the multi-account add flow and migration. Co-Authored-By: Claude Opus 4.6 * feat: store Copilot OAuth tokens in multi-account system Replace single-token storage (settings.copilotAPIToken) with multi-account storage via addTokenAccount(). After OAuth, fetches GitHub username via /user and plan name via copilot_internal/user to build a "username (Plan)" label. Detects duplicate accounts by username prefix and refreshes the token instead of creating duplicates. Co-Authored-By: Claude Opus 4.6 * feat: update Copilot settings UI for multi-account Replace single-token secure field and login buttons with an "Add Account" button that triggers OAuth device flow. The generic token accounts section (account list, picker, remove, paste fallback) appears automatically now that Copilot is in the TokenAccountSupportCatalog. Migration from config apiKey is triggered via observeSettings(). Co-Authored-By: Claude Opus 4.6 * feat: auto-migrate Copilot config token to multi-account store When copilotAPIToken exists in config but no token accounts exist, migrate the token to a ProviderTokenAccount with a fallback label and clear the config key. An async Task enriches the label with the GitHub username via /user. Migration is idempotent — guarded by checking that token accounts are empty. Co-Authored-By: Claude Opus 4.6 * test: add Copilot multi-account catalog, migration, and env precedence tests Cover the token account catalog entry (injection type, env override), config-to-account migration (happy path, idempotent, no-op cases), and environment precedence (token account overrides config API key). Uses Swift Testing framework with InMemory stores matching existing patterns. Co-Authored-By: Claude Opus 4.6 * docs: add spec, review, and implementation plan for Copilot multi-account Co-Authored-By: Claude Opus 4.6 * chore: update plan status to Shipped Co-Authored-By: Claude Opus 4.6 * Delete .plans/2026-04-01-copilot-multi-account.md * Delete specs/2026-04-01-copilot-multi-account.md * Delete reviews/2026-04-01-copilot-multi-account.md * fix: stack multi-account usage cards and refresh data on account switch Replace the tab-style account switcher with stacked usage cards so each account's quota is visible at once. Fix a race where switching accounts rebuilt the menu before the async refresh completed, showing stale data. Co-Authored-By: Claude Sonnet 4.6 * fix: removed migration code * fix: token account update preserves identity and selection * Refine Copilot multi-account UI * Preserve active token account on removal * Fix Copilot multi-account merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot account review feedback * Fix Copilot token account fetch fallback * Use stable GitHub identity for Copilot re-auth dedupe Address Codex review feedback on PR #637: - Add ProviderTokenAccount.externalIdentifier (optional, backwards- compatible Codable). Persists the GitHub login alongside Copilot token accounts. - CopilotLoginFlow now matches existing accounts by externalIdentifier first, only falling back to label-prefix matching for legacy accounts that pre-date the field. Update writes the identifier back so future re-auths use the stable identity path. This prevents duplicate accounts when usernames previously stored as 'Account N' or hand- edited labels. - Preserve CancellationError as a non-empty per-account error marker via tokenAccountSnapshotErrorMessage so the menu does not silently fall back to the live selected-account snapshot when an individual account refresh is cancelled. Global error path keeps suppressing cancellations as before. - Tests: 7 new tests covering identifier persistence, in-place update preservation, legacy backfill, decoding compatibility, and snapshot vs global error message behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix per-account card duplication on refresh cancellation When the user switches menu tabs mid-flight, the in-flight per-account refresh task is cancelled. Two issues then surfaced: 1. menuCardModel fell back to the provider-level live snapshot whenever snapshotOverride was nil — even on override-context cards. That caused every cancelled per-account card to render with the *selected* account's data, producing N identical 'cancelled' cards. 2. refreshTokenAccounts overwrote accountSnapshots wholesale, so a single cancellation wiped the previous good state for every account. Fixes: - menuCardModel: when surface == .overrideCard, never fall back to the live snapshot. Override cards belong to a specific account context; borrowing live data leaks the wrong identity into the card. - refreshTokenAccounts: capture the prior accountSnapshots and pass each account's prior snapshot into resolveAccountOutcome. On CancellationError with a valid prior snapshot, preserve the prior state instead of replacing it with an error chip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Hide cancelled per-account rows when no prior data exists The previous fix preserved prior per-account snapshots when a refresh was cancelled, but only helped when there was prior data to preserve. On a fresh refresh that gets cancelled (e.g. the user closed/reopened the menu quickly), every row would still render as a 'Refresh cancelled' chip with no usable data — three identical 'Copilot / cancelled' cards. Cancellation is not a user-facing error. Treat it as 'no result yet': - resolveAccountOutcome now returns a nil snapshot for CancellationError with no usable prior state. The row is dropped instead of becoming a placeholder. - refreshTokenAccounts skips overwriting accountSnapshots wholesale when every fetch was cancelled and produced no usable data, leaving any prior state intact. - When all accounts are dropped, the existing menu fallback already shows a single live card for the selected account instead of a row per account — so the menu stays usable while a real refresh re-runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Copilot legacy account reauth dedupe * Use stable GitHub IDs for Copilot accounts * Clear Copilot legacy token fallback --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Verification Agent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ratul Sarna --- Sources/CodexBar/MenuContent.swift | 2 + Sources/CodexBar/MenuDescriptor.swift | 3 +- .../PreferencesProviderSettingsRows.swift | 114 +++-- .../PreferencesProvidersPane+Testing.swift | 2 + .../CodexBar/PreferencesProvidersPane.swift | 7 + .../Providers/Copilot/CopilotLoginFlow.swift | 127 +++++- .../CopilotProviderImplementation.swift | 37 +- .../Copilot/CopilotSettingsStore.swift | 11 +- .../Shared/ProviderSettingsDescriptors.swift | 2 + .../SettingsStore+TokenAccounts.swift | 81 +++- .../CodexBar/StatusItemController+Menu.swift | 27 +- ...atusItemController+MenuActionMapping.swift | 1 + .../CodexBar/UsageStore+TokenAccounts.swift | 92 +++- .../Copilot/CopilotProviderDescriptor.swift | 11 +- .../Copilot/CopilotUsageFetcher.swift | 36 ++ .../Providers/ProviderSettingsSnapshot.swift | 6 +- .../TokenAccountSupportCatalog+Data.swift | 7 + Sources/CodexBarCore/TokenAccounts.swift | 13 +- .../CopilotMultiAccountTests.swift | 405 ++++++++++++++++++ .../SettingsStoreCoverageTests.swift | 60 +++ .../UsageStoreCoverageTests.swift | 9 + 21 files changed, 965 insertions(+), 88 deletions(-) create mode 100644 Tests/CodexBarTests/CopilotMultiAccountTests.swift diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 1f27ef5c1f..3c40cbed22 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -112,6 +112,8 @@ struct MenuContent: View { self.actions.addCodexAccount() case .requestCodexSystemPromotion: return + case let .addProviderAccount(provider): + self.actions.switchAccount(provider) case let .switchAccount(provider): self.actions.switchAccount(provider) case let .openTerminal(command): diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index cc757055ba..4bd442a985 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -57,6 +57,7 @@ struct MenuDescriptor { case statusPage case addCodexAccount case requestCodexSystemPromotion(UUID) + case addProviderAccount(UsageProvider) case switchAccount(UsageProvider) case openTerminal(command: String) case loginToProvider(url: String) @@ -503,7 +504,7 @@ extension MenuDescriptor.MenuAction { case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue - case .addCodexAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue + case .addCodexAccount, .addProviderAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue case .requestCodexSystemPromotion: nil case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 514c7e3cea..a5086c4dc8 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -214,9 +214,23 @@ struct ProviderSettingsTokenAccountsRowView: View { @State private var newToken: String = "" var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(self.descriptor.title) - .font(.subheadline.weight(.semibold)) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Text(self.descriptor.title) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + if let title = self.descriptor.primaryAddActionTitle, + let action = self.descriptor.primaryAddAction + { + Button(title) { + Task { @MainActor in + await action() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(self.descriptor.subtitle) @@ -231,46 +245,64 @@ struct ProviderSettingsTokenAccountsRowView: View { .font(.footnote) .foregroundStyle(.secondary) } else { - let selectedIndex = min(self.descriptor.activeIndex(), max(0, accounts.count - 1)) - Picker("", selection: Binding( - get: { selectedIndex }, - set: { index in self.descriptor.setActiveIndex(index) })) - { - ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in - Text(account.displayName).tag(index) - } - } - .labelsHidden() - .pickerStyle(.menu) - .controlSize(.small) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in + HStack(alignment: .center, spacing: 10) { + Button { + self.descriptor.setActiveIndex(index) + } label: { + HStack(alignment: .center, spacing: 8) { + Image(systemName: self.isActive(index: index, accountCount: accounts.count) ? + "checkmark.circle.fill" : "circle") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(self.isActive(index: index, accountCount: accounts.count) ? + Color.accentColor : Color.secondary) + Text(account.displayName) + .font( + .footnote.weight( + self.isActive(index: index, accountCount: accounts.count) ? + .semibold : .regular)) + .foregroundStyle(.primary) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - Button("Remove selected account") { - let account = accounts[selectedIndex] - self.descriptor.removeAccount(account.id) + Button("Remove") { + self.descriptor.removeAccount(account.id) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + if index < accounts.count - 1 { + Divider() + } + } } - .buttonStyle(.bordered) - .controlSize(.small) } - HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) - .textFieldStyle(.roundedBorder) - .font(.footnote) - SecureField(self.descriptor.placeholder, text: self.$newToken) - .textFieldStyle(.roundedBorder) - .font(.footnote) - Button("Add") { - let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) - let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) - guard !label.isEmpty, !token.isEmpty else { return } - self.descriptor.addAccount(label, token) - self.newLabel = "" - self.newToken = "" + if self.descriptor.primaryAddAction == nil { + HStack(spacing: 8) { + TextField("Label", text: self.$newLabel) + .textFieldStyle(.roundedBorder) + .font(.footnote) + SecureField(self.descriptor.placeholder, text: self.$newToken) + .textFieldStyle(.roundedBorder) + .font(.footnote) + Button("Add") { + let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) + let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !label.isEmpty, !token.isEmpty else { return } + self.descriptor.addAccount(label, token) + self.newLabel = "" + self.newToken = "" + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } HStack(spacing: 10) { @@ -287,6 +319,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } } } + + private func isActive(index: Int, accountCount: Int) -> Bool { + guard accountCount > 0 else { return false } + let selectedIndex = min(self.descriptor.activeIndex(), max(0, accountCount - 1)) + return selectedIndex == index + } } extension View { diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index 1fcbcffa9c..11ab98181b 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -248,6 +248,8 @@ enum ProvidersPaneTestHarness { setActiveIndex: { _ in }, addAccount: { _, _ in }, removeAccount: { _ in }, + primaryAddActionTitle: nil, + primaryAddAction: nil, openConfigFile: {}, reloadFromDisk: {}) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 8cf0ff608d..9d916465fd 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -390,6 +390,13 @@ struct ProvidersPane: View { } } }, + primaryAddActionTitle: provider == .copilot ? "Add Account" : nil, + primaryAddAction: provider == .copilot ? { + await CopilotLoginFlow.run(settings: self.settings) + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshProvider(provider, allowDisabled: true) + } + } : nil, openConfigFile: { self.settings.openTokenAccountsFile() }, diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 1170f2236f..b314afb750 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -80,14 +80,70 @@ struct CopilotLoginFlow { switch tokenResult { case let .success(token): - settings.copilotAPIToken = token + // Fetch username for account label. + // If accounts already exist, fail closed when identity lookup fails so re-auth cannot create + // an anonymous duplicate with stale credentials left on the original account. + let existingAccounts = settings.tokenAccounts(for: .copilot) + let label: String + let identity: CopilotUsageFetcher.GitHubUserIdentity? + do { + let resolvedIdentity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: token) + let resolvedUsername = resolvedIdentity.login + let planSuffix: String + do { + let fetcher = CopilotUsageFetcher(token: token) + let usage = try await fetcher.fetch() + let plan = usage.identity(for: .copilot)?.loginMethod ?? "" + planSuffix = plan.isEmpty ? "" : " (\(plan))" + } catch { + planSuffix = "" + } + identity = resolvedIdentity + label = "\(resolvedUsername)\(planSuffix)" + } catch { + guard existingAccounts.isEmpty else { + let err = NSAlert() + err.messageText = "Could Not Identify GitHub Account" + err.informativeText = "GitHub login succeeded, but CodexBar could not verify which " + + "account it belongs to. Please try again." + err.runModal() + return + } + identity = nil + label = "Account 1" + } + + // Match existing account by stable GitHub user ID. For legacy accounts that pre-date stable + // identifiers, also accept login-based externalIdentifier values and resolve stored token identity + // before falling back to labels. + let matchedExisting = await Self.matchExistingAccount( + existingAccounts: existingAccounts, + identity: identity, + label: label) + let externalIdentifier = identity.map(Self.externalIdentifier) + let wasRefresh = matchedExisting != nil + if let existing = matchedExisting { + settings.updateTokenAccount( + provider: .copilot, + accountID: existing.id, + label: label, + token: token, + externalIdentifier: .some(externalIdentifier)) + } else { + settings.addTokenAccount( + provider: .copilot, + label: label, + token: token, + externalIdentifier: externalIdentifier) + } settings.setProviderEnabled( provider: .copilot, metadata: ProviderRegistry.shared.metadata[.copilot]!, enabled: true) let success = NSAlert() - success.messageText = "Login Successful" + success.messageText = wasRefresh ? "Token Refreshed" : "Account Added" + success.informativeText = label success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } @@ -105,6 +161,73 @@ struct CopilotLoginFlow { } } + static func matchExistingAccount( + existingAccounts: [ProviderTokenAccount], + identity: CopilotUsageFetcher.GitHubUserIdentity?, + label: String, + legacyIdentityResolver: @escaping @Sendable (ProviderTokenAccount) async + -> CopilotUsageFetcher.GitHubUserIdentity? = { account in + try? await CopilotUsageFetcher.fetchGitHubIdentity(token: account.token) + }) async -> ProviderTokenAccount? + { + guard let identity, !existingAccounts.isEmpty else { return nil } + let stableIdentifier = self.externalIdentifier(for: identity) + let login = self.normalizedGitHubLogin(identity.login) + + if let byID = existingAccounts.first(where: { account in + self.normalizedExternalIdentifier(account.externalIdentifier) == stableIdentifier + }) { + return byID + } + + // Previous PR revisions stored GitHub login in externalIdentifier. Keep matching those + // accounts case-insensitively, then write back the stable ID on update. + if let byLegacyLogin = existingAccounts.first(where: { account in + self.normalizedGitHubLogin(account.externalIdentifier) == login + }) { + return byLegacyLogin + } + + let legacyAccounts = existingAccounts.filter { $0.externalIdentifier == nil } + for account in legacyAccounts { + guard let resolvedIdentity = await legacyIdentityResolver(account) else { continue } + if resolvedIdentity.id == identity.id || + self.normalizedGitHubLogin(resolvedIdentity.login) == login + { + return account + } + } + + let usernamePrefix = self.displayLabelPrefix(label) + return legacyAccounts.first { account in + self.displayLabelPrefix(account.label) == usernamePrefix + } + } + + static func externalIdentifier(for identity: CopilotUsageFetcher.GitHubUserIdentity) -> String { + "github:user:\(identity.id)" + } + + private static func normalizedExternalIdentifier(_ identifier: String?) -> String? { + let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + + private static func normalizedGitHubLogin(_ login: String?) -> String? { + let trimmed = login?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + // Stable IDs are not valid GitHub logins; do not let a numeric-looking login fallback + // match the "github:user:" identifier path accidentally. + guard !trimmed.lowercased().hasPrefix("github:user:") else { return nil } + return trimmed.lowercased() + } + + private static func displayLabelPrefix(_ label: String) -> String { + (label.components(separatedBy: " (").first ?? label) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + @MainActor private static func presentWaitingAlert( _ alert: NSAlert, diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 6d400417c7..4caf55e8af 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -20,41 +20,38 @@ struct CopilotProviderImplementation: ProviderImplementation { @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { - _ = context - return .copilot(context.settings.copilotSettingsSnapshot()) + .copilot(context.settings.copilotSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Add Account...", .addProviderAccount(.copilot)) } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( - id: "copilot-api-token", + id: "copilot-add-account", title: "GitHub Login", - subtitle: "Requires authentication via GitHub Device Flow.", - footerText: "The device code is copied to your clipboard. Paste it into the GitHub page with ⌘V.", - kind: .secure, - placeholder: "Sign in via button below", - binding: context.stringBinding(\.copilotAPIToken), + subtitle: "Add accounts via GitHub OAuth Device Flow.", + kind: .plain, + placeholder: nil, + binding: .constant(""), actions: [ ProviderSettingsActionDescriptor( - id: "copilot-login", - title: "Sign in with GitHub", + id: "copilot-add-account-action", + title: "Add Account", style: .bordered, - isVisible: { context.settings.copilotAPIToken.isEmpty }, - perform: { - await CopilotLoginFlow.run(settings: context.settings) - }), - ProviderSettingsActionDescriptor( - id: "copilot-relogin", - title: "Sign in again", - style: .link, - isVisible: { !context.settings.copilotAPIToken.isEmpty }, + isVisible: { true }, perform: { await CopilotLoginFlow.run(settings: context.settings) }), ], isVisible: nil, - onActivate: { context.settings.ensureCopilotAPITokenLoaded() }), + onActivate: nil), ] } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift index c40a51f3dc..fde70a5ce0 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift @@ -16,7 +16,14 @@ extension SettingsStore { } extension SettingsStore { - func copilotSettingsSnapshot() -> ProviderSettingsSnapshot.CopilotProviderSettings { - ProviderSettingsSnapshot.CopilotProviderSettings() + func copilotSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CopilotProviderSettings + { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: .copilot, + settings: self, + override: tokenOverride) + let token = account?.token ?? self.copilotAPIToken + return ProviderSettingsSnapshot.CopilotProviderSettings(apiToken: self.normalizedConfigValue(token)) } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 7b408d8daa..e6a587d90d 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -99,6 +99,8 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let setActiveIndex: (Int) -> Void let addAccount: (_ label: String, _ token: String) -> Void let removeAccount: (_ accountID: UUID) -> Void + let primaryAddActionTitle: String? + let primaryAddAction: (() async -> Void)? let openConfigFile: () -> Void let reloadFromDisk: () -> Void } diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 1f8a0277be..0fceb00c22 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -36,11 +36,18 @@ extension SettingsStore { ]) } - func addTokenAccount(provider: UsageProvider, label: String, token: String) { + func addTokenAccount( + provider: UsageProvider, + label: String, + token: String, + externalIdentifier: String? = nil) + { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return } let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedToken.isEmpty else { return } let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedIdentifier = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalisedIdentifier = (trimmedIdentifier?.isEmpty ?? true) ? nil : trimmedIdentifier let existing = self.tokenAccountsData(for: provider) let accounts = existing?.accounts ?? [] let fallbackLabel = trimmedLabel.isEmpty ? "Account \(accounts.count + 1)" : trimmedLabel @@ -49,13 +56,17 @@ extension SettingsStore { label: fallbackLabel, token: trimmedToken, addedAt: Date().timeIntervalSince1970, - lastUsed: nil) + lastUsed: nil, + externalIdentifier: normalisedIdentifier) let updated = ProviderTokenAccountData( version: existing?.version ?? 1, accounts: accounts + [account], activeIndex: accounts.count) self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated + if provider == .copilot { + entry.apiKey = nil + } } self.applyTokenAccountCookieSourceIfNeeded(provider: provider) CodexBarLog.logger(LogCategories.tokenAccounts).info( @@ -66,18 +77,80 @@ extension SettingsStore { ]) } + func updateTokenAccount( + provider: UsageProvider, + accountID: UUID, + label: String? = nil, + token: String? = nil, + externalIdentifier: String?? = nil) + { + guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } + + let trimmedLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedToken, trimmedToken.isEmpty { return } + + let existing = data.accounts[index] + let resolvedIdentifier: String? + if let externalIdentifier { + let trimmed = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + resolvedIdentifier = (trimmed?.isEmpty ?? true) ? nil : trimmed + } else { + resolvedIdentifier = existing.externalIdentifier + } + let updatedAccount = ProviderTokenAccount( + id: existing.id, + label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label, + token: trimmedToken ?? existing.token, + addedAt: existing.addedAt, + lastUsed: existing.lastUsed, + externalIdentifier: resolvedIdentifier) + + var accounts = data.accounts + accounts[index] = updatedAccount + let updated = ProviderTokenAccountData( + version: data.version, + accounts: accounts, + activeIndex: data.clampedActiveIndex()) + self.updateProviderConfig(provider: provider) { entry in + entry.tokenAccounts = updated + if provider == .copilot { + entry.apiKey = nil + } + } + self.applyTokenAccountCookieSourceIfNeeded(provider: provider) + CodexBarLog.logger(LogCategories.tokenAccounts).info( + "Token account updated", + metadata: [ + "provider": provider.rawValue, + "count": "\(updated.accounts.count)", + ]) + } + func removeTokenAccount(provider: UsageProvider, accountID: UUID) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + let activeAccountID = data.accounts[data.clampedActiveIndex()].id + guard let removedIndex = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } let filtered = data.accounts.filter { $0.id != accountID } self.updateProviderConfig(provider: provider) { entry in if filtered.isEmpty { entry.tokenAccounts = nil } else { - let clamped = min(max(data.activeIndex, 0), filtered.count - 1) + let nextActiveIndex = if activeAccountID != accountID, + let preservedIndex = filtered.firstIndex(where: { $0.id == activeAccountID }) + { + preservedIndex + } else { + min(removedIndex, filtered.count - 1) + } entry.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: filtered, - activeIndex: clamped) + activeIndex: nextActiveIndex) + } + if provider == .copilot { + entry.apiKey = nil } } CodexBarLog.logger(LogCategories.tokenAccounts).info( diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 4537ec4515..9d03f140bf 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -728,14 +728,19 @@ extension StatusItemController { onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) - Task { @MainActor in + // Immediately rebuild to show the new selection, then refresh data + // and rebuild again once fresh data arrives. + self.populateMenu(menu, provider: display.provider) + self.markMenuFresh(menu) + self.applyIcon(phase: nil) + Task { @MainActor [weak self, weak menu] in + guard let self else { return } await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh() } + guard let menu else { return } + self.rebuildOpenMenuIfStillVisible(menu, provider: display.provider) } - self.populateMenu(menu, provider: display.provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) }) let item = NSMenuItem() item.view = view @@ -817,7 +822,9 @@ extension StatusItemController { let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 - let showAll = self.settings.showAllTokenAccountsInMenu + let canShowAllCopilotAccounts = provider == .copilot && + accounts.count <= UsageStore.tokenAccountMenuSnapshotLimit + let showAll = canShowAllCopilotAccounts || self.settings.showAllTokenAccountsInMenu let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] return TokenAccountMenuDisplay( provider: provider, @@ -1356,12 +1363,20 @@ extension StatusItemController { let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) - let snapshot = snapshotOverride ?? self.store.snapshot(for: target) let surface: CodexConsumerProjection.Surface = if snapshotOverride != nil || errorOverride != nil { .overrideCard } else { .liveCard } + // Override cards belong to a specific account/context (e.g. a per-account + // refresh result). Never fall back to the provider-level live snapshot here — + // that data belongs to a *different* account and would render misleading + // duplicate cards when an account refresh failed or was cancelled. + let snapshot: UsageSnapshot? = if surface == .overrideCard { + snapshotOverride + } else { + snapshotOverride ?? self.store.snapshot(for: target) + } let now = Date() let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: target, diff --git a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift index 44d2c35c17..eb68b018ee 100644 --- a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift +++ b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift @@ -9,6 +9,7 @@ extension StatusItemController { case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) case .addCodexAccount: (#selector(self.addManagedCodexAccountFromMenu(_:)), nil) + case let .addProviderAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) case let .requestCodexSystemPromotion(managedAccountID): (#selector(self.requestCodexSystemPromotionFromMenu(_:)), managedAccountID.uuidString) case let .switchAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 7a00c12360..44f1ed5bbf 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -18,6 +18,8 @@ struct TokenAccountUsageSnapshot: Identifiable { } extension UsageStore { + static let tokenAccountMenuSnapshotLimit = 6 + func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } return self.settings.tokenAccounts(for: provider) @@ -25,6 +27,9 @@ extension UsageStore { func shouldFetchAllTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) -> Bool { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return false } + if provider == .copilot { + return accounts.count > 1 + } return self.settings.showAllTokenAccountsInMenu && accounts.count > 1 } @@ -32,16 +37,35 @@ extension UsageStore { let selectedAccount = self.settings.selectedTokenAccount(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) let effectiveSelected = selectedAccount ?? limitedAccounts.first + + // Capture the prior per-account snapshot state so we can preserve last-good + // data when an in-flight refresh is cancelled (e.g. menu tab switches). Without + // this, cancellation produces empty/error snapshots and the menu briefly shows + // misleading cards for accounts that previously had valid data. + let priorSnapshots = await MainActor.run { self.accountSnapshots[provider] ?? [] } + let priorByAccountID = Dictionary(uniqueKeysWithValues: priorSnapshots.map { ($0.account.id, $0) }) + var snapshots: [TokenAccountUsageSnapshot] = [] var historySamples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)] = [] var selectedOutcome: ProviderFetchOutcome? var selectedSnapshot: UsageSnapshot? + var sawAnyNonCancellationOutcome = false for account in limitedAccounts { let override = TokenAccountOverride(provider: provider, account: account) let outcome = await self.fetchOutcome(provider: provider, override: override) - let resolved = self.resolveAccountOutcome(outcome, provider: provider, account: account) - snapshots.append(resolved.snapshot) + let isCancellation = Self.outcomeIsCancellation(outcome) + if !isCancellation { + sawAnyNonCancellationOutcome = true + } + let resolved = self.resolveAccountOutcome( + outcome, + provider: provider, + account: account, + priorSnapshot: priorByAccountID[account.id]) + if let snapshot = resolved.snapshot { + snapshots.append(snapshot) + } if let usage = resolved.usage { historySamples.append((account: account, snapshot: usage)) } @@ -51,8 +75,15 @@ extension UsageStore { } } - await MainActor.run { - self.accountSnapshots[provider] = snapshots + // If every fetch was cancelled (e.g. the user closed/reopened the menu mid-flight) + // and we have no usable snapshots, leave the prior per-account state alone. + // Wiping it would produce a menu of useless "cancelled" placeholders. + let shouldPreservePriorState = !sawAnyNonCancellationOutcome && + snapshots.allSatisfy { $0.snapshot == nil } + if !shouldPreservePriorState { + await MainActor.run { + self.accountSnapshots[provider] = snapshots + } } if let selectedOutcome { @@ -69,11 +100,18 @@ extension UsageStore { selectedAccount: effectiveSelected) } + private static func outcomeIsCancellation(_ outcome: ProviderFetchOutcome) -> Bool { + if case let .failure(error) = outcome.result, error is CancellationError { + return true + } + return false + } + func limitedTokenAccounts( _ accounts: [ProviderTokenAccount], selected: ProviderTokenAccount?) -> [ProviderTokenAccount] { - let limit = 6 + let limit = Self.tokenAccountMenuSnapshotLimit if accounts.count <= limit { return accounts } var limited = Array(accounts.prefix(limit)) if let selected, !limited.contains(where: { $0.id == selected.id }) { @@ -126,10 +164,28 @@ extension UsageStore { } private struct ResolvedAccountOutcome { - let snapshot: TokenAccountUsageSnapshot + let snapshot: TokenAccountUsageSnapshot? let usage: UsageSnapshot? } + func tokenAccountErrorMessage(_ error: any Error) -> String? { + guard !(error is CancellationError) else { return nil } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? nil : message + } + + /// Per-account snapshot error text. Unlike ``tokenAccountErrorMessage``, + /// cancellations are preserved as a non-empty marker so the menu does not + /// silently fall back to the live (selected-account) snapshot when an + /// individual account refresh is cancelled. + func tokenAccountSnapshotErrorMessage(_ error: any Error) -> String { + if error is CancellationError { + return "Refresh cancelled" + } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? "Refresh failed" : message + } + func recordFetchedTokenAccountPlanUtilizationHistory( provider: UsageProvider, samples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)], @@ -148,7 +204,8 @@ extension UsageStore { private func resolveAccountOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, - account: ProviderTokenAccount) -> ResolvedAccountOutcome + account: ProviderTokenAccount, + priorSnapshot: TokenAccountUsageSnapshot? = nil) -> ResolvedAccountOutcome { switch outcome.result { case let .success(result): @@ -161,10 +218,23 @@ extension UsageStore { sourceLabel: result.sourceLabel) return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled) case let .failure(error): + // Preserve the last-good snapshot when the refresh was cancelled (e.g. the + // user switched menu tabs mid-flight). Without this the per-account list + // would briefly render error chips for accounts that already had data. + if error is CancellationError { + if let priorSnapshot, priorSnapshot.snapshot != nil { + return ResolvedAccountOutcome(snapshot: priorSnapshot, usage: priorSnapshot.snapshot) + } + // No usable prior data: skip this row entirely. The caller will + // either preserve the existing per-account state or fall back to + // the single live card. Rendering a "cancelled" placeholder here + // produces visually duplicate cards with no useful data. + return ResolvedAccountOutcome(snapshot: nil, usage: nil) + } let snapshot = TokenAccountUsageSnapshot( account: account, snapshot: nil, - error: error.localizedDescription, + error: self.tokenAccountSnapshotErrorMessage(error), sourceLabel: nil) return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) } @@ -200,11 +270,15 @@ extension UsageStore { account: account) case let .failure(error): await MainActor.run { + guard let message = self.tokenAccountErrorMessage(error) else { + self.errors[provider] = nil + return + } let hadPriorData = self.snapshots[provider] != nil || fallbackSnapshot != nil let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { - self.errors[provider] = error.localizedDescription + self.errors[provider] = message self.snapshots.removeValue(forKey: provider) } else { self.errors[provider] = nil diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 9e2b063ccd..fb039943c1 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -44,11 +44,11 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .apiToken func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil + Self.resolveToken(context: context) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let token = Self.resolveToken(environment: context.env), !token.isEmpty else { + guard let token = Self.resolveToken(context: context), !token.isEmpty else { throw URLError(.userAuthenticationRequired) } let fetcher = CopilotUsageFetcher(token: token) @@ -62,7 +62,10 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { false } - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.copilotToken(environment: environment) + private static func resolveToken(context: ProviderFetchContext) -> String? { + ProviderTokenResolver.copilotToken(environment: context.env) + ?? ProviderTokenResolver.copilotResolution(environment: [ + "COPILOT_API_TOKEN": context.settings?.copilot?.apiToken ?? "", + ])?.token } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index ddf41a10a5..e8f88c0d90 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -4,6 +4,16 @@ import FoundationNetworking #endif public struct CopilotUsageFetcher: Sendable { + public struct GitHubUserIdentity: Decodable, Equatable, Sendable { + public let id: Int64 + public let login: String + + public init(id: Int64, login: String) { + self.id = id + self.login = login + } + } + private let token: String public init(token: String) { @@ -66,6 +76,32 @@ public struct CopilotUsageFetcher: Sendable { identity: identity) } + public static func fetchGitHubUsername(token: String) async throws -> String { + try await self.fetchGitHubIdentity(token: token).login + } + + public static func fetchGitHubIdentity(token: String) async throws -> GitHubUserIdentity { + guard let url = URL(string: "https://api.github.com/user") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw URLError(.userAuthenticationRequired) + } + guard httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + return try JSONDecoder().decode(GitHubUserIdentity.self, from: data) + } + private func addCommonHeaders(to request: inout URLRequest) { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version") diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 2692c4920b..819a6d87e7 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -165,7 +165,11 @@ public struct ProviderSettingsSnapshot: Sendable { } public struct CopilotProviderSettings: Sendable { - public init() {} + public let apiToken: String? + + public init(apiToken: String? = nil) { + self.apiToken = apiToken + } } public struct KiloProviderSettings: Sendable { diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index a4ce33fb9e..94e1d3f7e9 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -86,5 +86,12 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .copilot: TokenAccountSupport( + title: "GitHub accounts", + subtitle: "Sign in with multiple GitHub accounts via OAuth.", + placeholder: "Paste GitHub token…", + injection: .environment(key: "COPILOT_API_TOKEN"), + requiresManualCookieSource: false, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 519386aec1..cb8e3f35bc 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -6,13 +6,24 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { public let token: String public let addedAt: TimeInterval public let lastUsed: TimeInterval? + /// Stable provider-specific identity (e.g. GitHub `login`) used for + /// re-auth deduplication. Optional so legacy accounts keep working. + public let externalIdentifier: String? - public init(id: UUID, label: String, token: String, addedAt: TimeInterval, lastUsed: TimeInterval?) { + public init( + id: UUID, + label: String, + token: String, + addedAt: TimeInterval, + lastUsed: TimeInterval?, + externalIdentifier: String? = nil) + { self.id = id self.label = label self.token = token self.addedAt = addedAt self.lastUsed = lastUsed + self.externalIdentifier = externalIdentifier } public var displayName: String { diff --git a/Tests/CodexBarTests/CopilotMultiAccountTests.swift b/Tests/CodexBarTests/CopilotMultiAccountTests.swift new file mode 100644 index 0000000000..e472a82191 --- /dev/null +++ b/Tests/CodexBarTests/CopilotMultiAccountTests.swift @@ -0,0 +1,405 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +// MARK: - Catalog + +@Test +func `copilot catalog entry exists`() { + let support = TokenAccountSupportCatalog.support(for: .copilot) + #expect(support != nil) + #expect(support?.requiresManualCookieSource == false) + #expect(support?.cookieName == nil) +} + +@Test +func `copilot catalog entry uses environment injection`() { + let support = TokenAccountSupportCatalog.support(for: .copilot) + guard let support else { + Issue.record("Copilot catalog entry missing") + return + } + if case let .environment(key) = support.injection { + #expect(key == "COPILOT_API_TOKEN") + } else { + Issue.record("Expected .environment injection, got cookieHeader") + } +} + +@Test +func `copilot env override uses correct key`() { + let override = TokenAccountSupportCatalog.envOverride(for: .copilot, token: "gh_abc") + #expect(override == ["COPILOT_API_TOKEN": "gh_abc"]) +} + +// MARK: - Username Fetch (parsing only) + +@Test +func `GitHub user response parses stable id and login`() throws { + let json = #"{"login": "testuser", "id": 123, "name": "Test User"}"# + let user = try JSONDecoder().decode(CopilotUsageFetcher.GitHubUserIdentity.self, from: Data(json.utf8)) + #expect(user.id == 123) + #expect(user.login == "testuser") +} + +@Test +func `GitHub user response requires stable id`() throws { + let json = #"{"login": "minimaluser"}"# + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(CopilotUsageFetcher.GitHubUserIdentity.self, from: Data(json.utf8)) + } +} + +// MARK: - API Key Fallback + +@MainActor +struct CopilotAPIKeyFallbackTests { + @Test + func `ensure loader preserves config token`() { + let settings = Self.makeSettingsStore(suite: "copilot-api-key-loader") + settings.copilotAPIToken = "gh_token_123" + + settings.ensureCopilotAPITokenLoaded() + + #expect(settings.copilotAPIToken == "gh_token_123") + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + } + + @Test + func `token accounts clear legacy config token`() { + let settings = Self.makeSettingsStore(suite: "copilot-api-key-with-accounts") + settings.copilotAPIToken = "gh_token_old" + settings.addTokenAccount(provider: .copilot, label: "existing", token: "gh_token_existing") + + settings.ensureCopilotAPITokenLoaded() + + #expect(settings.tokenAccounts(for: .copilot).count == 1) + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == "gh_token_existing") + #expect(settings.tokenAccounts(for: .copilot).first?.label == "existing") + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} + +// MARK: - Environment Precedence + +@MainActor +struct CopilotEnvironmentPrecedenceTests { + @Test + func `token account overrides config API key`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-env-override") + settings.copilotAPIToken = "old_config_token" + settings.addTokenAccount(provider: .copilot, label: "new", token: "new_account_token") + + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + let override = TokenAccountOverride(provider: .copilot, account: account) + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .copilot, + settings: settings, + tokenOverride: override) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: override) + + #expect(env["COPILOT_API_TOKEN"] == "new_account_token") + #expect(snapshot.copilot?.apiToken == "new_account_token") + } + + @Test + func `selected token account is included in copilot settings snapshot`() { + let settings = Self.makeSettingsStore(suite: "copilot-settings-snapshot-account") + settings.copilotAPIToken = "old_config_token" + settings.addTokenAccount(provider: .copilot, label: "new", token: "new_account_token") + + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + + #expect(snapshot.copilot?.apiToken == "new_account_token") + } + + @Test + func `config API key used when no token accounts`() { + let settings = Self.makeSettingsStore(suite: "copilot-env-config-only") + settings.copilotAPIToken = "config_token" + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .copilot, + settings: settings, + tokenOverride: nil) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + + #expect(env["COPILOT_API_TOKEN"] == "config_token") + #expect(snapshot.copilot?.apiToken == "config_token") + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} + +// MARK: - External Identifier Dedup + +@MainActor +struct CopilotExternalIdentifierTests { + @Test + func `addTokenAccount persists external identifier`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-add") + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "gh_token_1", + externalIdentifier: "octocat") + + let account = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(account.externalIdentifier == "octocat") + } + + @Test + func `updateTokenAccount preserves identifier when not provided`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-preserve") + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "gh_token_1", + externalIdentifier: "octocat") + let original = try #require(settings.tokenAccounts(for: .copilot).first) + + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "octocat (Business)", + token: "gh_token_2") + + let updated = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(updated.id == original.id) + #expect(updated.token == "gh_token_2") + #expect(updated.externalIdentifier == "octocat") + } + + @Test + func `updateTokenAccount writes identifier back for legacy accounts`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-backfill") + // Legacy account: no externalIdentifier (pre-feature). + settings.addTokenAccount(provider: .copilot, label: "octocat (Pro)", token: "gh_legacy") + let legacy = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(legacy.externalIdentifier == nil) + + settings.updateTokenAccount( + provider: .copilot, + accountID: legacy.id, + label: "octocat (Pro)", + token: "gh_refreshed", + externalIdentifier: .some("octocat")) + + let updated = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(updated.id == legacy.id) + #expect(updated.externalIdentifier == "octocat") + } + + @Test + func `legacy Account N account matches reauth by stored token identity`() async { + let legacy = Self.makeAccount(label: "Account 1", token: "old-token", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { account in + account.token == "old-token" ? Self.identity(id: 123, login: "octocat") : nil + }) + + #expect(matched?.id == legacy.id) + } + + @Test + func `user renamed legacy account matches reauth by stored token identity`() async { + let legacy = Self.makeAccount(label: "Work GitHub", token: "old-token", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { account in + account.token == "old-token" ? Self.identity(id: 123, login: "OctoCat") : nil + }) + + #expect(matched?.id == legacy.id) + } + + @Test + func `stable external identifier match is preferred`() async { + let identified = Self.makeAccount( + label: "Personal", + token: "identified", + externalIdentifier: "github:user:123") + let legacy = Self.makeAccount(label: "octocat", token: "legacy", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy, identified], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { _ in + Issue.record("Resolver should not run when externalIdentifier matches") + return nil + }) + + #expect(matched?.id == identified.id) + } + + @Test + func `legacy login external identifier still matches and can be backfilled`() async { + let identified = Self.makeAccount(label: "Personal", token: "identified", externalIdentifier: "OctoCat") + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [identified], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { _ in + Issue.record("Resolver should not run when legacy externalIdentifier matches") + return nil + }) + + #expect(matched?.id == identified.id) + #expect(CopilotLoginFlow.externalIdentifier(for: Self.identity(id: 123, login: "octocat")) == "github:user:123") + } + + @Test + func `decoding legacy token account JSON yields nil identifier`() throws { + let json = """ + { + "id": "11111111-1111-1111-1111-111111111111", + "label": "octocat", + "token": "gh_legacy", + "addedAt": 1700000000.0 + } + """ + let account = try JSONDecoder().decode(ProviderTokenAccount.self, from: Data(json.utf8)) + #expect(account.label == "octocat") + #expect(account.externalIdentifier == nil) + #expect(account.lastUsed == nil) + } + + private nonisolated static func identity(id: Int64, login: String) -> CopilotUsageFetcher.GitHubUserIdentity { + CopilotUsageFetcher.GitHubUserIdentity(id: id, login: login) + } + + private static func makeAccount( + label: String, + token: String, + externalIdentifier: String?) -> ProviderTokenAccount + { + ProviderTokenAccount( + id: UUID(), + label: label, + token: token, + addedAt: 1_700_000_000, + lastUsed: nil, + externalIdentifier: externalIdentifier) + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} + +// MARK: - Token Account Snapshot Error Messages + +@MainActor +struct TokenAccountSnapshotErrorMessageTests { + @Test + func `cancellation produces non-empty marker for per-account snapshot`() { + let store = Self.makeUsageStore() + let message = store.tokenAccountSnapshotErrorMessage(CancellationError()) + #expect(!message.isEmpty) + #expect(message.lowercased().contains("cancel")) + } + + @Test + func `cancellation is suppressed for global error path`() { + let store = Self.makeUsageStore() + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + } + + @Test + func `non-cancellation error preserves localized message`() { + let store = Self.makeUsageStore() + struct Boom: LocalizedError { + var errorDescription: String? { + "kaboom" + } + } + #expect(store.tokenAccountSnapshotErrorMessage(Boom()) == "kaboom") + #expect(store.tokenAccountErrorMessage(Boom()) == "kaboom") + } + + private static func makeUsageStore() -> UsageStore { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "copilot-snapshot-error-\(UUID().uuidString)"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 0b18ad89a5..cae25e49c3 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -81,6 +81,66 @@ struct SettingsStoreCoverageTests { settings.reloadTokenAccounts() } + @Test + func `token account update preserves identity and selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "token-2") + settings.setActiveTokenAccountIndex(0, for: .copilot) + + let original = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "Primary (Pro)", + token: "token-1b") + + let updated = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(updated.id == original.id) + #expect(updated.label == "Primary (Pro)") + #expect(updated.token == "token-1b") + #expect(settings.tokenAccounts(for: .copilot).count == 2) + } + + @Test + func `copilot token accounts clear legacy api key fallback`() throws { + let settings = Self.makeSettingsStore() + settings.copilotAPIToken = "legacy-token" + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == "token-1") + + settings.copilotAPIToken = "legacy-token" + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.removeTokenAccount(provider: .copilot, accountID: account.id) + + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == nil) + } + + @Test + func `removing another token account preserves active selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "A", token: "token-a") + settings.addTokenAccount(provider: .copilot, label: "B", token: "token-b") + settings.addTokenAccount(provider: .copilot, label: "C", token: "token-c") + settings.setActiveTokenAccountIndex(1, for: .copilot) + + let activeBefore = try #require(settings.selectedTokenAccount(for: .copilot)) + let accountToRemove = try #require(settings.tokenAccounts(for: .copilot).first) + settings.removeTokenAccount(provider: .copilot, accountID: accountToRemove.id) + + let activeAfter = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(activeAfter.id == activeBefore.id) + #expect(activeAfter.label == "B") + #expect(settings.tokenAccounts(for: .copilot).map(\.label) == ["B", "C"]) + } + @Test func `claude snapshot uses OAuth routing for OAuth token accounts`() { let settings = Self.makeSettingsStore() diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index b5710878a5..b9d50bebab 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -389,6 +389,15 @@ struct UsageStoreCoverageTests { #expect(gate.streak == 0) } + @Test + func `token account error message ignores cancellation`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-token-account-cancel") + let store = Self.makeUsageStore(settings: settings) + + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + #expect(store.tokenAccountErrorMessage(ProviderFetchError.noAvailableStrategy(.copilot)) != nil) + } + private static func makeSettingsStore( suite: String, zaiTokenStore: any ZaiTokenStoring = NoopZaiTokenStore(), From 82bbcde911b3493a99f4c1a12df6cfccf7322519 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 2 May 2026 13:47:34 +0530 Subject: [PATCH 0267/1239] Update Copilot multi-account changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a03c6b3b..a1b9e954e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.24 — Unreleased ### Providers & Usage +- Copilot: add multi-account support with GitHub OAuth sign-in, account switching, and per-account usage cards (#637). Thanks @ajmccall! - DeepSeek: add provider support with token-account balance tracking, paid vs. granted credit breakdown, and CLI support (#811). Thanks @willytop8! - Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! - Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! From 3deab300b842bb7206e4890da043d23abd2dd311 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 02:08:19 +0100 Subject: [PATCH 0268/1239] test: isolate Claude fingerprint prompt load --- Package.resolved | 4 +- ...AuthCredentialsStoreSecurityCLITests.swift | 87 ++++++++++--------- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/Package.resolved b/Package.resolved index 2dd5f95812..e7250ccc95 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/Commander", "state" : { - "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", - "version" : "0.2.1" + "revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b", + "version" : "0.2.2" } }, { diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift index 1e49708b0c..c9a167f64d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -763,50 +763,55 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let securityData = self.makeCredentialsData( - accessToken: "security-load-with-prompt", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() - let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 321, - createdAt: 320, - persistentRefHash: "sentinel") + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-load-with-prompt", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 321, + createdAt: 320, + persistentRefHash: "sentinel") - let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental, - operation: { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - try ProviderInteractionContext.$current.withValue(.userInitiated) { - try ClaudeOAuthCredentialsStore - .withClaudeKeychainFingerprintStoreOverrideForTesting( - fingerprintStore) - { - try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: nil, - fingerprint: sentinelFingerprint) - { - try ClaudeOAuthCredentialsStore - .withSecurityCLIReadOverrideForTesting( - .data(securityData)) - { - try ClaudeOAuthCredentialsStore.load( - environment: [:], - allowKeychainPrompt: true, - respectKeychainPromptCooldown: false) - } - } + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true, + respectKeychainPromptCooldown: false) + } + } + } } - } - } - }) + } + }) - #expect(creds.accessToken == "security-load-with-prompt") - #expect(fingerprintStore.fingerprint == nil) + #expect(creds.accessToken == "security-load-with-prompt") + #expect(fingerprintStore.fingerprint == nil) + } + } } } } From 236af15a888c7190ec9649faa6ab5551dcba43ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:03:00 +0100 Subject: [PATCH 0269/1239] fix: harden Claude OAuth usage handling --- .../Providers/Claude/ClaudeLoginFlow.swift | 9 ++- .../Claude/ClaudeProviderImplementation.swift | 1 - Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/UsageStore.swift | 7 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 7 +- Tests/CodexBarTests/ClaudeUsageTests.swift | 41 ++++++++++++ .../SettingsStoreCoverageTests.swift | 15 +++++ ...sageStoreSessionQuotaTransitionTests.swift | 64 +++++++++++++++++++ docs/claude.md | 3 +- 9 files changed, 144 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index 9550abb05b..76002520ef 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -2,7 +2,7 @@ import CodexBarCore @MainActor extension StatusItemController { - func runClaudeLoginFlow() async { + func runClaudeLoginFlow() async -> Bool { let phaseHandler: @Sendable (ClaudeLoginRunner.Phase) -> Void = { [weak self] phase in Task { @MainActor in switch phase { @@ -12,14 +12,19 @@ extension StatusItemController { } } let result = await ClaudeLoginRunner.run(timeout: 120, onPhaseChange: phaseHandler) - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return false } self.loginPhase = .idle self.presentClaudeLoginResult(result) let outcome = self.describe(result.outcome) let length = result.output.count self.loginLogger.info("Claude login", metadata: ["outcome": outcome, "length": "\(length)"]) if case .success = result.outcome { + let metadata = self.store.metadata(for: .claude) + self.settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + self.settings.claudeUsageDataSource = .oauth self.postLoginNotification(for: .claude) + return true } + return false } } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index da82c17344..28e3382b45 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -210,7 +210,6 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runClaudeLoginFlow() - return true } @MainActor diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6d3e76e4f1..d26a48a517 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -379,6 +379,9 @@ extension SettingsStore { self.updateProviderConfig(provider: provider) { entry in entry.enabled = enabled } + if !enabled, self.selectedMenuProvider == provider { + self.selectedMenuProvider = nil + } } func rerunProviderDetection() { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 120c75a2e5..62ee3f2891 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -610,7 +610,7 @@ final class UsageStore { provider: UsageProvider, snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? { - if let primary = snapshot.primary { + if let primary = snapshot.primary, Self.isSessionWindow(primary) { return (primary, .primary) } if provider == .copilot, let secondary = snapshot.secondary { @@ -619,6 +619,11 @@ final class UsageStore { return nil } + private static func isSessionWindow(_ window: RateWindow) -> Bool { + guard let minutes = window.windowMinutes else { return true } + return minutes <= 6 * 60 + } + func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) { // Session quota notifications are tied to the primary session window. Copilot free plans can // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 82d9198659..d942058846 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -836,7 +836,12 @@ extension ClaudeUsageFetcher { resetDescription: resetDescription) } - guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) else { + guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) + ?? makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDayOAuthApps, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDaySonnet, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) + else { throw ClaudeUsageError.parseFailed("missing session data") } diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 6e400fd6b3..6c7ff468fe 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -882,6 +882,47 @@ struct ClaudeUsageTests { } } +struct ClaudeOAuthUsageMappingTests { + @Test + func `oauth usage falls back to weekly window when five hour is absent`() throws { + let json = """ + { + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 17, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + + #expect(snapshot.primary.usedPercent == 42) + #expect(snapshot.primary.windowMinutes == 7 * 24 * 60) + #expect(snapshot.secondary?.usedPercent == 42) + #expect(snapshot.opus?.usedPercent == 17) + } + + @Test + func `oauth usage falls back when five hour has no utilization`() throws { + let json = """ + { + "five_hour": { "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 9, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + + #expect(snapshot.primary.usedPercent == 9) + #expect(snapshot.primary.windowMinutes == 7 * 24 * 60) + } + + @Test + func `oauth usage throws when no usable windows are present`() { + let json = "{}" + + #expect(throws: ClaudeUsageError.self) { + try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + } + } +} + @Suite(.serialized) struct ClaudeAutoFetcherCharacterizationTests { private final class RequestLog: @unchecked Sendable { diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index cae25e49c3..63d90b571c 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -35,6 +35,21 @@ struct SettingsStoreCoverageTests { #expect(enabled.contains(.codex)) } + @Test + func `disabling selected provider clears menu selection`() throws { + let settings = Self.makeSettingsStore() + let metadata = ProviderRegistry.shared.metadata + + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: true) + settings.selectedMenuProvider = .claude + + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: false) + + #expect(settings.selectedMenuProvider == nil) + #expect(settings.enabledProvidersOrdered(metadataByProvider: metadata) == [.codex]) + } + @Test func `menu bar metric preferences and display modes`() { let settings = Self.makeSettingsStore() diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 168ebe3d96..b4cb0b6f87 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -77,4 +77,68 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + + @Test + func `claude weekly primary fallback does not emit session quota notifications`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-weekly"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.isEmpty) + } + + @Test + func `claude five hour primary still emits session quota notifications`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.map(\.provider) == [.claude]) + } } diff --git a/docs/claude.md b/docs/claude.md index dba35bac23..7f22717eeb 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -52,9 +52,10 @@ Usage source picker: - `anthropic-beta: oauth-2025-04-20` - Mapping: - `five_hour` → session window. - - `seven_day` → weekly window. + - `seven_day` → weekly window; also becomes the primary fallback when `five_hour` is absent or has no utilization. - `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window. - `extra_usage` → Extra usage cost (monthly spend/limit). +- Successful OAuth login enables Claude and selects OAuth as the usage source. - Plan inference: `rate_limit_tier` from credentials maps to Max/Pro/Team/Enterprise. ## Web API (cookies) From e296a1f6e8d8732735b5464f9ad8b8acb4204f5d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:03:03 +0100 Subject: [PATCH 0270/1239] fix: scope CLI and Codex usage attribution --- Sources/CodexBarCLI/CLIHelpers.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CLIProviderSelectionTests.swift | 12 +++- .../CodexBarTests/CostUsageFetcherTests.swift | 64 +++++++++++++++++++ docs/cli.md | 2 + docs/codex.md | 3 +- 6 files changed, 80 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 0a6663b943..fa87545925 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -17,7 +17,6 @@ extension CodexBarCLI { if let rawOverride, let parsed = ProviderSelection(argument: rawOverride) { return parsed } - if enabled.count >= 3 { return .all } if enabled.count == 2 { let enabledSet = Set(enabled) let primary = Set(ProviderDescriptorRegistry.all.filter(\ .metadata.isPrimaryProvider).map(\ .id)) @@ -26,6 +25,7 @@ extension CodexBarCLI { } return .custom(enabled) } + if enabled.count >= 3 { return .custom(enabled) } if let first = enabled.first { return ProviderSelection(provider: first) } return .single(.codex) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 0450d30929..4936cb43d9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -760,7 +760,7 @@ enum CostUsageScanner { ?? info?["model_name"] as? String ?? payload["model"] as? String ?? obj["model"] as? String - let model = modelFromInfo ?? currentModel ?? "gpt-5" + let model = currentModel ?? modelFromInfo ?? "gpt-5" func toInt(_ v: Any?) -> Int { if let n = v as? NSNumber { return n.intValue } diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift index 2db996bd6f..dd247168df 100644 --- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift +++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift @@ -70,11 +70,19 @@ struct CLIProviderSelectionTests { } @Test - func `provider selection uses all when enabled`() { + func `provider selection uses enabled providers when three or more are enabled`() { let selection = CodexBarCLI.providerSelection( rawOverride: nil, enabled: [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot]) - #expect(selection.asList == ProviderSelection.all.asList) + #expect(selection.asList == [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot]) + } + + @Test + func `provider selection does not expand three enabled providers to all providers`() { + let enabled: [UsageProvider] = [.codex, .claude, .copilot] + let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: enabled) + #expect(selection.asList == enabled) + #expect(!selection.asList.contains(.gemini)) } @Test diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 0cfd90ea36..21884acc04 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -198,4 +198,68 @@ struct CostUsageFetcherTests { totalTokens: 205), ]) } + + @Test + func `fetcher prefers turn context model over token count fallback`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + + let nativeTurnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": "openai/gpt-5.4", + ], + ] + let nativeTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "model": "gpt-5", + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([nativeTurnContext, nativeTokenCount])) + + let nativeOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) ?? 0 + + #expect(snapshot.daily.first?.modelBreakdowns == [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: cost, + totalTokens: 110), + ]) + } } diff --git a/docs/cli.md b/docs/cli.md index a13c8f830c..84ea928a22 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,6 +44,8 @@ See `docs/configuration.md` for the schema. - `--refresh` ignores cached scans. - `--provider ` (default: enabled providers in config; falls back to defaults when missing). - Provider IDs live in the config file (see `docs/configuration.md`). + - With three or more providers enabled, the default stays scoped to enabled providers; use `--provider all` to query + every registered provider. - `--account