From 17b065100a44f99805e10b2a53edb831ec40a522 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 18:40:06 +0100 Subject: [PATCH 001/124] chore: start 0.29.1 development --- CHANGELOG.md | 6 ++++++ version.env | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca657ad..06a0fc06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.29.1 — Unreleased + +### Added + +### Fixed + ## 0.29.0 — 2026-05-22 ### Added diff --git a/version.env b/version.env index a4ba7d01..ea739fb3 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.29.0 -BUILD_NUMBER=68 +MARKETING_VERSION=0.29.1 +BUILD_NUMBER=69 From de7c25ba2ef07f6cfbe5259923c104b5ab042763 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 23 May 2026 02:39:58 +0800 Subject: [PATCH 002/124] Show extra-usage spend text in menu bar for Claude/Cursor (#1107) * Display extra-usage spend in menu bar * docs: update changelog for menu bar extra usage spend --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../StatusItemController+Animation.swift | 15 ++++ .../StatusItemExtraUsageMetricTests.swift | 87 +++++++++++++++++-- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a0fc06..6f5e36bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added ### Fixed +- Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! ## 0.29.0 — 2026-05-22 diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index af285935..510e3f07 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -667,6 +667,11 @@ extension StatusItemController { mode: self.settings.kiroMenuBarDisplayMode, showUsed: self.settings.usageBarsShowUsed) } + if self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .extraUsage, + let spend = Self.extraUsageSpendDisplayText(snapshot: snapshot) + { + return spend + } let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) let mode = self.settings.menuBarDisplayMode @@ -752,6 +757,16 @@ extension StatusItemController { removingSuffix: " left") } + nonisolated static func extraUsageSpendDisplayText(snapshot: UsageSnapshot?) -> String? { + guard let cost = snapshot?.providerCost, + cost.limit > 0, + cost.used >= 0 + else { + return nil + } + return UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + } + nonisolated static func kiroDisplayText( snapshot: UsageSnapshot?, mode: KiroMenuBarDisplayMode, diff --git a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift index 34b9e374..3ef14df7 100644 --- a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift +++ b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift @@ -38,7 +38,9 @@ struct StatusItemExtraUsageMetricTests { @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 (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-missing-budget", + provider: .cursor) let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -54,19 +56,94 @@ struct StatusItemExtraUsageMetricTests { #expect(window?.usedPercent == 72) } + @Test + func `menu bar extra usage preference shows currency spend text for cursor when provider cost exists`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-spend-text", + provider: .cursor) + 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: 12.34, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `menu bar extra usage preference shows currency spend text for claude when provider cost exists`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-claude-spend-text", + provider: .claude) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 88.8, + limit: 200, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "$88.80") + } + + @Test + func `menu bar extra usage preference falls back to existing percent text when provider cost is unavailable`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-fallback-percent", + provider: .cursor) + 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 displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "72%") + } + private func makeCursorController(suiteName: String) -> (UsageStore, StatusItemController) { + self.makeController(suiteName: suiteName, provider: .cursor) + } + + private func makeController(suiteName: String, provider: UsageProvider) -> (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) + settings.selectedMenuProvider = provider + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.extraUsage, for: provider) let registry = ProviderRegistry.shared - if let cursorMeta = registry.metadata[.cursor] { - settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: true) } let fetcher = UsageFetcher() From c2b63a01bae4f709d7c57fd874895ab297cc20ed Mon Sep 17 00:00:00 2001 From: Perry Story Date: Fri, 22 May 2026 16:16:11 -0400 Subject: [PATCH 003/124] perf: background credits and dashboard fetch for regular refreshes (#1078) * perf: background credits and dashboard fetch for regular refreshes Move refreshCreditsIfNeeded() and refreshOpenAIDashboardIfNeeded() into background tasks for non-forced refreshes. Add Task?-based coalescing guard for credits refresh to prevent unbounded stacking. Forced refreshes (manual) still await inline. Split from #1073. * fix: persist widget snapshots after background refreshes * fix: scope background codex refresh coalescing * fix: cancel scheduled codex credits before force refresh * test: split codex background refresh coalescing coverage * fix: stop cancelled dashboard refresh side effects * test: await background codex credits refresh --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../Codex/UsageStore+CodexRefresh.swift | 63 +++ Sources/CodexBar/UsageStore+OpenAIWeb.swift | 166 +++++-- Sources/CodexBar/UsageStore.swift | 24 +- .../CodexAccountScopedRefreshTests.swift | 4 +- ...odexBackgroundRefreshCoalescingTests.swift | 363 +++++++++++++++ .../CodexManagedOpenAIWebRefreshTests.swift | 434 ++++++++++++++++-- 7 files changed, 978 insertions(+), 77 deletions(-) create mode 100644 Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5e36bc..d96ee94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed - Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! +- Codex: run regular credits and OpenAI dashboard refreshes in the background while coalescing overlapping refresh work (#1078). Thanks @ptstory! ## 0.29.0 — 2026-05-22 diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index 192f7f34..dc060801 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -13,6 +13,67 @@ extension UsageStore { self.makeFetchContext(provider: .codex, override: nil).fetcher } + func scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) { + let refreshKey = self.codexCreditsRefreshKey( + expectedGuard: self.currentCodexAccountScopedRefreshGuard()) + if let existing = self.creditsRefreshTask, + !existing.isCancelled, + self.creditsRefreshTaskKey == refreshKey + { + return + } + + self.creditsRefreshTask?.cancel() + self.creditsRefreshTaskKey = refreshKey + self.creditsRefreshTask = Task(priority: .utility) { @MainActor [weak self] in + guard let self else { return } + defer { + if self.creditsRefreshTaskKey == refreshKey { + self.creditsRefreshTask = nil + self.creditsRefreshTaskKey = nil + } + } + await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt) + guard !Task.isCancelled else { return } + self.persistWidgetSnapshot(reason: "credits") + } + } + + func cancelScheduledCreditsRefresh() { + self.creditsRefreshTask?.cancel() + self.creditsRefreshTask = nil + self.creditsRefreshTaskKey = nil + } + + func refreshCreditsNow(minimumSnapshotUpdatedAt: Date? = nil) async { + self.cancelScheduledCreditsRefresh() + await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt) + } + + func codexCreditsRefreshKey(expectedGuard: CodexAccountScopedRefreshGuard) -> String { + let sourceKey = switch expectedGuard.source { + case .liveSystem: + "live" + case let .managedAccount(id): + "managed:\(id.uuidString)" + } + + let identityKey = switch expectedGuard.identity { + case let .providerAccount(id): + "provider:\(id)" + case let .emailOnly(normalizedEmail): + "email:\(normalizedEmail)" + case .unresolved: + "unresolved" + } + + return [ + sourceKey, + identityKey, + expectedGuard.accountKey ?? "account:nil", + ].joined(separator: "|") + } + func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async { guard self.isEnabled(.codex) else { return } var expectedGuard = self.currentCodexAccountScopedRefreshGuard() @@ -30,6 +91,7 @@ extension UsageStore { } do { let credits = try await self.loadLatestCodexCredits() + guard !Task.isCancelled else { return } guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } await MainActor.run { self.credits = credits @@ -58,6 +120,7 @@ extension UsageStore { snapshot: codexSnapshot, now: codexSnapshot.updatedAt) } catch { + guard !Task.isCancelled else { return } let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index c02e06c5..3b75b446 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -28,6 +28,15 @@ extension UsageStore { let allowCodexUsageBackfill: Bool } + private struct OpenAIDashboardCookieImportRequest { + let normalizedTarget: String? + let allowAnyAccount: Bool + let cookieSource: ProviderCookieSource + let cacheScope: CookieHeaderCache.Scope? + let preferCachedCookieHeader: Bool? + let force: Bool + } + private static let openAIWebRefreshMultiplier: TimeInterval = 5 private static let openAIWebPrimaryFetchTimeout: TimeInterval = 25 private static let openAIWebRetryFetchTimeout: TimeInterval = 8 @@ -409,7 +418,45 @@ extension UsageStore { } } + func scheduleOpenAIDashboardRefreshIfNeeded(expectedGuard: CodexAccountScopedRefreshGuard? = nil) { + self.syncOpenAIWebState() + let allowCurrentSnapshotFallback = expectedGuard?.source == .liveSystem && expectedGuard? + .identity == .unresolved + let targetEmail = self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: allowCurrentSnapshotFallback, + allowLastKnownLiveFallback: expectedGuard?.identity != .unresolved) + let refreshKey = self.openAIDashboardRefreshKey(targetEmail: targetEmail, expectedGuard: expectedGuard) + if let task = self.openAIDashboardBackgroundRefreshTask, + !task.isCancelled, + self.openAIDashboardBackgroundRefreshTaskKey == refreshKey + { + return + } + + if self.openAIDashboardBackgroundRefreshTaskKey != nil, + self.openAIDashboardBackgroundRefreshTaskKey != refreshKey + { + self.invalidateOpenAIDashboardRefreshTask() + } + self.openAIDashboardBackgroundRefreshTask?.cancel() + self.openAIDashboardBackgroundRefreshTaskKey = refreshKey + self.openAIDashboardBackgroundRefreshTask = Task(priority: .utility) { @MainActor [weak self] in + guard let self else { return } + defer { + if self.openAIDashboardBackgroundRefreshTaskKey == refreshKey { + self.openAIDashboardBackgroundRefreshTask = nil + self.openAIDashboardBackgroundRefreshTaskKey = nil + } + } + + await self.refreshOpenAIDashboardIfNeeded(force: false, expectedGuard: expectedGuard) + guard !Task.isCancelled else { return } + self.persistWidgetSnapshot(reason: "dashboard") + } + } + private func performOpenAIDashboardRefreshIfNeeded(_ context: OpenAIDashboardRefreshContext) async { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } self.openAIDashboardCookieImportStatus = nil var latestCookieImportStatus: String? if self.openAIWebDebugLines.isEmpty { @@ -440,6 +487,7 @@ extension UsageStore { let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: targetEmail, force: true) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } didImportCookiesForRefresh = true latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( @@ -462,12 +510,14 @@ extension UsageStore { accountEmail: effectiveEmail, logger: log, timeout: Self.openAIWebDashboardFetchTimeout(didImportCookies: didImportCookiesForRefresh)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) { if let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: context.targetEmail, force: true) { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } effectiveEmail = imported } latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() @@ -475,6 +525,7 @@ extension UsageStore { accountEmail: effectiveEmail, logger: log, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } } await self.applyOpenAIDashboard( @@ -484,17 +535,20 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, allowCodexUsageBackfill: context.allowCodexUsageBackfill) } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(body) { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.retryOpenAIDashboardAfterNoData( body: body, context: context, latestCookieImportStatus: &latestCookieImportStatus, logger: log) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.retryOpenAIDashboardAfterLoginRequired( context: context, latestCookieImportStatus: &latestCookieImportStatus, logger: log) } catch { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } if Self.isOpenAIDashboardTimeout(error) { await self.retryOpenAIDashboardAfterTimeout( context: context, @@ -527,6 +581,7 @@ extension UsageStore { targetEmail: targetEmail, force: true, preferCachedCookieHeader: true) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( importedEmail: imported, @@ -545,6 +600,7 @@ extension UsageStore { accountEmail: effectiveEmail, logger: logger, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( dash, targetEmail: effectiveEmail, @@ -552,6 +608,7 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, allowCodexUsageBackfill: context.allowCodexUsageBackfill) } catch { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: targetEmail, @@ -575,6 +632,7 @@ extension UsageStore { allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) var effectiveEmail = targetEmail let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( importedEmail: imported, @@ -593,6 +651,7 @@ extension UsageStore { accountEmail: effectiveEmail, logger: logger, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( dash, targetEmail: effectiveEmail, @@ -600,6 +659,7 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, allowCodexUsageBackfill: context.allowCodexUsageBackfill) } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(retryBody) { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } let finalBody = retryBody.isEmpty ? body : retryBody let message = self.openAIDashboardFriendlyError( body: finalBody, @@ -612,6 +672,7 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, routingTargetEmail: targetEmail) } catch { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: targetEmail, @@ -634,6 +695,7 @@ extension UsageStore { allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) var effectiveEmail = targetEmail let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( importedEmail: imported, @@ -652,6 +714,7 @@ extension UsageStore { accountEmail: effectiveEmail, logger: logger, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( dash, targetEmail: effectiveEmail, @@ -659,11 +722,13 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, allowCodexUsageBackfill: context.allowCodexUsageBackfill) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboardLoginRequiredFailure( expectedGuard: context.expectedGuard, refreshTaskToken: context.refreshTaskToken, routingTargetEmail: targetEmail) } catch { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: targetEmail, @@ -844,6 +909,10 @@ extension UsageStore { return self.openAIDashboardRefreshTaskToken == token } + private func shouldContinueOpenAIDashboardRefresh(token: UUID?) -> Bool { + !Task.isCancelled && self.shouldApplyOpenAIDashboardRefreshTask(token: token) + } + func invalidateOpenAIDashboardRefreshTask() { self.openAIDashboardRefreshTask?.cancel() self.openAIDashboardRefreshTask = nil @@ -920,14 +989,62 @@ extension UsageStore { return false } + private func openAIDashboardCookieImportResult( + request: OpenAIDashboardCookieImportRequest, + logger: @escaping (String) -> Void) async throws -> OpenAIDashboardBrowserCookieImporter.ImportResult + { + if let override = self._test_openAIDashboardCookieImportOverride { + return try await override( + request.normalizedTarget, + request.allowAnyAccount, + request.cookieSource, + request.cacheScope, + logger) + } + + let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) + switch request.cookieSource { + case .manual: + self.settings.ensureCodexCookieLoaded() + // Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are + // isolated per managed account, but a manual header is an explicit override owned by settings, + // so switching managed accounts does not currently swap it underneath the user. + let manualHeader = self.settings.codexCookieHeader + guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { + throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid + } + return try await importer.importManualCookies( + cookieHeader: manualHeader, + intoAccountEmail: request.normalizedTarget, + allowAnyAccount: request.allowAnyAccount, + cacheScope: request.cacheScope, + logger: logger) + case .auto: + return try await importer.importBestCookies( + intoAccountEmail: request.normalizedTarget, + allowAnyAccount: request.allowAnyAccount, + preferCachedCookieHeader: request.preferCachedCookieHeader ?? !request.force, + cacheScope: request.cacheScope, + logger: logger) + case .off: + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Off", + cookieCount: 0, + signedInEmail: request.normalizedTarget, + matchesCodexEmail: true) + } + } + func importOpenAIDashboardCookiesIfNeeded( targetEmail: String?, force: Bool, preferCachedCookieHeader: Bool? = nil) async -> String? { + guard !Task.isCancelled else { return nil } if await self.openAIWebCookieImportShouldFailClosed() { return nil } + guard !Task.isCancelled else { return nil } let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true @@ -965,42 +1082,17 @@ extension UsageStore { self.logOpenAIWeb(message) } - let result: OpenAIDashboardBrowserCookieImporter.ImportResult - if let override = self._test_openAIDashboardCookieImportOverride { - result = try await override(normalizedTarget, allowAnyAccount, cookieSource, cacheScope, log) - } else { - let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) - switch cookieSource { - case .manual: - self.settings.ensureCodexCookieLoaded() - // Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are - // isolated per managed account, but a manual header is an explicit override owned by settings, - // so switching managed accounts does not currently swap it underneath the user. - let manualHeader = self.settings.codexCookieHeader - guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { - throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid - } - result = try await importer.importManualCookies( - cookieHeader: manualHeader, - intoAccountEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - cacheScope: cacheScope, - logger: log) - case .auto: - result = try await importer.importBestCookies( - intoAccountEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - preferCachedCookieHeader: preferCachedCookieHeader ?? !force, - cacheScope: cacheScope, - logger: log) - case .off: - result = OpenAIDashboardBrowserCookieImporter.ImportResult( - sourceLabel: "Off", - cookieCount: 0, - signedInEmail: normalizedTarget, - matchesCodexEmail: true) - } - } + let request = OpenAIDashboardCookieImportRequest( + normalizedTarget: normalizedTarget, + allowAnyAccount: allowAnyAccount, + cookieSource: cookieSource, + cacheScope: cacheScope, + preferCachedCookieHeader: preferCachedCookieHeader, + force: force) + let result = try await self.openAIDashboardCookieImportResult( + request: request, + logger: log) + guard !Task.isCancelled else { return nil } let effectiveEmail = result.signedInEmail? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty == false @@ -1036,6 +1128,7 @@ extension UsageStore { } return effectiveEmail } catch let err as OpenAIDashboardBrowserCookieImporter.ImportError { + guard !Task.isCancelled else { return nil } switch err { case let .noMatchingAccount(found): let foundText: String = if found.isEmpty { @@ -1073,6 +1166,7 @@ extension UsageStore { } } } catch { + guard !Task.isCancelled else { return nil } self.logOpenAIWeb("[\(stamp)] import failed: \(error.localizedDescription)") await MainActor.run { self.openAIDashboardCookieImportStatus = diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 608d779d..0bc6f514 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -179,6 +179,10 @@ final class UsageStore { @ObservationIgnored var lastCodexAccountScopedRefreshGuard: CodexAccountScopedRefreshGuard? @ObservationIgnored var lastKnownLiveSystemCodexEmail: String? @ObservationIgnored var openAIWebAccountDidChange: Bool = false + @ObservationIgnored var creditsRefreshTask: Task? + @ObservationIgnored var creditsRefreshTaskKey: String? + @ObservationIgnored var openAIDashboardBackgroundRefreshTask: Task? + @ObservationIgnored var openAIDashboardBackgroundRefreshTaskKey: String? @ObservationIgnored var openAIDashboardRefreshTask: Task? @ObservationIgnored var openAIDashboardRefreshTaskKey: String? @ObservationIgnored var openAIDashboardRefreshTaskToken: UUID? @@ -554,7 +558,13 @@ final class UsageStore { group.addTask { await self.refreshStatus(provider) } } } - group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } + if forceTokenUsage { + group.addTask { await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) } + } + } + + if !forceTokenUsage { + self.scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } if forceTokenUsage { @@ -586,14 +596,18 @@ final class UsageStore { ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() - await self.refreshOpenAIDashboardIfNeeded( - force: forceTokenUsage, - expectedGuard: codexDashboardGuard) + if forceTokenUsage { + await self.refreshOpenAIDashboardIfNeeded( + force: true, + expectedGuard: codexDashboardGuard) + } else { + self.scheduleOpenAIDashboardRefreshIfNeeded(expectedGuard: codexDashboardGuard) + } } if forceTokenUsage, self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) - await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) + await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) } self.persistWidgetSnapshot(reason: "refresh") diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 3d64d423..6eb33679 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -871,10 +871,12 @@ struct CodexAccountScopedRefreshTests { await blocker.waitUntilStarted() await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 12))) await refreshTask.value + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "alpha@example.com") + + await store.creditsRefreshTask?.value #expect(store.credits?.remaining == 55) #expect(store.lastCreditsSource == .api) - #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "alpha@example.com") } @Test diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift new file mode 100644 index 00000000..33f25c47 --- /dev/null +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -0,0 +1,363 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized) +@MainActor +struct CodexBackgroundRefreshCoalescingTests { + @Test + func `rapid regular refreshes coalesce concurrent Codex credits fetches`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-credits-coalescing") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let blocker = BlockingCreditsLoader() + let firstCompletion = RefreshCompletionProbe() + let secondCompletion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let firstRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + await firstCompletion.markCompleted() + } + await blocker.waitUntilStarted(count: 1) + #expect(await firstCompletion.isCompleted == true) + + let secondRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + await secondCompletion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + + #expect(await blocker.startedCount() == 1) + #expect(await secondCompletion.isCompleted == true) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await firstRefreshTask.value + await secondRefreshTask.value + } + + @Test + func `regular credits refresh reschedules when Codex account changes`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-credits-account-switch") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + let alphaAccount = try Self.makeManagedAccount(email: "alpha@example.com") + let betaAccount = try Self.makeManagedAccount(email: "beta@example.com") + defer { + try? FileManager.default.removeItem(atPath: alphaAccount.managedHomePath) + try? FileManager.default.removeItem(atPath: betaAccount.managedHomePath) + } + settings._test_activeManagedCodexAccount = alphaAccount + settings.codexActiveSource = .managedAccount(id: alphaAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = self.makeStore(settings: settings) + let blocker = BlockingCreditsLoader() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let alphaRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + await blocker.waitUntilStarted(count: 1) + + settings._test_activeManagedCodexAccount = betaAccount + settings.codexActiveSource = .managedAccount(id: betaAccount.id) + let betaRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + await blocker.waitUntilStarted(count: 2) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 10, events: [], updatedAt: Date()))) + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await alphaRefreshTask.value + await betaRefreshTask.value + await store.creditsRefreshTask?.value + + #expect(await blocker.startedCount() == 2) + #expect(store.lastCreditsSnapshotAccountKey == "beta@example.com") + #expect(store.credits?.remaining == 25) + } + + @Test + func `force refresh cancels stale background Codex credits fetch`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-credits-force-cancels-background") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let blocker = BlockingCreditsLoader() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let regularRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + await blocker.waitUntilStarted(count: 1) + + let forceRefreshTask = Task { + await store.refresh(forceTokenUsage: true) + } + await blocker.waitUntilStarted(count: 2) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 10, events: [], updatedAt: Date()))) + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await regularRefreshTask.value + await forceRefreshTask.value + + #expect(await blocker.startedCount() == 2) + #expect(store.credits?.remaining == 25) + } + + @Test + func `rapid regular refreshes coalesce concurrent OpenAI dashboard fetches`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-dashboard-coalescing") + settings.statusChecksEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let blocker = BlockingManagedOpenAIDashboardLoader() + let firstCompletion = RefreshCompletionProbe() + let secondCompletion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_openAIDashboardLoaderOverride = { _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let firstRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + await firstCompletion.markCompleted() + } + await blocker.waitUntilStarted(count: 1) + #expect(await firstCompletion.isCompleted == true) + + let secondRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + await secondCompletion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + + #expect(await blocker.startedCount() == 1) + #expect(await secondCompletion.isCompleted == true) + + let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await firstRefreshTask.value + await secondRefreshTask.value + await backgroundTask.value + + #expect(store.openAIDashboard?.creditsRemaining == 25) + } + + @Test + func `cancelled background dashboard import does not publish stale account status`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-dashboard-cancelled-import") + settings.statusChecksEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let importBlocker = BlockingOpenAIDashboardCookieImport() + store._test_openAIDashboardCookieImportOverride = { _, _, _, _, _ in + try await importBlocker.awaitResult() + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let importTask = Task { @MainActor in + await store.importOpenAIDashboardCookiesIfNeeded( + targetEmail: managedAccount.email, + force: true) + } + await importBlocker.waitUntilStarted() + importTask.cancel() + await importBlocker.resumeNext(with: .failure( + OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount( + found: [.init(sourceLabel: "Chrome", email: "other@example.com")]))) + + let imported = await importTask.value + #expect(imported == nil) + #expect(store.openAIDashboard == nil) + #expect(store.openAIDashboardCookieImportStatus == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + + private func makeSettingsStore(suite: String) throws -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.providerDetectionCompleted = true + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + return settings + } + + private func makeStore(settings: SettingsStore) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + } + + private static func installManagedAccount( + email: String, + settings: SettingsStore) throws -> ManagedCodexAccount + { + let account = try Self.makeManagedAccount(email: email) + settings._test_activeManagedCodexAccount = account + settings.codexActiveSource = .managedAccount(id: account.id) + return account + } + + private static func makeManagedAccount(email: String) throws -> ManagedCodexAccount { + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: email, + plan: "Pro") + return ManagedCodexAccount( + id: UUID(), + email: email, + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan), + ] + let data = try JSONSerialization.data(withJSONObject: ["tokens": tokens], options: [.sortedKeys]) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": [ + "chatgpt_plan_type": plan, + ], + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } +} + +private actor BlockingOpenAIDashboardCookieImport { + private var continuations: [ + CheckedContinuation, Never> + ] = [] + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var started = 0 + + func awaitResult() async throws -> OpenAIDashboardBrowserCookieImporter.ImportResult { + let result = await withCheckedContinuation { continuation in + self.continuations.append(continuation) + self.started += 1 + self.resumeReadyStartWaiters() + } + return try result.get() + } + + func waitUntilStarted(count: Int = 1) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resumeNext(with result: Result) { + guard !self.continuations.isEmpty else { return } + let continuation = self.continuations.removeFirst() + continuation.resume(returning: result) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 213d965f..894341f7 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -6,12 +6,302 @@ import Testing @Suite(.serialized) @MainActor struct CodexManagedOpenAIWebRefreshTests { + @Test + func `regular refresh does not await OpenAI web scrape`() async throws { + let settings = try self + .makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-regular-refresh-nonblocking") + settings.statusChecksEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + 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 completion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_openAIDashboardLoaderOverride = { _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + + #expect(await blocker.startedCount() == 1) + #expect(await completion.isCompleted == true) + + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await refreshTask.value + } + + @Test + func `regular refresh does not await Codex credits fetch`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-regular-refresh-nonblocking-credits") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + 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 = BlockingCreditsLoader() + let completion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + await completion.markCompleted() + } + + await blocker.waitUntilStarted(count: 1) + + #expect(await blocker.startedCount() == 1) + #expect(await completion.isCompleted == true) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await refreshTask.value + } + + @Test + func `background credits refresh persists updated widget snapshot after refresh returns`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-widget-background-credits") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + 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) + store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) + + let creditsBlocker = BlockingCreditsLoader() + let saver = BlockingWidgetSnapshotSaver() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await creditsBlocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_widgetSnapshotSaveOverride = { snapshot in + await saver.save(snapshot) + } + defer { store._test_widgetSnapshotSaveOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + + await refreshTask.value + await saver.waitUntilStarted(count: 1) + + let firstSnapshots = await saver.savedSnapshots() + let firstCodexEntry = try #require(firstSnapshots.first?.entries.first { $0.provider == .codex }) + #expect(firstCodexEntry.creditsRemaining == nil) + + await saver.resumeNext() + let backgroundTask = try #require(store.creditsRefreshTask) + await creditsBlocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + await backgroundTask.value + await saver.waitUntilStarted(count: 2) + + #expect(await saver.startedCount() == 2) + let secondSnapshots = await saver.savedSnapshots() + let secondCodexEntry = try #require(secondSnapshots.last?.entries.first { $0.provider == .codex }) + #expect(secondCodexEntry.creditsRemaining == 25) + + await saver.resumeNext() + await store.widgetSnapshotPersistTask?.value + } + + @Test + func `background dashboard refresh persists updated widget snapshot after refresh returns`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-widget-background-dashboard") + settings.statusChecksEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + 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) + store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) + store.creditsRefreshTask = Task {} + store.creditsRefreshTaskKey = store.codexCreditsRefreshKey( + expectedGuard: store.currentCodexAccountScopedRefreshGuard()) + + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + let saver = BlockingWidgetSnapshotSaver() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_openAIDashboardLoaderOverride = { _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_widgetSnapshotSaveOverride = { snapshot in + await saver.save(snapshot) + } + defer { store._test_widgetSnapshotSaveOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + + await refreshTask.value + await saver.waitUntilStarted(count: 1) + + let firstSnapshots = await saver.savedSnapshots() + let firstCodexEntry = try #require(firstSnapshots.first?.entries.first { $0.provider == .codex }) + #expect(firstCodexEntry.codeReviewRemainingPercent == nil) + + await saver.resumeNext() + let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) + await dashboardBlocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + await backgroundTask.value + await saver.waitUntilStarted(count: 2) + + #expect(await saver.startedCount() == 2) + let secondSnapshots = await saver.savedSnapshots() + let secondCodexEntry = try #require(secondSnapshots.last?.entries.first { $0.provider == .codex }) + #expect(secondCodexEntry.codeReviewRemainingPercent == 95) + + await saver.resumeNext() + await store.widgetSnapshotPersistTask?.value + } + @Test func `manual cookie import bypasses same account refresh coalescing`() async throws { let settings = try self.makeSettingsStore( suite: "CodexManagedOpenAIWebRefreshTests-manual-import-bypass-coalesce") let managedHome = FileManager.default.temporaryDirectory .appendingPathComponent("codex-managed-openai-web-refresh-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHome) } let managedAccount = ManagedCodexAccount( id: UUID(), email: "managed@example.com", @@ -246,43 +536,15 @@ struct CodexManagedOpenAIWebRefreshTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, startupBehavior: .testing) - let blocker = BlockingManagedOpenAIDashboardLoader() - let importTracker = OpenAIDashboardImportCallTracker() + store.openAIDashboardCookieImportStatus = + "OpenAI cookies are for other@example.com, not managed@example.com." store._test_openAIDashboardLoaderOverride = { _, _, _ in - try await blocker.awaitResult() + throw ManagedDashboardTestError.networkTimeout } defer { store._test_openAIDashboardLoaderOverride = nil } - store._test_openAIDashboardCookieImportOverride = { _, _, _, _, _ in - let call = await importTracker.recordCall() - if call == 1 { - return OpenAIDashboardBrowserCookieImporter.ImportResult( - sourceLabel: "Chrome", - cookieCount: 2, - signedInEmail: managedAccount.email, - matchesCodexEmail: true) - } - throw OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount( - found: [.init(sourceLabel: "Chrome", email: "other@example.com")]) - } - defer { store._test_openAIDashboardCookieImportOverride = nil } let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() - let firstTask = Task { - await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) - } - await blocker.waitUntilStarted(count: 1) - - let secondTask = Task { - await store.importOpenAIDashboardBrowserCookiesNow() - } - await blocker.waitUntilStarted(count: 2) - - await blocker.resumeNext(with: .failure(OpenAIDashboardFetcher.FetchError.loginRequired)) - await importTracker.waitUntilCalls(count: 2) - await blocker.resumeNext(with: .failure(ManagedDashboardTestError.networkTimeout)) - - await firstTask.value - await secondTask.value + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) #expect(store.lastOpenAIDashboardError == ManagedDashboardTestError.networkTimeout.localizedDescription) } @@ -312,6 +574,56 @@ struct CodexManagedOpenAIWebRefreshTests { settings.codexCookieSource = .auto return settings } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String, accountId: String? = nil) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + var tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan, accountId: accountId), + ] + if let accountId { + tokens["accountId"] = accountId + } + let data = try JSONSerialization.data(withJSONObject: ["tokens": tokens], options: [.sortedKeys]) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String, accountId: String? = nil) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + var authClaims: [String: Any] = [ + "chatgpt_plan_type": plan, + ] + if let accountId { + authClaims["chatgpt_account_id"] = accountId + } + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": authClaims, + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } + + private static func codexSnapshot(email: String, usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: usedPercent, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Pro")) + } } private enum ManagedDashboardTestError: LocalizedError { @@ -325,16 +637,24 @@ private enum ManagedDashboardTestError: LocalizedError { } } -private actor BlockingManagedOpenAIDashboardLoader { +actor RefreshCompletionProbe { + private(set) var isCompleted = false + + func markCompleted() { + self.isCompleted = true + } +} + +actor BlockingManagedOpenAIDashboardLoader { private var continuations: [CheckedContinuation, Never>] = [] private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] private var started: Int = 0 func awaitResult() async throws -> OpenAIDashboardSnapshot { - self.started += 1 - self.resumeReadyStartWaiters() let result = await withCheckedContinuation { continuation in self.continuations.append(continuation) + self.started += 1 + self.resumeReadyStartWaiters() } return try result.get() } @@ -369,6 +689,50 @@ private actor BlockingManagedOpenAIDashboardLoader { } } +actor BlockingCreditsLoader { + private var continuations: [CheckedContinuation, Never>] = [] + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var started = 0 + + func awaitResult() async throws -> CreditsSnapshot { + let result = await withCheckedContinuation { continuation in + self.continuations.append(continuation) + self.started += 1 + self.resumeReadyStartWaiters() + } + return try result.get() + } + + func waitUntilStarted(count: Int = 1) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func startedCount() -> Int { + self.started + } + + func resumeNext(with result: Result) { + guard !self.continuations.isEmpty else { return } + let continuation = self.continuations.removeFirst() + continuation.resume(returning: result) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} + private actor OpenAIDashboardImportCallTracker { private var calls: Int = 0 private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] From e9b7e25f851b6a96cd6f725a1c2d534a4bee5e65 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 23 May 2026 07:10:01 +0800 Subject: [PATCH 004/124] Display: add workday segmentation for weekly progress bars (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Display: add workday segmentation for weekly progress bars Fixes #1096 — Weekly Progress Bar configurable to segment in number of work days. - Add a Display preference for weekly progress work-day segmentation: Off / 4 days / 5 days / 7 days - Draw display-only day-boundary tick marks on weekly usage bars - Keep the default Off / nil so existing users see no UI change - Merge work-day markers with existing quota warning markers - No warning/alert behavior added; future warnings remain out of scope for this PR Validation: - swift test --filter MenuCardQuotaWarningMarkerTests - swift test --filter SettingsStoreCoverageTests - swift test --filter MenuCardModelTests - make check * Fix weekly workday marker observation and codex lanes * Linux: import FoundationNetworking for HTTPCookie * docs: update changelog for workday markers --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../MenuCardQuotaWarningMarkers.swift | 59 ++++++ Sources/CodexBar/MenuCardView.swift | 14 +- Sources/CodexBar/PreferencesDisplayPane.swift | 19 ++ .../CodexBar/PreferencesProvidersPane.swift | 1 + .../Resources/en.lproj/Localizable.strings | 2 + Sources/CodexBar/SettingsStore+Defaults.swift | 12 ++ .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+MenuCardModel.swift | 1 + .../MenuCardModelCodexProjectionTests.swift | 192 ++++++++++++++++++ .../MenuCardQuotaWarningMarkerTests.swift | 42 ++++ .../SettingsStoreCoverageTests.swift | 32 +++ Tests/CodexBarTests/SettingsStoreTests.swift | 27 +++ 15 files changed, 400 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d96ee94a..1b65a4ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.29.1 — Unreleased ### Added +- Display: add optional workday markers for weekly progress bars (#1102). Thanks @Yuxin-Qiao! ### Fixed - Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift index 89505ac5..abe15e28 100644 --- a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift +++ b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift @@ -18,4 +18,63 @@ extension UsageMenuCardView.Model { .map { showUsed ? 100 - Double($0) : Double($0) } .filter { $0 > 0 && $0 < 100 } } + + /// Merges quota warning markers with optional work-day boundary markers. + /// Preserves original warning-marker ordering when workdayMarkers is empty, + /// sorts the combined set when workday markers are present. + static func mergedMarkerPercents( + warningMarkers: [Double], + workdayMarkers: [Double]) -> [Double] + { + let combined = warningMarkers + workdayMarkers + return workdayMarkers.isEmpty ? combined : combined.sorted() + } + + /// Combines quota warning markers with optional work-day boundary markers + /// into a single sorted array. Workday markers are only applied when + /// includeWorkdayMarkers is true and windowMinutes == 10080. + static func markerPercents( + thresholds: [Int]?, + showUsed: Bool, + workDays: Int?, + windowMinutes: Int?, + includeWorkdayMarkers: Bool) -> [Double] + { + let warningMarkers = Self.warningMarkerPercents(thresholds: thresholds, showUsed: showUsed) + let workdayMarkers = includeWorkdayMarkers + ? workDayMarkerPercents(workDays: workDays, windowMinutes: windowMinutes) + : [] + return Self.mergedMarkerPercents(warningMarkers: warningMarkers, workdayMarkers: workdayMarkers) + } + + static func weeklyMarkerPercents(input: Input, windowMinutes: Int?) -> [Double] { + UsageMenuCardView.Model.markerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed, + workDays: input.workDaysPerWeek, + windowMinutes: windowMinutes, + includeWorkdayMarkers: true) + } + + static func codexLaneMarkerPercents( + input: Input, + lane: CodexConsumerProjection.RateLane, + windowMinutes: Int?) -> [Double] + { + UsageMenuCardView.Model.markerPercents( + thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], + showUsed: input.usageBarsShowUsed, + workDays: input.workDaysPerWeek, + windowMinutes: windowMinutes, + includeWorkdayMarkers: lane == .weekly) + } +} + +/// Returns boundary percentages for work day markers on a weekly progress bar. +/// Only valid when windowMinutes == 10080 (standard 7-day week). +/// nil workDays means feature is disabled. +func workDayMarkerPercents(workDays: Int?, windowMinutes: Int?) -> [Double] { + guard workDays != nil, windowMinutes == 10080 else { return [] } + guard let wd = workDays, wd >= 2, wd <= 7 else { return [] } + return (1.. SettingsStore { let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 4182f310..7c3b7079 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1068,6 +1068,33 @@ struct SettingsStoreTests { await expectObservation(for: .weekly, thresholds: [80, 40]) } + @Test + func `menu observation token updates on weekly progress work days changes`() async throws { + let suite = "SettingsStoreTests-observation-weekly-progress-work-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.weeklyProgressWorkDays = 5 + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + @Test func `config backed settings trigger observation`() async throws { let suite = "SettingsStoreTests-observation-config" From 7754035e6bb5eb689850c2d5deaeb5dc0fac8483 Mon Sep 17 00:00:00 2001 From: Sopen <58549604+sopenlaz0@users.noreply.github.com> Date: Sat, 23 May 2026 06:19:06 +0700 Subject: [PATCH 005/124] fix: read OpenCode Go usage from local data (#1021) * Fix OpenCode Go local usage * Fix OpenCode Go monthly anchor * fix: keep OpenCode Go web usage authoritative --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../OpenCodeGoLocalUsageReader.swift | 320 ++++++++++++++++++ .../OpenCodeGoProviderDescriptor.swift | 89 ++++- .../OpenCodeGo/OpenCodeGoUsageFetcher.swift | 27 ++ .../OpenCodeGoLocalUsageReaderTests.swift | 299 ++++++++++++++++ .../OpenCodeGoProviderStrategyTests.swift | 64 ++++ .../OpenCodeGoUsageFetcherErrorTests.swift | 28 ++ 7 files changed, 824 insertions(+), 4 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift create mode 100644 Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift create mode 100644 Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b65a4ea..123293ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Display: add optional workday markers for weekly progress bars (#1102). Thanks @Yuxin-Qiao! ### Fixed +- OpenCode Go: read local usage history before falling back to browser-cookie dashboard fetches (#1021). Thanks @sopenlaz0! - Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! - Codex: run regular credits and OpenAI dashboard refreshes in the background while coalescing overlapping refresh work (#1078). Thanks @ptstory! diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift new file mode 100644 index 00000000..13098a44 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -0,0 +1,320 @@ +import Foundation + +#if os(macOS) +import SQLite3 + +public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable { + case notDetected + case historyUnavailable(String) + case sqliteFailed(String) + + public var errorDescription: String? { + switch self { + case .notDetected: + "OpenCode Go not detected. Log in with OpenCode Go or use it locally first." + case let .historyUnavailable(message): + "OpenCode Go local usage history is unavailable: \(message)" + case let .sqliteFailed(message): + "SQLite error reading OpenCode Go usage: \(message)" + } + } +} + +public struct OpenCodeGoLocalUsageReader: Sendable { + private static let fiveHours: TimeInterval = 5 * 60 * 60 + private static let week: TimeInterval = 7 * 24 * 60 * 60 + private static let limits = (session: 12.0, weekly: 30.0, monthly: 60.0) + + private let authURL: URL + private let databaseURL: URL + + public init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + let openCodeDirectory = homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + self.authURL = openCodeDirectory.appendingPathComponent("auth.json", isDirectory: false) + self.databaseURL = openCodeDirectory.appendingPathComponent("opencode.db", isDirectory: false) + } + + public init(authURL: URL, databaseURL: URL) { + self.authURL = authURL + self.databaseURL = databaseURL + } + + public func fetch(now: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + let hasAuth = Self.hasAuthKey(at: self.authURL) + guard FileManager.default.fileExists(atPath: self.databaseURL.path) else { + if hasAuth { + throw OpenCodeGoLocalUsageError.historyUnavailable("database not found") + } + throw OpenCodeGoLocalUsageError.notDetected + } + + let rows = try self.readRows() + guard hasAuth || !rows.isEmpty else { + throw OpenCodeGoLocalUsageError.notDetected + } + guard !rows.isEmpty else { + throw OpenCodeGoLocalUsageError.historyUnavailable("no local usage rows") + } + return Self.snapshot(rows: rows, now: now) + } + + private func readRows() throws -> [UsageRow] { + var db: OpaquePointer? + guard sqlite3_open_v2(self.databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let sql = self.hasTable(named: "part", db: db) ? Self.messageAndPartUsageSQL : Self.messageUsageSQL + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + var rows: [UsageRow] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + + let createdMs = sqlite3_column_int64(stmt, 0) + let cost = sqlite3_column_double(stmt, 1) + guard createdMs > 0, cost >= 0, cost.isFinite else { continue } + rows.append(UsageRow(createdMs: createdMs, cost: cost)) + } + return rows + } + + private func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static let messageUsageSQL = """ + SELECT + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + """ + + private static let messageAndPartUsageSQL = """ + WITH message_costs AS ( + SELECT + id AS messageID, + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + ) + SELECT createdMs, cost + FROM message_costs + UNION ALL + SELECT + CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.time_created) AS INTEGER) + AS createdMs, + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost + FROM part p + JOIN message m ON m.id = p.message_id + WHERE json_valid(p.data) + AND json_valid(m.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') + AND json_extract(m.data, '$.providerID') = 'opencode-go' + AND json_extract(m.data, '$.role') = 'assistant' + AND NOT EXISTS ( + SELECT 1 + FROM message_costs + WHERE message_costs.messageID = p.message_id + ) + """ + + private struct UsageRow { + let createdMs: Int64 + let cost: Double + } + + private static func hasAuthKey(at url: URL) -> Bool { + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let entry = object["opencode-go"] as? [String: Any], + let key = entry["key"] as? String + else { + return false + } + return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func snapshot(rows: [UsageRow], now: Date) -> OpenCodeGoUsageSnapshot { + let nowMs = Int64(now.timeIntervalSince1970 * 1000) + let sessionStart = nowMs - Int64(Self.fiveHours * 1000) + let weekStart = self.startOfUTCWeek(now: now).timeIntervalSince1970 * 1000 + let weekStartMs = Int64(weekStart) + let weekEndMs = weekStartMs + Int64(Self.week * 1000) + let earliestMs = rows.map(\.createdMs).min() + let monthBounds = self.monthBounds(now: now, anchorMs: earliestMs) + + let sessionCost = self.sum(rows: rows, startMs: sessionStart, endMs: nowMs) + let weeklyCost = self.sum(rows: rows, startMs: weekStartMs, endMs: weekEndMs) + let monthlyCost = self.sum(rows: rows, startMs: monthBounds.startMs, endMs: monthBounds.endMs) + + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: self.percent(used: sessionCost, limit: self.limits.session), + weeklyUsagePercent: self.percent(used: weeklyCost, limit: self.limits.weekly), + monthlyUsagePercent: self.percent(used: monthlyCost, limit: self.limits.monthly), + rollingResetInSec: self.rollingReset(rows: rows, nowMs: nowMs), + weeklyResetInSec: max(0, Int((weekEndMs - nowMs) / 1000)), + monthlyResetInSec: max(0, Int((monthBounds.endMs - nowMs) / 1000)), + updatedAt: now) + } + + private static func sum(rows: [UsageRow], startMs: Int64, endMs: Int64) -> Double { + rows.reduce(0) { total, row in + guard row.createdMs >= startMs, row.createdMs < endMs else { return total } + return total + row.cost + } + } + + private static func percent(used: Double, limit: Double) -> Double { + guard used.isFinite, limit > 0 else { return 0 } + let value = max(0, min(100, used / limit * 100)) + return (value * 10).rounded() / 10 + } + + private static func rollingReset(rows: [UsageRow], nowMs: Int64) -> Int { + let sessionStart = nowMs - Int64(Self.fiveHours * 1000) + let oldest = rows + .filter { $0.createdMs >= sessionStart && $0.createdMs < nowMs } + .map(\.createdMs) + .min() ?? nowMs + return max(0, Int((oldest + Int64(Self.fiveHours * 1000) - nowMs) / 1000)) + } + + private static func startOfUTCWeek(now: Date) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + calendar.firstWeekday = 2 + calendar.minimumDaysInFirstWeek = 4 + let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now) + return calendar.date(from: components) ?? now + } + + private static func monthBounds(now: Date, anchorMs: Int64?) -> (startMs: Int64, endMs: Int64) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + + guard let anchorMs else { + let start = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) ?? now + let end = calendar.date(byAdding: .month, value: 1, to: start) ?? start + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + + let anchor = Date(timeIntervalSince1970: TimeInterval(anchorMs) / 1000) + let anchorComponents = calendar.dateComponents([.day, .hour, .minute, .second, .nanosecond], from: anchor) + let nowComponents = calendar.dateComponents([.year, .month], from: now) + + var startMonthComponents = nowComponents + var start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents) + if start > now { + guard let previous = calendar.date(byAdding: .month, value: -1, to: start) else { + let end = self.anchoredMonth( + calendar: calendar, + month: self.monthComponents(after: startMonthComponents, calendar: calendar), + anchor: anchorComponents) + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + startMonthComponents = calendar.dateComponents([.year, .month], from: previous) + start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents) + } + let end = self.anchoredMonth( + calendar: calendar, + month: self.monthComponents(after: startMonthComponents, calendar: calendar), + anchor: anchorComponents) + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + + private static func monthComponents(after month: DateComponents, calendar: Calendar) -> DateComponents { + let monthStart = calendar.date(from: month) ?? Date() + let nextMonth = calendar.date(byAdding: .month, value: 1, to: monthStart) ?? monthStart + return calendar.dateComponents([.year, .month], from: nextMonth) + } + + private static func anchoredMonth( + calendar: Calendar, + month: DateComponents, + anchor: DateComponents) -> Date + { + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = month.year + components.month = month.month + components.day = anchor.day + components.hour = anchor.hour + components.minute = anchor.minute + components.second = anchor.second + components.nanosecond = anchor.nanosecond + + if let date = calendar.date(from: components), + calendar.component(.month, from: date) == month.month + { + return date + } + + components.day = calendar.range(of: .day, in: .month, for: calendar.date(from: month) ?? Date())?.count + return calendar.date(from: components) ?? Date() + } +} + +#else + +public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable { + case notSupported + + public var errorDescription: String? { + "OpenCode Go local usage is only supported on macOS." + } +} + +public struct OpenCodeGoLocalUsageReader: Sendable { + public init(homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) {} + public init(authURL _: URL, databaseURL _: URL) {} + + public func fetch(now _: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + throw OpenCodeGoLocalUsageError.notSupported + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index c5ed2a77..51b2ac41 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -33,11 +33,84 @@ public enum OpenCodeGoProviderDescriptor { noDataMessage: { "OpenCode Go cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenCodeGoUsageFetchStrategy()] })), + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "opencodego", versionDetector: nil)) } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + if context.sourceMode == .web { + return [OpenCodeGoUsageFetchStrategy()] + } + return [ + OpenCodeGoUsageFetchStrategy(), + OpenCodeGoLocalUsageFetchStrategy(), + ] + } +} + +struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { + let id: String = "opencodego.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.snapshot(context: context) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "local") + } + + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + error is OpenCodeGoLocalUsageError + } + + private func snapshot(context: ProviderFetchContext) async throws -> OpenCodeGoUsageSnapshot { + let snapshot = try OpenCodeGoLocalUsageReader().fetch() + guard context.includeOptionalUsage, + context.settings?.opencodego?.cookieSource != .off + else { + return snapshot + } + + guard let cookieHeader = Self.cachedOrManualCookieHeader(context: context) else { + return snapshot + } + + let workspaceOverride = context.settings?.opencodego?.workspaceID + ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] + let zenBalanceTask = Task { + do { + return try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride) + } catch is CancellationError { + throw CancellationError() + } catch { + return nil + } + } + let zenBalance = try await OpenCodeGoUsageFetcher.completedOptionalZenBalance(from: zenBalanceTask) + return snapshot.withZenBalanceUSD(zenBalance) + } + + private static func cachedOrManualCookieHeader(context: ProviderFetchContext) -> String? { + if let settings = context.settings?.opencodego, settings.cookieSource == .manual { + return OpenCodeWebCookieSupport.requestCookieHeader(from: settings.manualCookieHeader) + } + + #if os(macOS) + guard let cached = CookieHeaderCache.load(provider: .opencodego) else { return nil } + return OpenCodeWebCookieSupport.requestCookieHeader(from: cached.cookieHeader) + #else + return nil + #endif + } } struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { @@ -81,11 +154,19 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { } } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + return switch error { + case OpenCodeGoSettingsError.missingCookie, + OpenCodeGoSettingsError.invalidCookie, + OpenCodeGoUsageError.invalidCredentials: + true + default: + false + } } - private static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { + static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { try OpenCodeWebCookieSupport.resolveCookieHeader( context: OpenCodeWebCookieSupport.Context( settings: context.settings?.opencodego, diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index 39b290f8..70e18ec0 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -151,6 +151,31 @@ public struct OpenCodeGoUsageFetcher: Sendable { return snapshot.withZenBalanceUSD(zenBalance) } + static func fetchOptionalZenBalance( + cookieHeader: String, + timeout: TimeInterval, + workspaceIDOverride: String? = nil, + session: URLSession? = nil) async throws -> Double? + { + let session = session ?? self.redirectGuardSession + guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { + throw OpenCodeGoUsageError.invalidCredentials + } + let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) { + override + } else { + try await self.fetchWorkspaceID( + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } + return try await self.fetchOptionalZenBalance( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: min(timeout, self.optionalZenBalanceTimeout), + session: session) + } + static func allowsRedirect(from sourceURL: URL?, to destinationURL: URL?) -> Bool { guard let sourceHost = sourceURL?.host?.lowercased(), let destinationHost = destinationURL?.host?.lowercased(), @@ -168,7 +193,9 @@ public struct OpenCodeGoUsageFetcher: Sendable { } return url } +} +extension OpenCodeGoUsageFetcher { private static func fetchWorkspaceID( cookieHeader: String, timeout: TimeInterval, diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift new file mode 100644 index 00000000..ca060b0a --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -0,0 +1,299 @@ +#if os(macOS) + +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +struct OpenCodeGoLocalUsageReaderTests { + @Test + func `reads local OpenCode Go history into usage windows`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-05T12:00:00.000Z"), + cost: 6.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-02-25T07:53:16.000Z"), + cost: 2.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 30) + #expect(snapshot.monthlyUsagePercent == 18.3) + #expect(snapshot.rollingResetInSec == 14400) + #expect(snapshot.weeklyResetInSec == 216_000) + #expect(snapshot.monthlyResetInSec == 1_626_796) + } + + @Test + func `auth without history falls through to web strategy`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.historyUnavailable("database not found")) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + @Test + func `auth with unreadable history falls through to web strategy`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + var db: OpaquePointer? + guard sqlite3_open(env.databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + sqlite3_close(db) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.self) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + @Test + func `monthly window keeps original anchor after shorter month clamp`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-01-31T00:00:00.000Z"), + cost: 1.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-29T10:00:00.000Z"), + cost: 6.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-29T12:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now) + + #expect(snapshot.monthlyUsagePercent == 10) + #expect(snapshot.monthlyResetInSec == 129_600) + } + + @Test + func `reads step finish parts when message only stores metadata`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: nil) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 10) + #expect(snapshot.monthlyUsagePercent == 5) + } + + @Test + func `does not double count step finish parts when message has cost`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 10) + #expect(snapshot.monthlyUsagePercent == 5) + } + + @Test + func `missing auth and history is not detected`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.notDetected) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + private static func makeEnvironment() throws -> (root: URL, authURL: URL, databaseURL: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodeGoLocalUsageReaderTests-\(UUID().uuidString)", isDirectory: true) + let directory = root + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return ( + root, + directory.appendingPathComponent("auth.json", isDirectory: false), + directory.appendingPathComponent("opencode.db", isDirectory: false)) + } + + private static func writeAuth(to url: URL) throws { + let data = Data(#"{"opencode-go":{"type":"api-key","key":"go-key"}}"#.utf8) + try data.write(to: url) + } + + private static func createDatabase(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try Self.exec( + db: db, + sql: """ + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + """) + } + + @discardableResult + private static func insertMessage(databaseURL: URL, createdMs: Int64, cost: Double?) throws -> String { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let messageID = UUID().uuidString + var payload: [String: Any] = [ + "providerID": "opencode-go", + "role": "assistant", + "time": ["created": createdMs], + ] + if let cost { + payload["cost"] = cost + } + let data = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: data, encoding: .utf8) ?? "{}" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, messageID, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, json, -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + return messageID + } + + private static func insertStepFinishPart( + databaseURL: URL, + messageID: String, + createdMs: Int64, + cost: Double) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let payload: [String: Any] = [ + "type": "step-finish", + "cost": cost, + "tokens": ["input": 1, "output": 1, "total": 2], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: data, encoding: .utf8) ?? "{}" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, messageID, -1, transient) + sqlite3_bind_text(stmt, 3, "session-1", -1, transient) + sqlite3_bind_text(stmt, 4, json, -1, transient) + sqlite3_bind_int64(stmt, 5, createdMs) + sqlite3_bind_int64(stmt, 6, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private static func ms(_ iso: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return Int64((formatter.date(from: iso)?.timeIntervalSince1970 ?? 0) * 1000) + } + + private enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} + +#endif diff --git a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift new file mode 100644 index 00000000..4c4d4d39 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoProviderStrategyTests { + 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)) + } + + @Test + func `auto source prefers web before local fallback`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext()) + + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + } + + @Test + func `web source does not include local fallback`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext(sourceMode: .web)) + + #expect(strategies.map(\.id) == ["opencodego.web"]) + } + + @Test + func `web strategy falls back to local only for auth setup failures in auto mode`() { + let strategy = OpenCodeGoUsageFetchStrategy() + let autoContext = self.makeContext() + let webContext = self.makeContext(sourceMode: .web) + + #expect(strategy.shouldFallback(on: OpenCodeGoSettingsError.missingCookie, context: autoContext)) + #expect(strategy.shouldFallback(on: OpenCodeGoSettingsError.invalidCookie, context: autoContext)) + #expect(strategy.shouldFallback(on: OpenCodeGoUsageError.invalidCredentials, context: autoContext)) + #expect(!strategy.shouldFallback(on: OpenCodeGoUsageError.networkError("timeout"), context: autoContext)) + #expect(!strategy.shouldFallback(on: OpenCodeGoSettingsError.missingCookie, context: webContext)) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift index 7929ae3b..131bfddd 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -271,6 +271,34 @@ struct OpenCodeGoUsageFetcherErrorTests { #expect(snapshot.toUsageSnapshot().providerCost?.period == "Zen balance") } + @Test + func `optional zen balance helper uses normalized cookie and workspace override`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + #expect(url.path == "/workspace/wrk_TEST123") + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + + let balance = try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "https://opencode.ai/workspace/wrk_TEST123/go", + session: self.makeSession()) + + #expect(balance == 98.76) + #expect(observedCookie == "auth=test") + } + @Test func `optional zen balance failure does not fail subscription usage`() async throws { defer { From 11f920652804747b68bae8ad54e553a826b1b460 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 23 May 2026 16:13:48 +0800 Subject: [PATCH 006/124] Claude: normalize OAuth extra usage spend limit from minor units (#1114) * Fix Claude OAuth extra usage spend normalization * docs: update changelog for Claude spend normalization --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../Providers/Claude/ClaudeUsageFetcher.swift | 2 +- Tests/CodexBarTests/ClaudeOAuthTests.swift | 47 ++++++++++--------- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 123293ee..23c181a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Display: add optional workday markers for weekly progress bars (#1102). Thanks @Yuxin-Qiao! ### Fixed +- Claude: normalize OAuth extra-usage spend limits from minor units so Enterprise spend displays as currency instead of 100x too high (#1114, fixes #1111). Thanks @Yuxin-Qiao! - OpenCode Go: read local usage history before falling back to browser-cookie dashboard fetches (#1021). Thanks @sopenlaz0! - Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! - Codex: run regular credits and OpenAI dashboard refreshes in the background while coalescing overlapping refresh work (#1078). Thanks @ptstory! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 7ce2f879..d74a6678 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -954,7 +954,7 @@ extension ClaudeUsageFetcher { let normalized = Self.normalizeClaudeExtraUsageAmounts( used: used, limit: limit, - treatAsMajorUnits: isSpendLimit) + treatAsMajorUnits: false) return ProviderCostSnapshot( used: normalized.used, limit: normalized.limit, diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index e180075d..6773f407 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -200,15 +200,15 @@ struct ClaudeOAuthTests { } @Test - func `maps enterprise O auth spend limit without session windows`() throws { + func `does not display spend limit 100x too high for enterprise O auth`() throws { let json = """ { "extra_usage": { "is_enabled": true, - "monthly_limit": 600, - "used_credits": 434.43, - "utilization": 72, - "currency": "USD" + "monthly_limit": 2000, + "used_credits": 763, + "utilization": 38.15, + "currency": "EUR" } } """ @@ -216,52 +216,55 @@ struct ClaudeOAuthTests { Data(json.utf8), subscriptionType: "enterprise") #expect(snap.loginMethod == "Claude Enterprise") - #expect(snap.primary.usedPercent == 72) + #expect(snap.primary.usedPercent == 38.15) #expect(snap.primaryWindowKind == .spendLimit) #expect(snap.primary.windowMinutes == nil) - #expect(snap.primary.resetDescription == "Spend limit: $434.43 / $600.00") + #expect(snap.primary.resetDescription == "Spend limit: €7.63 / €20.00") #expect(snap.secondary == nil) #expect(snap.providerCost?.period == "Spend limit") - #expect(snap.providerCost?.limit == 600) - #expect(snap.providerCost?.used == 434.43) + #expect(snap.providerCost?.currencyCode == "EUR") + #expect(snap.providerCost?.limit == 20) + #expect(snap.providerCost?.used == 7.63) let usage = ClaudeOAuthFetchStrategy._snapshotForTesting(from: snap) #expect(usage.primary == nil) #expect(usage.providerCost?.period == "Spend limit") - #expect(usage.providerCost?.used == 434.43) + #expect(usage.providerCost?.limit == 20) + #expect(usage.providerCost?.used == 7.63) } @Test - func `maps O auth spend limit without plan metadata as major units`() throws { + func `maps O auth spend limit without plan metadata from minor units`() throws { let json = """ { "extra_usage": { "is_enabled": true, - "monthly_limit": 600, - "used_credits": 434.43, - "utilization": 72, - "currency": "USD" + "monthly_limit": 2000, + "used_credits": 763, + "utilization": 38.15, + "currency": "EUR" } } """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) #expect(snap.loginMethod == nil) #expect(snap.primaryWindowKind == .spendLimit) - #expect(snap.primary.usedPercent == 72) - #expect(snap.primary.resetDescription == "Spend limit: $434.43 / $600.00") + #expect(snap.primary.usedPercent == 38.15) + #expect(snap.primary.resetDescription == "Spend limit: €7.63 / €20.00") #expect(snap.providerCost?.period == "Spend limit") - #expect(snap.providerCost?.limit == 600) - #expect(snap.providerCost?.used == 434.43) + #expect(snap.providerCost?.currencyCode == "EUR") + #expect(snap.providerCost?.limit == 20) + #expect(snap.providerCost?.used == 7.63) } @Test - func `maps large enterprise O auth spend limit as major units`() throws { + func `maps large enterprise O auth spend limit from minor units`() throws { let json = """ { "extra_usage": { "is_enabled": true, - "monthly_limit": 10000, - "used_credits": 1234.56, + "monthly_limit": 1000000, + "used_credits": 123456, "utilization": 12.3456, "currency": "USD" } From 2dc7aa09dd244c12f15d593242b9eded0ca410cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ray=20Garc=C3=ADa?= Date: Sat, 23 May 2026 10:14:37 +0200 Subject: [PATCH 007/124] Add Noctalia integration (#1115) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fb2ca81e..709f2266 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ CLI install: ## Linux desktop integration? - [codexbar-waybar](https://github.com/Marouan-chak/codexbar-waybar) — Waybar custom module + GTK4 popover for Hyprland / Sway / other Wayland compositors, built on top of the bundled Linux CLI. - [Codexbar GNOME](https://extensions.gnome.org/extension/9841/codexbar/) — GNOME Shell extension that brings CodexBar usage into the desktop panel. +- [noctalia-codex-usage](https://github.com/rayoplateado/noctalia-codex-usage) — Noctalia/Quickshell plugin that shows Codex 5-hour and weekly usage limits, built on top of the bundled Linux CLI. ## Credits From 2ff00f6d8e66ec5290fccc58a8abe65d795fecca Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 23 May 2026 16:59:42 +0800 Subject: [PATCH 008/124] Provider switcher: compact multi-row layout (#1113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Provider switcher: compact multi-row layout Fixes/follows up #1082. Summary: - Reduces vertical crowding in the merged provider switcher when many providers are enabled. - Adds compact row heights only for stacked multi-row layouts: - 3 rows: 40pt → 36pt - 4+ rows: 40pt → 34pt - Keeps single-row and two-row behavior unchanged. - Does not add NSScrollView. - Does not change hitTest, acceptsFirstMouse, mouseDown, or mouseUp. - Preserves provider ordering and click behavior. - UI-only: no provider fetching, billing, quota, parsing, ProviderCostSnapshot, or usage snapshot changes. Validation: - swift test --filter StatusMenuSwitcher - swift test --filter MenuCard - make check - git diff --check * Provider switcher: use safer compact row height * fix: cover compact provider switcher rows --------- Co-authored-by: Peter Steinberger --- .../StatusItemController+SwitcherViews.swift | 18 ++++---- .../StatusMenuSwitcherClickTests.swift | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 550dafe8..2db684d1 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -102,7 +102,7 @@ final class ProviderSwitcherView: NSView { maxAllowedSegmentWidth: initialMaxAllowedSegmentWidth, stackedIcons: self.stackedIcons) self.rowSpacing = self.stackedIcons ? 4 : 2 - self.rowHeight = Self.switcherRowHeight(stackedIcons: self.stackedIcons, rowCount: self.rowCount) + self.rowHeight = Self.switcherRowHeight(stackedIcons: self.stackedIcons) let height: CGFloat = self.rowHeight * CGFloat(self.rowCount) + self.rowSpacing * CGFloat(max(0, self.rowCount - 1)) self.preferredWidth = width @@ -547,12 +547,8 @@ final class ProviderSwitcherView: NSView { return rows } - private static func switcherRowHeight(stackedIcons: Bool, rowCount: Int) -> CGFloat { - let baseRowHeight: CGFloat = if stackedIcons, rowCount >= 3 { - 40 - } else { - stackedIcons ? 36 : 30 - } + private static func switcherRowHeight(stackedIcons: Bool) -> CGFloat { + let baseRowHeight: CGFloat = stackedIcons ? 36 : 30 return baseRowHeight + self.quotaIndicatorReservedHeight } @@ -665,6 +661,14 @@ final class ProviderSwitcherView: NSView { self.buttons.map(\.fittingSize) } + func _test_rowCount() -> Int { + self.rowCount + } + + func _test_rowHeight() -> CGFloat { + self.rowHeight + } + func _test_setHoveredButtonTag(_ tag: Int?) { self.hoveredButtonTag = tag self.updateButtonStyles() diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 5207d21c..793e1f33 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -458,4 +458,45 @@ struct StatusMenuSwitcherClickTests { isARepeat: false, keyCode: keyCode)) } + + @Test + func `multi-row switcher uses compact height and stays inside bounds`() { + // 14 providers + Overview forces the four-row path and includes multi-word titles. + let view = ProviderSwitcherView( + providers: [ + .codex, + .claude, + .cursor, + .factory, + .zai, + .minimax, + .alibaba, + .opencodego, + .grok, + .groq, + .gemini, + .openrouter, + .perplexity, + .kiro, + ], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + // All buttons must stay within switcher bounds (no vertical overflow). + for frame in view._test_buttonFrames() { + #expect(frame.minY >= 0) + #expect(frame.maxY <= view.bounds.maxY) + } + + #expect(view._test_rowCount() == 4) + #expect(view._test_rowHeight() == 44) + #expect(view.bounds.height == 188) + } } From e79badffd3e38d22322a5bfab0c0702c0aca69a8 Mon Sep 17 00:00:00 2001 From: kiankyars <69437137+kiankyars@users.noreply.github.com> Date: Sat, 23 May 2026 02:06:31 -0700 Subject: [PATCH 009/124] fix: use Groq logo for Groq provider icon (#1112) * fix: use Groq logo for Groq provider icon The Groq provider icon was a duplicate of the Grok (xAI) logo. Replace with the actual Groq 'Q' wordmark so the Settings page shows the correct brand. * test: cover distinct Groq provider icon --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Sources/CodexBar/Resources/ProviderIcon-groq.svg | 2 +- Tests/CodexBarTests/ProviderIconResourcesTests.swift | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c181a5..fee30a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Display: add optional workday markers for weekly progress bars (#1102). Thanks @Yuxin-Qiao! ### Fixed +- Groq: show a distinct Groq provider icon instead of reusing the Grok glyph (#1112). Thanks @kiankyars! - Claude: normalize OAuth extra-usage spend limits from minor units so Enterprise spend displays as currency instead of 100x too high (#1114, fixes #1111). Thanks @Yuxin-Qiao! - OpenCode Go: read local usage history before falling back to browser-cookie dashboard fetches (#1021). Thanks @sopenlaz0! - Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/Resources/ProviderIcon-groq.svg b/Sources/CodexBar/Resources/ProviderIcon-groq.svg index 876acc82..4b283c37 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-groq.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-groq.svg @@ -1,4 +1,4 @@ - + diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index de7989e3..53bcbf11 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -43,6 +43,16 @@ struct ProviderIconResourcesTests { } } + @Test + func `groq and grok provider icons are distinct`() throws { + let root = try Self.repoRoot() + let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) + let groq = try String(contentsOf: resources.appending(path: "ProviderIcon-groq.svg"), encoding: .utf8) + let grok = try String(contentsOf: resources.appending(path: "ProviderIcon-grok.svg"), encoding: .utf8) + + #expect(groq != grok) + } + private static func repoRoot() throws -> URL { var dir = URL(filePath: #filePath).deletingLastPathComponent() for _ in 0..<12 { From 94f831a20932011e9861e1e3f81202360ada8376 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 23 May 2026 10:48:08 +0100 Subject: [PATCH 010/124] Retry transient OpenAI usage failures (#1117) * fix: retry transient OpenAI usage failures * docs: add OpenAI retry PR reference --- CHANGELOG.md | 1 + Sources/CodexBarCore/ProviderHTTPClient.swift | 116 +++++++++++++++++- .../OpenAI/OpenAIAPIUsageFetcher.swift | 34 +++-- .../OpenAIAPICreditBalanceTests.swift | 83 +++++++++++++ .../ProviderHTTPClientTests.swift | 93 ++++++++++++++ 5 files changed, 314 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fee30a87..9d5e94b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Groq: show a distinct Groq provider icon instead of reusing the Grok glyph (#1112). Thanks @kiankyars! - Claude: normalize OAuth extra-usage spend limits from minor units so Enterprise spend displays as currency instead of 100x too high (#1114, fixes #1111). Thanks @Yuxin-Qiao! +- OpenAI: retry transient Admin API usage failures once before surfacing an access error (#1117). - OpenCode Go: read local usage history before falling back to browser-cookie dashboard fetches (#1021). Thanks @sopenlaz0! - Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! - Codex: run regular credits and OpenAI dashboard refreshes in the background while coalescing overlapping refresh work (#1078). Thanks @ptstory! diff --git a/Sources/CodexBarCore/ProviderHTTPClient.swift b/Sources/CodexBarCore/ProviderHTTPClient.swift index 1c8a3f85..f5ea38a4 100644 --- a/Sources/CodexBarCore/ProviderHTTPClient.swift +++ b/Sources/CodexBarCore/ProviderHTTPClient.swift @@ -35,6 +35,76 @@ public struct ProviderHTTPResponse: Sendable { } } +public struct ProviderHTTPRetryPolicy: Sendable { + public let maxRetries: Int + public let retryableStatusCodes: Set + public let retryableURLErrorCodes: Set + public let retryableMethods: Set + public let baseDelaySeconds: TimeInterval + public let maxDelaySeconds: TimeInterval + + public init( + maxRetries: Int, + retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504], + retryableURLErrorCodes: Set = [ + .timedOut, + .networkConnectionLost, + .cannotConnectToHost, + .cannotFindHost, + .dnsLookupFailed, + ], + retryableMethods: Set = ["GET", "HEAD", "OPTIONS"], + baseDelaySeconds: TimeInterval = 1, + maxDelaySeconds: TimeInterval = 10) + { + self.maxRetries = max(0, maxRetries) + self.retryableStatusCodes = retryableStatusCodes + self.retryableURLErrorCodes = retryableURLErrorCodes + self.retryableMethods = retryableMethods + self.baseDelaySeconds = max(0, baseDelaySeconds) + self.maxDelaySeconds = max(0, maxDelaySeconds) + } + + public static let disabled = ProviderHTTPRetryPolicy( + maxRetries: 0, + retryableStatusCodes: [], + retryableURLErrorCodes: [], + baseDelaySeconds: 0, + maxDelaySeconds: 0) + + public static let transientIdempotent = ProviderHTTPRetryPolicy(maxRetries: 1) + + func shouldRetry(request: URLRequest, attempt: Int, statusCode: Int) -> Bool { + self.canRetry(request: request, attempt: attempt) + && self.retryableStatusCodes.contains(statusCode) + } + + func shouldRetry(request: URLRequest, attempt: Int, error: Error) -> Bool { + guard self.canRetry(request: request, attempt: attempt) else { return false } + guard let urlError = error as? URLError else { return false } + return self.retryableURLErrorCodes.contains(urlError.code) + } + + func delaySeconds(attempt: Int, response: HTTPURLResponse?) -> TimeInterval { + if let retryAfter = response?.value(forHTTPHeaderField: "Retry-After"), + let seconds = TimeInterval(retryAfter.trimmingCharacters(in: .whitespacesAndNewlines)), + seconds >= 0 + { + return min(seconds, self.maxDelaySeconds) + } + + guard self.baseDelaySeconds > 0 else { return 0 } + let multiplier = pow(2, Double(max(0, attempt))) + return min(self.baseDelaySeconds * multiplier, self.maxDelaySeconds) + } + + private func canRetry(request: URLRequest, attempt: Int) -> Bool { + guard attempt < self.maxRetries else { return false } + let method = request.httpMethod?.uppercased() ?? "GET" + return self.retryableMethods.contains(method) + } +} + public struct ProviderHTTPTransportHandler: ProviderHTTPTransport { private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) @@ -49,11 +119,49 @@ public struct ProviderHTTPTransportHandler: ProviderHTTPTransport { extension ProviderHTTPTransport { public func response(for request: URLRequest) async throws -> ProviderHTTPResponse { - let (data, response) = try await self.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) + try await self.response(for: request, retryPolicy: .disabled) + } + + public func response( + for request: URLRequest, + retryPolicy: ProviderHTTPRetryPolicy) async throws -> ProviderHTTPResponse + { + var attempt = 0 + + while true { + do { + let (data, response) = try await self.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + let providerResponse = ProviderHTTPResponse(data: data, response: httpResponse) + guard retryPolicy.shouldRetry( + request: request, + attempt: attempt, + statusCode: providerResponse.statusCode) + else { + return providerResponse + } + try await Self.sleepBeforeRetry(policy: retryPolicy, attempt: attempt, response: httpResponse) + attempt += 1 + } catch { + guard retryPolicy.shouldRetry(request: request, attempt: attempt, error: error) else { + throw error + } + try await Self.sleepBeforeRetry(policy: retryPolicy, attempt: attempt, response: nil) + attempt += 1 + } } - return ProviderHTTPResponse(data: data, response: httpResponse) + } + + private static func sleepBeforeRetry( + policy: ProviderHTTPRetryPolicy, + attempt: Int, + response: HTTPURLResponse?) async throws + { + let delay = policy.delaySeconds(attempt: attempt, response: response) + guard delay > 0 else { return } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift index c9e05d65..34a77ebf 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift @@ -46,7 +46,8 @@ public enum OpenAIAPIUsageFetcher { completionsURL: URL = Self.organizationCompletionsUsageURL, session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: Date = Date(), - historyDays: Int = 30) async throws -> OpenAIAPIUsageSnapshot + historyDays: Int = 30, + retryPolicy: ProviderHTTPRetryPolicy = .transientIdempotent) async throws -> OpenAIAPIUsageSnapshot { let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -60,12 +61,14 @@ public enum OpenAIAPIUsageFetcher { apiKey: trimmed, baseURL: costsURL, ranges: ranges, - transport: transport) + transport: transport, + retryPolicy: retryPolicy) let completions = try await Self.fetchCompletions( apiKey: trimmed, baseURL: completionsURL, ranges: ranges, - transport: transport) + transport: transport, + retryPolicy: retryPolicy) return Self.makeSnapshot( costs: costs, @@ -96,7 +99,8 @@ public enum OpenAIAPIUsageFetcher { apiKey: String, baseURL: URL, ranges: [DateRange], - transport: any ProviderHTTPTransport) async throws -> CostsResponse + transport: any ProviderHTTPTransport, + retryPolicy: ProviderHTTPRetryPolicy) async throws -> CostsResponse { var buckets: [CostBucket] = [] for range in ranges { @@ -106,7 +110,12 @@ public enum OpenAIAPIUsageFetcher { queryItems: [ URLQueryItem(name: "group_by", value: "line_item"), ]) - let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "costs", transport: transport) + let data = try await Self.fetchData( + url: url, + apiKey: apiKey, + endpoint: "costs", + transport: transport, + retryPolicy: retryPolicy) try buckets.append(contentsOf: Self.decodeCosts(data).data) } return CostsResponse(data: buckets) @@ -116,7 +125,8 @@ public enum OpenAIAPIUsageFetcher { apiKey: String, baseURL: URL, ranges: [DateRange], - transport: any ProviderHTTPTransport) async throws -> CompletionsUsageResponse + transport: any ProviderHTTPTransport, + retryPolicy: ProviderHTTPRetryPolicy) async throws -> CompletionsUsageResponse { var buckets: [CompletionsUsageBucket] = [] for range in ranges { @@ -126,7 +136,12 @@ public enum OpenAIAPIUsageFetcher { queryItems: [ URLQueryItem(name: "group_by", value: "model"), ]) - let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "completions", transport: transport) + let data = try await Self.fetchData( + url: url, + apiKey: apiKey, + endpoint: "completions", + transport: transport, + retryPolicy: retryPolicy) try buckets.append(contentsOf: Self.decodeCompletions(data).data) } return CompletionsUsageResponse(data: buckets) @@ -136,7 +151,8 @@ public enum OpenAIAPIUsageFetcher { url: URL, apiKey: String, endpoint: String, - transport: any ProviderHTTPTransport) async throws -> Data + transport: any ProviderHTTPTransport, + retryPolicy: ProviderHTTPRetryPolicy) async throws -> Data { var request = URLRequest(url: url) request.httpMethod = "GET" @@ -146,7 +162,7 @@ public enum OpenAIAPIUsageFetcher { let response: ProviderHTTPResponse do { - response = try await transport.response(for: request) + response = try await transport.response(for: request, retryPolicy: retryPolicy) } catch { throw OpenAIAPIUsageError.networkError(error.localizedDescription) } diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift index 083ac122..e8d88e40 100644 --- a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -264,6 +264,49 @@ struct OpenAIAPICreditBalanceTests { #expect(limits.allSatisfy { $0 <= 31 }) } + @Test + func `admin usage retries transient completions failure once`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let completions = Data(""" + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 10, + "output_tokens": 5, + "num_model_requests": 1, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """.utf8) + let transport = OpenAIAdminUsageRetryScript(costs: emptyPage, completions: completions) + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1, + retryPolicy: ProviderHTTPRetryPolicy(maxRetries: 1, baseDelaySeconds: 0, maxDelaySeconds: 0)) + + #expect(snapshot.latestDay.totalTokens == 15) + #expect(snapshot.latestDay.requests == 1) + #expect(await transport.completionsRequestCount() == 2) + } + @Test func `maps admin usage to openai usage snapshot`() { let now = Date(timeIntervalSince1970: 1_700_179_200) @@ -319,3 +362,43 @@ struct OpenAIAPICreditBalanceTests { #expect(result.usage.providerCost?.limit == 100) } } + +private actor OpenAIAdminUsageRetryScript: ProviderHTTPTransport { + private let costs: Data + private let completions: Data + private var completionsRequests = 0 + + init(costs: Data, completions: Data) { + self.costs = costs + self.completions = completions + } + + func completionsRequestCount() -> Int { + self.completionsRequests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + if url.path.contains("/usage/completions") { + self.completionsRequests += 1 + if self.completionsRequests == 1 { + return (Data(), HTTPURLResponse( + url: url, + statusCode: 503, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + return (self.completions, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + + return (self.costs, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} diff --git a/Tests/CodexBarTests/ProviderHTTPClientTests.swift b/Tests/CodexBarTests/ProviderHTTPClientTests.swift index 40a794d7..c07b43e0 100644 --- a/Tests/CodexBarTests/ProviderHTTPClientTests.swift +++ b/Tests/CodexBarTests/ProviderHTTPClientTests.swift @@ -81,6 +81,99 @@ struct ProviderHTTPClientTests { _ = try await transport.response(for: request) } } + + @Test + func `response helper retries transient HTTP status once`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [503, 200]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/retry"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 200) + #expect(await script.requestCount() == 2) + } + + @Test + func `response helper retries transient URL error once`() async throws { + let script = ScriptedHTTPTransport(results: [ + .failure(URLError(.timedOut)), + .success(200), + ]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/retry-error"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 200) + #expect(await script.requestCount() == 2) + } + + @Test + func `response helper does not retry non idempotent methods`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [503, 200]) + var request = try URLRequest(url: #require(URL(string: "https://example.com/post"))) + request.httpMethod = "POST" + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 503) + #expect(await script.requestCount() == 1) + } + + @Test + func `response helper does not retry auth failures`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [403, 200]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/forbidden"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 403) + #expect(await script.requestCount() == 1) + } +} + +extension ProviderHTTPRetryPolicy { + fileprivate static let testOneRetry = ProviderHTTPRetryPolicy( + maxRetries: 1, + baseDelaySeconds: 0, + maxDelaySeconds: 0) +} + +private actor ScriptedHTTPTransport: ProviderHTTPTransport { + enum Result { + case success(Int) + case failure(URLError) + } + + private var results: [Result] + private var requests: [URLRequest] = [] + + init(statusCodes: [Int]) { + self.results = statusCodes.map(Result.success) + } + + init(results: [Result]) { + self.results = results + } + + func requestCount() -> Int { + self.requests.count + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.requests.append(request) + let next = self.results.isEmpty ? .success(200) : self.results.removeFirst() + switch next { + case let .success(statusCode): + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data(#"{"ok":true}"#.utf8), response) + case let .failure(error): + throw error + } + } } final class StubURLProtocol: URLProtocol { From 22cf7d4d2b7631022901a6e62fe39227b21610b8 Mon Sep 17 00:00:00 2001 From: Magicien <162632566+lederniermagicien@users.noreply.github.com> Date: Sat, 23 May 2026 21:11:30 +0100 Subject: [PATCH 011/124] fix: preserve menu bar item identity Preserve status item identity during display-change recovery and refresh displaced live items in place, including same-count display remaps. Co-authored-by: Magicien <162632566+lederniermagicien@users.noreply.github.com> --- CHANGELOG.md | 1 + .../CodexBar/MenuBarVisibilityWatcher.swift | 58 ++++++--- Sources/CodexBar/StatusItemController.swift | 51 +++++++- .../MenuBarVisibilityWatcherTests.swift | 116 +++++++++++++++--- ...tusItemControllerSplitLifecycleTests.swift | 61 +++++++++ 5 files changed, 251 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5e94b9..702fb064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Groq: show a distinct Groq provider icon instead of reusing the Grok glyph (#1112). Thanks @kiankyars! - Claude: normalize OAuth extra-usage spend limits from minor units so Enterprise spend displays as currency instead of 100x too high (#1114, fixes #1111). Thanks @Yuxin-Qiao! +- Menu bar: preserve status item identity during display-change recovery so menu bar managers do not treat CodexBar as a new hidden item (#1122, fixes #1109). Thanks @lederniermagicien! - OpenAI: retry transient Admin API usage failures once before surfacing an access error (#1117). - OpenCode Go: read local usage history before falling back to browser-cookie dashboard fetches (#1021). Thanks @sopenlaz0! - Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift index 174e87ea..50d1e685 100644 --- a/Sources/CodexBar/MenuBarVisibilityWatcher.swift +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -80,7 +80,16 @@ enum MenuBarVisibilityWatcher { static func isBlockedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool { guard snapshot.isVisible else { return false } guard snapshot.hasButton else { return true } - return !snapshot.hasWindow || !snapshot.hasScreen || !snapshot.isOnCurrentScreen || snapshot.buttonWidth <= 0 + // Menu bar managers can park status-item windows off the current screen while preserving the + // underlying NSStatusItem. Recreating in that state makes those managers see a new item. + return !snapshot.hasWindow || snapshot.buttonWidth <= 0 + } + + static func isDisplacedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool { + guard snapshot.isVisible, snapshot.hasButton, snapshot.hasWindow, snapshot.buttonWidth > 0 else { + return false + } + return !snapshot.hasScreen || !snapshot.isOnCurrentScreen } static func hasBlockedVisibleSnapshots(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool { @@ -97,6 +106,12 @@ enum MenuBarVisibilityWatcher { } } + static func hasAnyDisplacedVisibleSnapshot(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool { + snapshots.contains { snapshot in + self.isDisplacedSnapshot(snapshot: snapshot) + } + } + @MainActor static func visibilitySnapshots(_ items: [NSStatusItem]) -> [StatusItemVisibilitySnapshot] { items.map { item in @@ -119,19 +134,17 @@ enum MenuBarVisibilityWatcher { return self.hasAnyBlockedVisibleSnapshot(snapshots) } - static func shouldAttemptScreenChangeRecovery( - previousScreenCount: Int, - currentScreenCount: Int, + static func shouldRefreshScreenChangePlacement( + previousScreenCount _: Int, + currentScreenCount _: Int, snapshots: [StatusItemVisibilitySnapshot]) -> Bool { - if self.hasAnyBlockedVisibleSnapshot(snapshots) { - return true - } - guard currentScreenCount < previousScreenCount else { return false } - return snapshots.contains { snapshot in - snapshot.isVisible - } + self.hasAnyDisplacedVisibleSnapshot(snapshots) + } + + static func shouldAttemptScreenChangeRecovery(snapshots: [StatusItemVisibilitySnapshot]) -> Bool { + self.hasAnyBlockedVisibleSnapshot(snapshots) } static func shouldRetryScreenChangeRecovery( @@ -260,7 +273,21 @@ extension StatusItemController { let settledCurrentScreenCount = NSScreen.screens.count self.lastKnownScreenCount = settledCurrentScreenCount let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) - guard MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + if MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: snapshots) { + self.menuLogger.error( + "Display configuration changed; recreating status items", + metadata: [ + "previousScreenCount": "\(previousScreenCount)", + "currentScreenCount": "\(settledCurrentScreenCount)", + "capturedScreenCount": "\(currentScreenCount)", + "snapshots": snapshots.map(\.description).joined(separator: " | "), + ]) + self.recreateStatusItemsForVisibilityRecovery() + self.schedulePostScreenChangeRecoveryVerification(attempt: 1) + return + } + + guard MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( previousScreenCount: previousScreenCount, currentScreenCount: settledCurrentScreenCount, snapshots: snapshots) @@ -268,16 +295,15 @@ extension StatusItemController { return } - self.menuLogger.error( - "Display configuration changed; recreating status items", + self.menuLogger.info( + "Display configuration changed; refreshing existing status items", metadata: [ "previousScreenCount": "\(previousScreenCount)", "currentScreenCount": "\(settledCurrentScreenCount)", "capturedScreenCount": "\(currentScreenCount)", "snapshots": snapshots.map(\.description).joined(separator: " | "), ]) - self.recreateStatusItemsForVisibilityRecovery() - self.schedulePostScreenChangeRecoveryVerification(attempt: 1) + self.refreshExistingStatusItemsForVisibilityRecovery() } private func schedulePostScreenChangeRecoveryVerification(attempt: Int) { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 6f7d2bf9..5ddd472a 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -25,6 +25,23 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private static let defaultMenuRefreshEnabled = !SettingsStore.isRunningTests private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests static let quotaWarningFlashDuration: TimeInterval = 60 + private nonisolated static let statusItemAccessibilityTitle = "CodexBar" + private nonisolated static let statusItemAccessibilityIdentifierPrefix = "CodexBar.StatusItem" + + private enum StatusItemIdentity { + case merged + case provider(UsageProvider) + + var accessibilityIdentifier: String { + switch self { + case .merged: + StatusItemController.statusItemAccessibilityIdentifierPrefix + case let .provider(provider): + "\(StatusItemController.statusItemAccessibilityIdentifierPrefix).\(provider.rawValue)" + } + } + } + #if DEBUG static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { self.menuRefreshEnabled = enabled @@ -166,10 +183,15 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin set { self.settings.selectedMenuProvider = newValue } } - private static func makeStatusItem(statusBar: NSStatusBar) -> NSStatusItem { + private static func makeStatusItem(statusBar: NSStatusBar, identity: StatusItemIdentity) -> NSStatusItem { let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) - // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). - item.button?.imageScaling = .scaleNone + if let button = item.button { + // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). + button.imageScaling = .scaleNone + button.setAccessibilityIdentifier(identity.accessibilityIdentifier) + button.setAccessibilityTitle(self.statusItemAccessibilityTitle) + button.toolTip = self.statusItemAccessibilityTitle + } return item } @@ -280,7 +302,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed self.statusBar = statusBar - self.statusItem = Self.makeStatusItem(statusBar: statusBar) + self.statusItem = Self.makeStatusItem(statusBar: statusBar, identity: .merged) self.lastKnownScreenCount = NSScreen.screens.count // Status items for individual providers are now created lazily in updateVisibility() super.init() @@ -637,7 +659,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if let existing = self.statusItems[provider] { return existing } - let item = Self.makeStatusItem(statusBar: self.statusBar) + let item = Self.makeStatusItem(statusBar: self.statusBar, identity: .provider(provider)) self.statusItems[provider] = item return item } @@ -648,7 +670,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #endif self.statusItem.menu = nil self.statusBar.removeStatusItem(self.statusItem) - self.statusItem = Self.makeStatusItem(statusBar: self.statusBar) + self.statusItem = Self.makeStatusItem(statusBar: self.statusBar, identity: .merged) for provider in Array(self.statusItems.keys) { self.removeProviderStatusItem(for: provider) } @@ -859,3 +881,20 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin NotificationCenter.default.removeObserver(self) } } + +extension StatusItemController { + func refreshExistingStatusItemsForVisibilityRecovery() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + let visibleItems = ([self.statusItem] + Array(self.statusItems.values)).filter(\.isVisible) + for item in visibleItems { + item.isVisible = false + } + for item in visibleItems { + item.isVisible = true + } + self.updateVisibility() + self.updateIcons() + } +} diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index 032617f8..0ae320fd 100644 --- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -64,7 +64,7 @@ struct MenuBarVisibilityWatcherTests { } @Test - func `flags visible item attached to a detached screen`() { + func `allows visible item attached to a detached screen`() { let snapshot = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, @@ -73,7 +73,35 @@ struct MenuBarVisibilityWatcherTests { isOnCurrentScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `classifies detached live item as displaced but not blocked`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + #expect(MenuBarVisibilityWatcher.isDisplacedSnapshot(snapshot: snapshot)) + } + + @Test + func `classifies stale screen live item as displaced but not blocked`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + #expect(MenuBarVisibilityWatcher.isDisplacedSnapshot(snapshot: snapshot)) } @Test @@ -165,7 +193,7 @@ struct MenuBarVisibilityWatcherTests { } @Test - func `screen change recovery triggers when a display is removed with visible status item`() { + func `screen change placement refresh ignores display removal with healthy status item`() { let healthy = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, @@ -173,14 +201,14 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( previousScreenCount: 2, currentScreenCount: 1, snapshots: [healthy])) } @Test - func `screen change recovery ignores display removal when no status item is visible`() { + func `screen change placement refresh ignores display removal when no status item is visible`() { let hidden = StatusItemVisibilitySnapshot( isVisible: false, hasButton: true, @@ -188,7 +216,7 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( previousScreenCount: 2, currentScreenCount: 1, snapshots: [hidden])) @@ -203,14 +231,43 @@ struct MenuBarVisibilityWatcherTests { hasScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( - previousScreenCount: 1, + #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: [blocked])) + } + + @Test + func `screen change placement refresh triggers for detached live item after display removal`() { + let displaced = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, currentScreenCount: 1, - snapshots: [blocked])) + snapshots: [displaced])) } @Test - func `screen change recovery ignores healthy item when display count does not shrink`() { + func `screen change placement refresh triggers for stale screen live item after display removal`() { + let displaced = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 1, + snapshots: [displaced])) + } + + @Test + func `screen change placement refresh ignores healthy item when display count does not shrink`() { let healthy = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, @@ -218,14 +275,30 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( previousScreenCount: 1, currentScreenCount: 2, snapshots: [healthy])) } @Test - func `screen change retry continues while blocked before retry limit`() { + func `screen change placement refresh triggers for displaced live item when display count is unchanged`() { + let displaced = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 2, + snapshots: [displaced])) + } + + @Test + func `screen change retry ignores detached screen with live status item`() { let blocked = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, @@ -234,17 +307,32 @@ struct MenuBarVisibilityWatcherTests { isOnCurrentScreen: false, buttonWidth: 18) + #expect(!MenuBarVisibilityWatcher.shouldRetryScreenChangeRecovery( + attempt: MenuBarVisibilityWatcher.screenChangeRecoveryRetryLimit - 1, + snapshots: [blocked])) + } + + @Test + func `screen change retry continues for missing window before retry limit`() { + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + #expect(MenuBarVisibilityWatcher.shouldRetryScreenChangeRecovery( attempt: MenuBarVisibilityWatcher.screenChangeRecoveryRetryLimit - 1, snapshots: [blocked])) } @Test - func `screen change retry stops at retry limit`() { + func `screen change retry stops at retry limit for missing window`() { let blocked = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, - hasWindow: true, + hasWindow: false, hasScreen: false, isOnCurrentScreen: false, buttonWidth: 18) diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index bbfb8230..e252492a 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -97,6 +97,63 @@ struct StatusItemControllerSplitLifecycleTests { #expect(!self.containsHostingView(mergedButton)) } + @Test + func `status items publish stable non persistent manager identity`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let codexButton = try #require(controller.statusItems[.codex]?.button) + let claudeButton = try #require(controller.statusItems[.claude]?.button) + + #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(controller.statusItems[.codex]?.autosaveName.hasPrefix("CodexBar.") == false) + #expect(controller.statusItems[.claude]?.autosaveName.hasPrefix("CodexBar.") == false) + #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + #expect(codexButton.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + #expect(claudeButton.accessibilityIdentifier() == "CodexBar.StatusItem.claude") + #expect(controller.statusItem.button?.accessibilityTitle() == "CodexBar") + #expect(codexButton.accessibilityTitle() == "CodexBar") + #expect(claudeButton.accessibilityTitle() == "CodexBar") + } + + @Test + func `non destructive visibility refresh preserves split provider status items`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let oldCodexItem = try #require(controller.statusItems[.codex]) + let oldClaudeItem = try #require(controller.statusItems[.claude]) + let oldCodexButton = try #require(oldCodexItem.button) + + controller.refreshExistingStatusItemsForVisibilityRecovery() + + let newCodexItem = try #require(controller.statusItems[.codex]) + let newClaudeItem = try #require(controller.statusItems[.claude]) + #expect(newCodexItem === oldCodexItem) + #expect(newClaudeItem === oldClaudeItem) + #expect(newCodexItem.button === oldCodexButton) + #expect(!newCodexItem.autosaveName.hasPrefix("CodexBar.")) + #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + } + + @Test + func `non destructive visibility refresh preserves merged status item`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + let oldMergedItem = controller.statusItem + let oldMergedButton = try #require(controller.statusItem.button) + + controller.refreshExistingStatusItemsForVisibilityRecovery() + + #expect(controller.statusItem === oldMergedItem) + #expect(controller.statusItem.button === oldMergedButton) + #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + } + @Test func `visibility recovery recreates split provider status items`() throws { let (_, controller) = try self.makeSplitController() @@ -107,6 +164,8 @@ struct StatusItemControllerSplitLifecycleTests { let newCodexItem = try #require(controller.statusItems[.codex]) #expect(newCodexItem !== oldCodexItem) + #expect(!newCodexItem.autosaveName.hasPrefix("CodexBar.")) + #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") } @Test @@ -123,5 +182,7 @@ struct StatusItemControllerSplitLifecycleTests { let mergedButton = try #require(controller.statusItem.button) #expect(mergedButton.image != nil) + #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(mergedButton.accessibilityIdentifier() == "CodexBar.StatusItem") } } From 126dc923871530a217e0a6149351f400341af6e9 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 24 May 2026 07:26:08 +0800 Subject: [PATCH 012/124] fix: handle Claude CLI subscription usage output Fix Claude CLI subscription-only /usage handling. Claude CLI 2.1 can return only the subscription notice without quota data. Classify that as a precise no-quota parse failure, stop PTY collection on the notice, and fall back to direct CLI usage when the PTY panel times out or fails to load. Proof: - claude --version => 2.1.148 (Claude Code) - direct claude /usage returns the subscription-only notice - CodexBarCLI usage --provider claude --source cli reports the subscription-specific error - swift test --filter 'StatusProbeTests.*subscription|TTYIntegrationTests.*subscription|ClaudeDirectUsageFallbackTests|SubprocessRunnerTests' - make check - autoreview --mode local clean Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Host/Process/SubprocessRunner.swift | 5 +- .../Providers/Claude/ClaudeCLISession.swift | 7 +- .../Providers/Claude/ClaudeStatusProbe.swift | 28 ++++ .../Providers/Claude/ClaudeUsageFetcher.swift | 66 +++++++- .../ClaudeDirectUsageFallbackTests.swift | 152 ++++++++++++++++++ Tests/CodexBarTests/StatusProbeTests.swift | 71 ++++++++ Tests/CodexBarTests/TTYIntegrationTests.swift | 40 +++++ 8 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 702fb064..6bfd91cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Display: add optional workday markers for weekly progress bars (#1102). Thanks @Yuxin-Qiao! ### Fixed +- Claude: classify Claude CLI 2.1 subscription-only `/usage` output separately and fall back to direct CLI usage when the PTY panel fails to load (#1121, fixes #1116). Thanks @Yuxin-Qiao! - Groq: show a distinct Groq provider icon instead of reusing the Grok glyph (#1112). Thanks @kiankyars! - Claude: normalize OAuth extra-usage spend limits from minor units so Enterprise spend displays as currency instead of 100x too high (#1114, fixes #1111). Thanks @Yuxin-Qiao! - Menu bar: preserve status item identity during display-change recovery so menu bar managers do not treat CodexBar as a new hidden item (#1122, fixes #1109). Thanks @lederniermagicien! diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 4febdad9..b2278c25 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -158,6 +158,8 @@ public enum SubprocessRunner { arguments: [String], environment: [String: String], timeout: TimeInterval, + standardInput: Any? = nil, + currentDirectoryURL: URL? = nil, label: String) async throws -> SubprocessResult { guard FileManager.default.isExecutableFile(atPath: binary) else { @@ -174,12 +176,13 @@ public enum SubprocessRunner { process.executableURL = URL(fileURLWithPath: binary) process.arguments = arguments process.environment = environment + process.currentDirectoryURL = currentDirectoryURL let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe - process.standardInput = nil + process.standardInput = standardInput let termination = ProcessTermination() process.terminationHandler = { process in diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index 96ef72ee..93f2ddd8 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -300,8 +300,7 @@ actor ClaudeCLISession { let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() proc.currentDirectoryURL = workingDirectory - var env = TTYCommandRunner.enrichedEnvironment() - env = Self.scrubbedClaudeEnvironment(from: env) + var env = Self.launchEnvironment() env["PWD"] = workingDirectory.path proc.environment = env @@ -344,6 +343,10 @@ actor ClaudeCLISession { self.startedAt = Date() } + static func launchEnvironment(baseEnv: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] { + self.scrubbedClaudeEnvironment(from: TTYCommandRunner.enrichedEnvironment(baseEnv: baseEnv)) + } + private static func scrubbedClaudeEnvironment(from base: [String: String]) -> [String: String] { var env = base let explicitKeys: [String] = [ diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index ece1da18..333ec400 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -317,6 +317,7 @@ public struct ClaudeStatusProbe: Sendable { || normalized.contains("currentweek") || normalized.contains("loadingusage") || normalized.contains("failedtoloadusagedata") + || self.usageCaptureHasSubscriptionNotice(normalized) } private static func extractPercent(labelSubstrings: [String], context: LabelSearchContext) -> Int? { @@ -453,6 +454,9 @@ public struct ClaudeStatusProbe: Sendable { { return "Claude CLI usage endpoint is rate limited right now. Please try again later." } + if self.isSubscriptionNoticeOnly(text: text) { + return "Claude CLI /usage returned a subscription notice without session quota data." + } if lower.contains("failed to load usage data") { return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." } @@ -468,6 +472,24 @@ public struct ClaudeStatusProbe: Sendable { return !self.usageCaptureHasSessionValue(normalized) && self.allPercents(text).isEmpty } + /// Returns true when the text contains only a subscription notice with no session/weekly quota data. + /// CLI 2.1+ can return "You are currently using your subscription to power your Claude Code usage" + /// which lacks the "Current session" / "Current week" labels and percentage values needed for quota display. + /// A PTY capture may contain both an intermediate "Loading usage data…" panel and the final subscription + /// notice; `loadingusage` is not treated as quota data in this check so mixed captures surface correctly. + private static func isSubscriptionNoticeOnly(text: String) -> Bool { + let normalized = text.lowercased().filter { !$0.isWhitespace } + guard normalized.contains("currentlyusingyoursubscription") else { return false } + guard normalized.contains("claudecodeusage") else { return false } + // Only real session/week labels and actual percentage values count as quota data. + // `loadingusage` is not quota data — a mixed loading+subscription PTY capture should + // surface the subscription error, not a still-loading stall. + let hasQuotaData = normalized.contains("currentsession") || normalized.contains("currentweek") + || normalized.contains("%used") || normalized.contains("%left") || normalized.contains("%remaining") + || normalized.contains("%available") + return !hasQuotaData + } + /// Collect remaining percentages in the order they appear; used as a backup when labels move/rename. private static func allPercents(_ text: String) -> [Int] { let lines = text.components(separatedBy: .newlines) @@ -871,6 +893,7 @@ public struct ClaudeStatusProbe: Sendable { let stopWhenNormalized: (@Sendable (String) -> Bool)? = subcommand == "/usage" ? { @Sendable normalizedScan in Self.usageCaptureHasSessionValue(normalizedScan) + || Self.usageCaptureHasSubscriptionNotice(normalizedScan) } : nil do { @@ -902,4 +925,9 @@ public struct ClaudeStatusProbe: Sendable { let tail = normalizedText[labelRange.upperBound...] return tail.range(of: #"[0-9]{1,3}(?:\.[0-9]+)?%"#, options: .regularExpression) != nil } + + private static func usageCaptureHasSubscriptionNotice(_ normalizedText: String) -> Bool { + normalizedText.contains("currentlyusingyoursubscription") + && normalizedText.contains("claudecodeusage") + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index d74a6678..7eaac8a1 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -569,11 +569,50 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } private func loadViaCLI(model: String, timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { - var snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: timeout) + var snapshot: ClaudeUsageSnapshot + do { + snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: timeout) + } catch { + if error is CancellationError { throw error } + guard Self.shouldTryDirectCLIUsage(after: error) else { throw error } + let ptyError = error + do { + snapshot = try await self.fetcher.loadViaDirectCLI( + timeout: Self.directCLIUsageTimeout(for: timeout)) + } catch let directError { + if directError is CancellationError { throw directError } + guard Self.directCLIErrorShouldReplacePTYError(directError) else { throw ptyError } + throw directError + } + } snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) return snapshot } + private static func directCLIUsageTimeout(for ptyTimeout: TimeInterval) -> TimeInterval { + min(max(ptyTimeout / 3, 6), 8) + } + + private static func directCLIErrorShouldReplacePTYError(_ error: Error) -> Bool { + if case let ClaudeStatusProbeError.parseFailed(message) = error { + return message.lowercased().contains("subscription") + } + if case let ClaudeUsageError.parseFailed(message) = error { + return message.lowercased().contains("subscription") + } + return false + } + + private static func shouldTryDirectCLIUsage(after error: Error) -> Bool { + if case ClaudeStatusProbeError.timedOut = error { return true } + if case let ClaudeStatusProbeError.parseFailed(message) = error { + let lower = message.lowercased() + return lower.contains("still loading usage") || lower.contains("could not load usage data") + } + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || message.contains("timeout") + } + private static func shouldRetryCLIProbe(after error: Error) -> Bool { if case ClaudeStatusProbeError.timedOut = error { return true } if case let ClaudeStatusProbeError.parseFailed(message) = error { @@ -1114,6 +1153,31 @@ extension ClaudeUsageFetcher { keepCLISessionsAlive: self.keepCLISessionsAlive) let snap = try await probe.fetch() + return try Self.makeSnapshot(from: snap) + } + + private func loadViaDirectCLI(timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { + guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else { + throw ClaudeUsageError.claudeNotInstalled + } + + let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() + var environment = ClaudeCLISession.launchEnvironment(baseEnv: self.environment) + environment["PWD"] = workingDirectory.path + + let result = try await SubprocessRunner.run( + binary: claudeBinary, + arguments: ["/usage"], + environment: environment, + timeout: timeout, + standardInput: FileHandle.nullDevice, + currentDirectoryURL: workingDirectory, + label: "claude-direct-usage") + let snap = try ClaudeStatusProbe.parse(text: result.stdout) + return try Self.makeSnapshot(from: snap) + } + + private static func makeSnapshot(from snap: ClaudeStatusSnapshot) throws -> ClaudeUsageSnapshot { guard let sessionPctLeft = snap.sessionPercentLeft else { throw ClaudeUsageError.parseFailed("missing session data") } diff --git a/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift b/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift new file mode 100644 index 00000000..8b2375ac --- /dev/null +++ b/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeDirectUsageFallbackTests { + private final class InvocationLog: @unchecked Sendable { + private let url: URL + private let lock = NSLock() + + init(url: URL) { + self.url = url + } + + func contents() -> String { + self.lock.withLock { + (try? String(contentsOf: self.url, encoding: .utf8)) ?? "" + } + } + } + + @Test + func `cli source falls back to direct usage when pty usage fails to load`() async throws { + let cliLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-direct-fallback-log-\(UUID().uuidString).txt") + let log = InvocationLog(url: cliLogURL) + let fakeCLI = try Self.makeDirectFallbackClaudeCLI(logURL: cliLogURL) + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [ + "CLAUDE_CLI_PATH": fakeCLI.path, + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "ANTHROPIC_ADMIN_KEY": "admin-token", + ], + dataSource: .cli) + + try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) { + do { + _ = try await fetcher.loadLatestUsage(model: "sonnet") + #expect(Bool(false), "Subscription-only usage should fail parsing") + } catch let ClaudeUsageError.parseFailed(message) { + #expect(message.lowercased().contains("subscription")) + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("subscription")) + } + } + } + + let invocations = log.contents() + #expect(invocations.contains("pty-usage")) + #expect(invocations.contains("direct-usage")) + #expect(!invocations.contains("pty-secret-env")) + #expect(!invocations.contains("direct-secret-env")) + } + + @Test + func `direct usage timeout keeps original pty failure`() async throws { + let cliLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-direct-timeout-log-\(UUID().uuidString).txt") + let log = InvocationLog(url: cliLogURL) + let fakeCLI = try Self.makeDirectTimeoutClaudeCLI(logURL: cliLogURL) + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: ["CLAUDE_CLI_PATH": fakeCLI.path], + dataSource: .cli) + + await ClaudeCLISession.withIsolatedSessionForTesting { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) { + do { + _ = try await fetcher.loadLatestUsage(model: "sonnet") + #expect(Bool(false), "PTY failure should still surface") + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("could not load usage data")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + } + + let invocations = log.contents() + #expect(invocations.contains("pty-usage")) + #expect(invocations.contains("direct-usage")) + } + + private static func makeDirectFallbackClaudeCLI(logURL: URL) throws -> URL { + try self.makeClaudeCLI(name: "claude-direct-fallback", logURL: logURL, scriptBody: """ + if [ "$1" = "/usage" ]; then + printf 'direct-usage\\n' >> "$LOG_FILE" + if [ -n "$CODEXBAR_CLAUDE_OAUTH_TOKEN" ] || + [ -n "$CODEXBAR_CLAUDE_OAUTH_SCOPES" ] || + [ -n "$ANTHROPIC_ADMIN_KEY" ]; then + printf 'direct-secret-env\\n' >> "$LOG_FILE" + fi + printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage' + exit 0 + fi + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf 'pty-usage\\n' >> "$LOG_FILE" + if [ -n "$CODEXBAR_CLAUDE_OAUTH_TOKEN" ] || + [ -n "$CODEXBAR_CLAUDE_OAUTH_SCOPES" ] || + [ -n "$ANTHROPIC_ADMIN_KEY" ]; then + printf 'pty-secret-env\\n' >> "$LOG_FILE" + fi + printf '%s\\n' 'Failed to load usage data' + ;; + *"/status"*) + printf 'pty-status\\n' >> "$LOG_FILE" + printf '%s\\n' 'Account: subscription@example.com' + ;; + esac + done + """) + } + + private static func makeDirectTimeoutClaudeCLI(logURL: URL) throws -> URL { + try self.makeClaudeCLI(name: "claude-direct-timeout", logURL: logURL, scriptBody: """ + if [ "$1" = "/usage" ]; then + printf 'direct-usage\\n' >> "$LOG_FILE" + sleep 30 + exit 0 + fi + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf 'pty-usage\\n' >> "$LOG_FILE" + printf '%s\\n' 'Failed to load usage data' + ;; + esac + done + """) + } + + private static func makeClaudeCLI(name: String, logURL: URL, scriptBody: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("\(name)-\(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)' + \(scriptBody) + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: scriptURL.path) + return scriptURL + } +} diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index 8de79c18..dde1026a 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -520,6 +520,77 @@ struct StatusProbeTests { } } + @Test + func `surfaces claude subscription notice without quota data`() { + let sample = """ + You are currently using your subscription to power your Claude Code usage + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail for subscription notice without quota data") + } catch let ClaudeStatusProbeError.parseFailed(message) { + let lower = message.lowercased() + #expect(lower.contains("subscription")) + #expect(!lower.contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func `parse claude status subscription notice is distinct from loading stall`() { + let subscriptionOnly = "You are currently using your subscription to power your Claude Code usage" + let loadingOnly = """ + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + """ + + do { + _ = try ClaudeStatusProbe.parse(text: subscriptionOnly) + #expect(Bool(false), "Subscription notice should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(subMessage) { + #expect(!subMessage.lowercased().contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error for subscription: \(error)") + } + + do { + _ = try ClaudeStatusProbe.parse(text: loadingOnly) + #expect(Bool(false), "Loading panel should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(loadMessage) { + #expect(loadMessage.lowercased().contains("loading")) + } catch { + #expect(Bool(false), "Unexpected error for loading: \(error)") + } + } + + @Test + func `parse claude status mixed loading and subscription notice surfaces subscription error`() { + // PTY capture containing both an intermediate "Loading usage data…" panel and the final + // Claude CLI 2.1.148 subscription notice. The subscription error must be surfaced, not + // the still-loading stall, so the UI shows the precise subscription message. + let mixedCapture = """ + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + + You are currently using your subscription to power your Claude Code usage + """ + + do { + _ = try ClaudeStatusProbe.parse(text: mixedCapture) + #expect(Bool(false), "Parsing should fail for mixed loading+subscription capture") + } catch let ClaudeStatusProbeError.parseFailed(message) { + let lower = message.lowercased() + #expect(lower.contains("subscription")) + #expect(!lower.contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error for mixed capture: \(error)") + } + } + @Test func `parses claude reset time only`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index b11eb181..69709ae3 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -69,6 +69,23 @@ struct TTYIntegrationTests { #expect(snapshot.weeklyPercentLeft == 79) } + @Test + func `claude pty usage stops on subscription notice`() async throws { + let cli = try Self.makeSubscriptionNoticeClaudeCLI() + defer { Task { await ClaudeCLISession.shared.reset() } } + + do { + try await ClaudeCLISession.withIsolatedSessionForTesting { + _ = try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 3).fetch() + } + #expect(Bool(false), "Subscription notice should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("subscription")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + private static func makeSlowUsageClaudeCLI() throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) @@ -96,4 +113,27 @@ struct TTYIntegrationTests { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) return url } + + private static func makeSubscriptionNoticeClaudeCLI() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("claude") + let script = """ + #!/bin/sh + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage' + ;; + *"/status"*) + printf '%s\\n' 'Account: subscription@example.com' + ;; + esac + done + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } } From 3c0dbe96772a00df4cdbdc491f041f308df73bb7 Mon Sep 17 00:00:00 2001 From: IlyaL Date: Sun, 24 May 2026 08:36:25 +0800 Subject: [PATCH 013/124] feat: add Traditional Chinese localization Add zh-Hant localization resources, wire Traditional Chinese into the language picker, and credit the contributor in the changelog. Co-authored-by: IlyaL Co-authored-by: Claude Opus 4.7 --- CHANGELOG.md | 1 + Sources/CodexBar/PreferencesGeneralPane.swift | 2 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 625 ++++++++++++++++++ 8 files changed, 633 insertions(+) create mode 100644 Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfd91cb..2f74e8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Display: add optional workday markers for weekly progress bars (#1102). Thanks @Yuxin-Qiao! +- Localization: add Traditional Chinese (`zh-Hant`) app strings. Thanks @ilyaliao! ### Fixed - Claude: classify Claude CLI 2.1 subscription-only `/usage` output separately and fall back to direct CLI usage when the PTY panel fails to load (#1121, fixes #1116). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 58a6afc7..186e6f47 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -8,6 +8,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case spanish = "es" case catalan = "ca" case chineseSimplified = "zh-Hans" + case chineseTraditional = "zh-Hant" case portugueseBrazilian = "pt-BR" var id: String { @@ -21,6 +22,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .spanish: L("language_spanish") case .catalan: L("language_catalan") case .chineseSimplified: L("language_chinese_simplified") + case .chineseTraditional: L("language_chinese_traditional") case .portugueseBrazilian: L("language_portuguese_brazilian") } } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c0c64d9a..970c7764 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "Obrir en iniciar la sessió"; "start_at_login_subtitle" = "Obre el CodexBar automàticament en iniciar el Mac."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 11a1623c..19f3edb4 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "Start at Login"; "start_at_login_subtitle" = "Automatically opens CodexBar when you start your Mac."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 01f7c8cb..fa3fe614 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "Abrir al iniciar sesión"; "start_at_login_subtitle" = "Abre CodexBar automáticamente al iniciar tu Mac."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0b93ace5..099da41a 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -399,6 +399,7 @@ "language_spanish" = "Espanhol"; "language_catalan" = "Catalão"; "language_chinese_simplified" = "Chinês simplificado"; +"language_chinese_traditional" = "Chinês tradicional"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "Iniciar ao fazer login"; "start_at_login_subtitle" = "Abre o CodexBar automaticamente ao iniciar o Mac."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 7e3b6b66..f04a7919 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -405,6 +405,7 @@ "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "开机启动"; "start_at_login_subtitle" = "启动 Mac 时自动打开 CodexBar。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..98f0d7f1 --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,625 @@ +/* Chinese (Traditional) localization for CodexBar */ + +" providers" = " 供應商"; +"(System)" = "(System)"; +"30d" = "30 天"; +"A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; +"API key" = "API 金鑰"; +"API key limit" = "API 金鑰限制"; +"API region" = "API 區域"; +"API token" = "API token"; +"API tokens" = "API token"; +"About" = "關於"; +"Account" = "帳號"; +"Accounts" = "帳號"; +"Accounts subtitle" = "帳號副標題"; +"Active" = "活躍"; +"Add" = "新增"; +"Add Workspace" = "新增工作區"; +"Advanced" = "進階"; +"All" = "全部"; +"Always allow prompts" = "始終允許提示"; +"Animation pattern" = "動畫模式"; +"Antigravity login is managed in the app" = "Antigravity 登入由應用管理"; +"Applies only to the Security.framework OAuth keychain reader." = "僅適用於 Security.framework OAuth 鑰匙圈讀取器。"; +"Auth" = "認證"; +"Auto" = "自動"; +"Auto falls back to the next source if the preferred one fails." = "如果首選來源失敗,自動回退到下一個來源。"; +"Auto uses API first, then falls back to CLI on auth failures." = "自動優先使用 API,認證失敗時回退到 CLI。"; +"Auto-detect" = "自動檢測"; +"Auto-refresh is off; use the menu's Refresh command." = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; +"Auto-refresh: hourly · Timeout: 10m" = "自動重新整理:每小時 · 超時:10 分鐘"; +"Automatic" = "自動"; +"Automatic imports browser cookies and WorkOS tokens." = "自動匯入瀏覽器 Cookie 和 WorkOS token。"; +"Automatic imports browser cookies and local storage tokens." = "自動匯入瀏覽器 Cookie 和本地存儲 token。"; +"Automatic imports browser cookies for dashboard extras." = "自動匯入用於儀表板附加功能的瀏覽器 Cookie。"; +"Automatic imports browser cookies for the web API." = "自動匯入用於 Web API 的瀏覽器 Cookie。"; +"Automatic imports browser cookies from Model Studio/Bailian." = "自動從 Model Studio/Bailian 匯入瀏覽器 Cookie。"; +"Automatic imports browser cookies from admin.mistral.ai." = "自動從 admin.mistral.ai 匯入瀏覽器 Cookie。"; +"Automatic imports browser cookies from opencode.ai." = "自動從 opencode.ai 匯入瀏覽器 Cookie。"; +"Automatic imports browser cookies or stored sessions." = "自動匯入瀏覽器 Cookie 或已存儲的會話。"; +"Automatic imports browser cookies." = "自動匯入瀏覽器 Cookie。"; +"Automatically imports browser session cookie." = "自動匯入瀏覽器會話 Cookie。"; +"Automatically opens CodexBar when you start your Mac." = "啟動 Mac 時自動開啟 CodexBar。"; +"Automation" = "自動化"; +"Average (\\(label1) + \\(label2))" = "平均(\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "平均(\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "避免鑰匙圈提示"; +"Balance" = "餘額"; +"Battery Saver" = "省電模式"; +"Bordered" = "帶邊框"; +"Build" = "建置"; +"Built \\(buildTimestamp)" = "建置於 \\(buildTimestamp)"; +"Buy Credits..." = "購買額度..."; +"Buy Credits…" = "購買額度…"; +"CLI paths" = "CLI 路徑"; +"CLI sessions" = "CLI 會話"; +"Caches" = "快取"; +"Cancel" = "取消"; +"Check for Updates…" = "檢查更新…"; +"Check for updates automatically" = "自動檢查更新"; +"Check if you like your agents having some fun up there." = "看看你是否喜歡你的 Agent 在上面找點樂子。"; +"Check provider status" = "檢查供應商狀態"; +"Choose Codex workspace" = "選擇 Codex 工作區"; +"Choose the MiniMax host (global .io or China mainland .com)." = "選擇 MiniMax 主機(全球 .io 或中國大陸 .com)。"; +"Choose up to " = "選擇最多 "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "選擇最多 \\(Self.maxOverviewProviders) 個供應商"; +"Choose up to \\(count) providers" = "選擇最多 \\(count) 個供應商"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列中顯示的內容(進度會顯示實際用量與預期的對比)。"; +"Choose which Codex account CodexBar should follow." = "選擇 CodexBar 要跟隨的 Codex 帳號。"; +"Choose which window drives the menu bar percent." = "選擇用於驅動選單列百分比的時段。"; +"Chrome" = "Chrome"; +"Claude CLI not found" = "找不到 Claude CLI"; +"Claude binary" = "Claude 二進位檔案"; +"Claude cookies" = "Claude Cookie"; +"Claude login failed" = "Claude 登入失敗"; +"Claude login timed out" = "Claude 登入超時"; +"Close" = "關閉"; +"Code review" = "代碼審查"; +"Codex CLI not found" = "找不到 Codex CLI"; +"Codex account login already running" = "Codex 帳號登入已在執行"; +"Codex binary" = "Codex 二進位檔案"; +"Codex login failed" = "Codex 登入失敗"; +"Codex login timed out" = "Codex 登入超時"; +"CodexBar Lifecycle Keepalive" = "CodexBar 生命週期保活"; +"CodexBar could not read managed account storage. " = "CodexBar 無法讀取託管帳號存儲。"; +"Configure…" = "設定…"; +"Connected" = "已連接"; +"Controls how much detail is logged." = "控制日誌記錄的詳細程度。"; +"Cookie header" = "Cookie 標頭"; +"Cookie source" = "Cookie 來源"; +"Cookie: ..." = "Cookie:..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie:\\u{2026}\\\n\\\n或貼上來自 Abacus AI 儀表板的 cURL 捕獲內容"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie:\\u{2026}\\\n\\\n或貼上 __Secure-next-auth.session-token 的值"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie:\\u{2026}\\\n\\\n或貼上 kimi-auth token 值"; +"Cookie: …" = "Cookie:…"; +"CopilotDeviceFlow" = "Copilot 設備流程"; +"Cost" = "費用"; +"Could not add Codex account" = "無法新增 Codex 帳號"; +"Could not open Terminal for Gemini" = "無法為 Gemini 開啟終端"; +"Could not start claude /login" = "無法啟動 claude /login"; +"Could not start codex login" = "無法啟動 codex login"; +"Could not switch system account" = "無法切換系統帳號"; +"Credits" = "額度"; +"Credits history" = "額度歷史"; +"Cursor login failed" = "Cursor 登入失敗"; +"Custom" = "自定義"; +"Custom Path" = "自定義路徑"; +"Daily Routines" = "日常例程"; +"Debug" = "除錯"; +"Default" = "預設"; +"Designs" = "設計"; +"Disable Keychain access" = "禁用鑰匙圈存取"; +"Disabled" = "已禁用"; +"Disabled — no recent data" = "已禁用 — 無近期資料"; +"Disconnected" = "已斷開連接"; +"Display" = "顯示"; +"Display mode" = "顯示模式"; +"Display reset times as absolute clock values instead of countdowns." = "將重置時間顯示為絕對時鐘值,而不是倒計時。"; +"Done" = "完成"; +"Effective PATH" = "有效 PATH"; +"Email" = "電子郵件"; +"Enable Merge Icons to configure Overview tab providers." = "啟用「合併圖示」以設定「概覽」標籤中的供應商。"; +"Enable file logging" = "啟用檔案日誌"; +"Enabled" = "已啟用"; +"Error" = "錯誤"; +"Error simulation" = "錯誤模擬"; +"Expose troubleshooting tools in the Debug tab." = "在「除錯」標籤中顯示故障排除工具。"; +"Failed" = "失敗"; +"False" = "假"; +"Fetch strategy attempts" = "獲取策略嘗試"; +"Fetching" = "獲取中"; +"Field" = "欄位"; +"Field subtitle" = "欄位副標題"; +"Finish the current managed account change before switching the system account." = "請先完成目前託管帳號變更,再切換系統帳號。"; +"Force animation on next refresh" = "下次重新整理時強制動畫"; +"Gateway region" = "網關區域"; +"Gemini CLI not found" = "找不到 Gemini CLI"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity,並在圖示和選單中顯示故障事件。"; +"General" = "一般"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot 登入"; +"GitHub Login" = "GitHub 登入"; +"Hide details" = "隱藏詳情"; +"Hide personal information" = "隱藏個人資訊"; +"Historical tracking" = "歷史追蹤"; +"How often CodexBar polls providers in the background." = "CodexBar 在背景輪詢供應商的頻率。"; +"Inactive" = "非活躍"; +"Install CLI" = "安裝 CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "安裝 Claude CLI(npm i -g @anthropic-ai/claude-code)後重試。"; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "安裝 Codex CLI(npm i -g @openai/codex)後重試。"; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "安裝 Gemini CLI(npm i -g @google/gemini-cli)後重試。"; +"JetBrains AI is ready" = "JetBrains AI 已就緒"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "保持 CLI 會話存活"; +"Keyboard shortcut" = "快捷鍵"; +"Keychain access" = "鑰匙圈存取"; +"Keychain prompt policy" = "鑰匙圈提示策略"; +"Last \\(name) fetch failed:" = "上次獲取 \\(name) 失敗:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "上次獲取 \\(self.store.metadata(for: self.provider).displayName) 失敗:"; +"Last attempt" = "上次嘗試"; +"Limits not available" = "限制不可用"; +"Link" = "連結"; +"Loading animations" = "載入動畫"; +"Loading…" = "載入中…"; +"Local" = "本地"; +"Logging" = "日誌"; +"Login failed" = "登入失敗"; +"Login shell PATH (startup capture)" = "登入 shell PATH(啟動時捕獲)"; +"Login timed out" = "登入超時"; +"MCP details" = "MCP 詳情"; +"Managed Codex accounts unavailable" = "託管 Codex 帳號不可用"; +"Managed account storage is unreadable. Live account access is still available, " = "託管帳號存儲不可讀。實時帳號存取仍可用,"; +"Manual" = "手動"; +"May your tokens never run out—keep agent limits in view." = "願你的 token 永不耗盡,隨時關注 Agent 限制。"; +"Menu bar" = "選單列"; +"Menu bar auto-shows the provider closest to its rate limit." = "選單列會自動顯示最接近速率限制的供應商。"; +"Menu bar metric" = "選單列指標"; +"Menu bar shows percent" = "選單列顯示百分比"; +"Menu content" = "選單內容"; +"Merge Icons" = "合併圖示"; +"Never prompt" = "從不提示"; +"No" = "否"; +"No Codex accounts detected yet." = "未檢測到 Codex 帳號。"; +"No JetBrains IDE detected" = "未檢測到 JetBrains IDE"; +"No cost history data." = "尚無費用歷史資料。"; +"No usage yet" = "尚無用量"; +"Not fetched yet" = "尚未獲取"; +"No credits history data." = "尚無額度歷史資料。"; +"No data available" = "無可用資料"; +"No data yet" = "尚無資料"; +"No enabled providers available for Overview." = "「概覽」中沒有可用的已啟用供應商。"; +"No providers selected" = "未選擇供應商"; +"No token accounts yet." = "尚無 token 帳號。"; +"No usage breakdown data." = "尚無用量明細資料。"; +"None" = "無"; +"Notifications" = "通知"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "當 5 小時會話配額降至 0% 以及重新"; +"OK" = "好"; +"Obscure email addresses in the menu bar and menu UI." = "在選單列和選單介面中隱藏電子郵件地址。"; +"Off" = "關閉"; +"Offline" = "離線"; +"On" = "開啟"; +"Online" = "線上"; +"Only on user action" = "僅在使用者操作時"; +"Open" = "開啟"; +"Open API Keys" = "開啟 API 金鑰"; +"Open Amp Settings" = "開啟 Amp 設定"; +"Open Antigravity to sign in, then refresh CodexBar." = "開啟 Antigravity 登入,然後重新整理 CodexBar。"; +"Open Browser" = "開啟瀏覽器"; +"Open Coding Plan" = "開啟 Coding Plan"; +"Open Console" = "開啟控制台"; +"Open Dashboard" = "開啟儀表板"; +"Open Mistral Admin" = "開啟 Mistral 管理背景"; +"Open Ollama Settings" = "開啟 Ollama 設定"; +"Open Terminal" = "開啟終端"; +"Open Usage Page" = "開啟用量頁面"; +"Open Warp API Key Guide" = "開啟 Warp API 金鑰指南"; +"Open menu" = "開啟選單"; +"Open token file" = "開啟 token 檔案"; +"OpenAI cookies" = "OpenAI Cookie"; +"OpenAI web extras" = "OpenAI Web 附加功能"; +"Option A" = "選項 A"; +"Option B" = "選項 B"; +"Optional override if workspace lookup fails." = "工作區尋找失敗時可選的覆蓋項。"; +"Options" = "選項"; +"Override auto-detection with a custom IDE base path" = "使用自定義 IDE 基礎路徑覆蓋自動檢測"; +"Overview" = "概覽"; +"Overview rows always follow provider order." = "概覽行始終遵循供應商順序。"; +"Overview tab providers" = "概覽標籤供應商"; +"Paste API key…" = "貼上 API 金鑰…"; +"Paste API token…" = "貼上 API token…"; +"Paste key…" = "貼上金鑰…"; +"Paste sessionKey or OAuth token…" = "貼上 sessionKey 或 OAuth token…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "貼上發往 admin.mistral.ai 請求中的 Cookie 標頭。"; +"Paste token…" = "貼上 token…"; +"Personal" = "個人"; +"Picker" = "選擇器"; +"Picker subtitle" = "選擇器副標題"; +"Placeholder" = "占位符"; +"Plan" = "方案"; +"Play full-screen confetti when weekly usage resets." = "當每週用量重置時播放全螢幕彩紙。"; +"Polls OpenAI/Claude status pages and Google Workspace for " = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace,以檢查"; +"Prevents any Keychain access while enabled." = "啟用時封鎖任何鑰匙圈存取。"; +"Primary (API key limit)" = "主要(API 金鑰限制)"; +"Primary (\\(label))" = "主要(\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "主要(\\(metadata.sessionLabel))"; +"Probe logs" = "探測日誌"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "進度條會隨配額消耗而填充(而不是顯示剩餘量)。"; +"Provider" = "供應商"; +"Providers" = "供應商"; +"Quit CodexBar" = "結束 CodexBar"; +"Random (default)" = "隨機(預設)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "讀取本地用量日誌。在選單中顯示今天及所選歷史時段的費用。"; +"Refresh" = "重新整理"; +"Refreshing" = "正在重新整理"; +"Refresh cadence" = "重新整理頻率"; +"Remote" = "遠程"; +"Remove" = "移除"; +"Remove Codex account?" = "移除 Codex 帳號?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "要從 CodexBar 中移除 \\(account.email) 嗎?其託管的 Codex 主目錄將被刪除。"; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "要從 CodexBar 中移除 \\(email) 嗎?其託管的 Codex 主目錄將被刪除。"; +"Remove selected account" = "移除所選帳號"; +"Replace critter bars with provider branding icons and a percentage." = "將小動物進度條替換為供應商品牌圖示和百分比。"; +"Replay selected animation" = "重放選中的動畫"; +"Requires authentication via GitHub Device Flow." = "需要通過 GitHub 設備流程進行認證。"; +"Resets: \\(reset)" = "重置:\\(reset)"; +"Rolling five-hour limit" = "滾動 5 小時限制"; +"Search hourly" = "每小時搜索"; +"Secondary (\\(label))" = "次要(\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "次要(\\(metadata.weeklyLabel))"; +"Select a provider" = "選擇一個供應商"; +"Select the IDE to monitor" = "選擇要監控的 IDE"; +"Session" = "會話"; +"Session quota notifications" = "會話配額通知"; +"Session tokens" = "會話 token"; +"Settings" = "設定"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "在選單中顯示 Codex 額度和 Claude 額外用量部分。"; +"Show Debug Settings" = "顯示除錯設定"; +"Show all token accounts" = "顯示所有 token 帳號"; +"Show cost summary" = "顯示費用摘要"; +"Show credits + extra usage" = "顯示額度 + 額外用量"; +"Show details" = "顯示詳情"; +"Show most-used provider" = "顯示用量最高的供應商"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切換器中顯示供應商圖示(否則顯示每週進度線)。"; +"Show reset time as clock" = "將重置時間顯示為時鐘"; +"Show usage as used" = "顯示已使用用量"; +"Sign in via button below" = "通過下方按鈕登入"; +"Skip teardown between probes (debug-only)." = "探測之間跳過清理(僅限除錯)。"; +"Source" = "來源"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; +"Start at Login" = "開機啟動"; +"State" = "狀態"; +"Status" = "狀態"; +"Store Claude sessionKey cookies or OAuth access tokens." = "存儲 Claude sessionKey Cookie 或 OAuth 存取 token。"; +"Store multiple Abacus AI Cookie headers." = "存儲多個 Abacus AI Cookie 標頭。"; +"Store multiple Augment Cookie headers." = "存儲多個 Augment Cookie 標頭。"; +"Store multiple Cursor Cookie headers." = "存儲多個 Cursor Cookie 標頭。"; +"Store multiple Factory Cookie headers." = "存儲多個 Factory Cookie 標頭。"; +"Store multiple MiniMax Cookie headers." = "存儲多個 MiniMax Cookie 標頭。"; +"Store multiple Mistral Cookie headers." = "存儲多個 Mistral Cookie 標頭。"; +"Store multiple Ollama Cookie headers." = "存儲多個 Ollama Cookie 標頭。"; +"Store multiple OpenCode Cookie headers." = "存儲多個 OpenCode Cookie 標頭。"; +"Store multiple OpenCode Go Cookie headers." = "存儲多個 OpenCode Go Cookie 標頭。"; +"Stored in the CodexBar config file." = "存儲在 CodexBar 設定檔中。"; +"Stored in ~/.codexbar/config.json. " = "存儲在 ~/.codexbar/config.json 中。"; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "存儲在 ~/.codexbar/config.json 中。可在 kimi-k2.ai 生成。"; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "存儲在 ~/.codexbar/config.json 中。請貼上來自 Synthetic 儀表板的金鑰。"; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "存儲在 ~/.codexbar/config.json 中。請貼上來自 Model Studio 的 Coding Plan API 金鑰。"; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "存儲在 ~/.codexbar/config.json 中。請貼上你的 MiniMax API 金鑰。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "存儲在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "存儲本地 Codex 用量歷史(8 週),用於個性化進度預測。"; +"Subscription Utilization" = "訂閱使用率"; +"Surprise me" = "給我驚喜"; +"Switcher shows icons" = "切換器顯示圖示"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "將 CodexBarCLI 作為 codexbar 符號連結到 /usr/local/bin 和 /opt/homebrew/bin。"; +"System" = "系統"; +"Temporarily shows the loading animation after the next refresh." = "下次重新整理後暫時顯示載入動畫。"; +"Tertiary (\\(label))" = "第三(\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "第三(\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "此 Mac 上的預設 Codex 帳號。"; +"Toggle" = "切換"; +"Toggle subtitle" = "切換副標題"; +"Token" = "token"; +"Trigger the menu bar menu from anywhere." = "從任意位置觸發選單列選單。"; +"True" = "真"; +"Twitter" = "Twitter"; +"Unsupported" = "不支持"; +"Unavailable" = "不可用"; +"Update Channel" = "更新頻道"; +"Updated" = "已更新"; +"Updates unavailable in this build." = "此建置中更新不可用。"; +"Usage" = "用量"; +"Usage breakdown" = "用量明細"; +"Usage history (30 days)" = "用量歷史"; +"Usage source" = "用量來源"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中國大陸端點使用 BigModel(open.bigmodel.cn)。"; +"Use a single menu bar icon with a provider switcher." = "使用單個選單列圖示並帶供應商切換器。"; +"Use international or China mainland console gateways for quota fetches." = "使用國際或中國大陸控制台網關獲取配額。"; +"Version" = "版本"; +"Version \\(self.versionString)" = "版本 \\(self.versionString)"; +"Version \\(version)" = "版本 \\(version)"; +"Version \\(versionString)" = "版本 \\(versionString)"; +"Vertex AI Login" = "Vertex AI 登入"; +"Wait for the current managed Codex login to finish before adding another account." = "請等待目前託管 Codex 登入完成後再新增其他帳號。"; +"Waiting for Authentication..." = "等待認證…"; +"Website" = "網站"; +"Weekly" = "每週"; +"Weekly limit confetti" = "每週限制彩紙"; +"Weekly token limit" = "每週 token 限制"; +"Weekly usage" = "每週用量"; +"Weekly usage unavailable for this account." = "此帳號的每週用量不可用。"; +"Window: \\(window)" = "時段:\\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "將日誌寫入 \\(self.fileLogPath) 以進行除錯。"; +"Yes" = "是"; +"not detected" = "未檢測到"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode):\\(usage)"; +"\\(name): \\(truncated)" = "\\(name):\\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name):\\(updated) · 30 天 \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name):獲取中…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name):上次嘗試 \\(when)"; +"\\(name): no data yet" = "\\(name):尚無資料"; +"\\(name): unsupported" = "\\(name):不支持"; +"all browsers" = "所有瀏覽器"; +"available again." = "可用時發送通知。"; +"built_format" = "建置於 %@"; +"copilot_complete_in_browser" = "請在瀏覽器中完成登入。"; +"copilot_device_code_copied" = "設備代碼已複製。"; +"copilot_verify_at" = "請在 %@ 驗證"; +"copilot_window_closes_auto" = "登入完成後,此視窗會自動關閉。"; +"cost_status_error" = "%1$@:%2$@"; +"cost_status_fetching" = "%1$@:獲取中… %2$@"; +"cost_status_last_attempt" = "%1$@:上次嘗試 %2$@"; +"cost_status_no_data" = "%@:尚無資料"; +"cost_status_snapshot" = "%1$@:%2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@:不支持"; +"credits_remaining" = "額度:%@"; +"cursor_on_demand" = "按需計費:%@"; +"cursor_on_demand_with_limit" = "按需計費:%1$@ / %2$@"; +"extra_usage_format" = "額外用量:%1$@ / %2$@"; +"jetbrains_detected_generate" = "檢測到:%@。使用一次 AI 助手以生成配額資料,然後重新整理 CodexBar。"; +"jetbrains_detected_select" = "檢測到:%@。在設定中選擇你偏好的 IDE,然後重新整理 CodexBar。"; +"last_fetch_failed_with_provider" = "上次獲取 %@ 失敗:"; +"last_spend" = "上次支出:%@"; +"mcp_model_usage" = "%1$@:%2$@"; +"mcp_resets" = "重置:%@"; +"mcp_window" = "時段:%@"; +"metric_average" = "平均(%1$@ + %2$@)"; +"metric_primary" = "主要(%@)"; +"metric_secondary" = "次要(%@)"; +"metric_tertiary" = "第三(%@)"; +"multiple_workspaces_found" = "CodexBar 發現 %@ 有多個工作區。請選擇要新增的工作區。"; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "最多選擇 %@ 個供應商"; +"remove_account_message" = "要從 CodexBar 中移除 %@ 嗎?其託管的 Codex 主目錄將被刪除。"; +"version_format" = "版本 %@"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "已設定 workspaceID,但只有 opencode、opencodego 和 deepgram 支持 workspaceID。"; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger。MIT 許可證。"; +"section_system" = "系統"; +"section_usage" = "用量"; +"section_automation" = "自動化"; +"language_title" = "語言"; +"language_subtitle" = "更改顯示語言。需要重啟應用才能完全生效。"; +"language_system" = "跟隨系統"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"start_at_login_title" = "開機啟動"; +"start_at_login_subtitle" = "啟動 Mac 時自動開啟 CodexBar。"; +"show_cost_summary" = "顯示費用摘要"; +"show_cost_summary_subtitle" = "讀取本地用量日誌。在選單中顯示今天及所選歷史時段的費用。"; +"cost_history_days_title" = "歷史時段:%d 天"; +"cost_auto_refresh_info" = "自動重新整理:每小時 · 超時:10 分鐘"; +"refresh_cadence_title" = "重新整理頻率"; +"refresh_cadence_subtitle" = "CodexBar 在背景輪詢供應商的頻率。"; +"manual_refresh_hint" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; +"check_provider_status_title" = "檢查供應商狀態"; +"check_provider_status_subtitle" = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace 的 Gemini/Antigravity,在圖示和選單中顯示故障資訊。"; +"session_quota_notifications_title" = "會話配額通知"; +"session_quota_notifications_subtitle" = "當 5 小時會話配額用完及恢復時發送通知。"; +"quota_warning_notifications_title" = "配額預警通知"; +"quota_warning_notifications_subtitle" = "當會話或每週剩餘配額低於設定閾值時提醒。"; +"quota_warnings_title" = "配額預警"; +"quota_warning_session" = "會話"; +"quota_warning_session_capitalized" = "會話"; +"quota_warning_weekly" = "每週"; +"quota_warning_weekly_capitalized" = "每週"; +"quota_warning_warn_at" = "預警閾值"; +"quota_warning_global_threshold_subtitle" = "會話和每週時段的剩餘百分比,除非供應商單獨覆蓋。"; +"quota_warning_sound" = "播放通知音效"; +"quota_warning_provider_inherits" = "預設使用全局配額預警設定,除非在這裡自定義時段。"; +"quota_warning_customize_thresholds" = "自定義 %@ 閾值"; +"quota_warning_enable_warnings" = "啟用 %@ 預警"; +"quota_warning_window_warn_at" = "%@ 預警閾值"; +"quota_warning_off" = "關閉"; +"quota_warning_inherited" = "繼承:%@"; +"quota_warning_depleted_only" = "僅耗盡時"; +"quota_warning_upper" = "上限"; +"quota_warning_lower" = "下限"; +"apply" = "套用"; +"quit_app" = "結束 CodexBar"; +"tab_general" = "一般"; +"tab_providers" = "供應商"; +"tab_display" = "顯示"; +"tab_advanced" = "進階"; +"tab_about" = "關於"; +"tab_debug" = "除錯"; +"select_a_provider" = "選擇一個供應商"; +"cancel" = "取消"; +"last_fetch_failed" = "上次獲取失敗"; +"usage_not_fetched_yet" = "尚未獲取用量"; +"managed_account_storage_unreadable" = "託管帳號存儲不可讀。實時帳號存取仍可用,但託管新增、重新認證和移除操作已被禁用,直到存儲恢復。"; +"remove_codex_account_title" = "移除 Codex 帳號?"; +"remove" = "移除"; +"managed_login_already_running" = "託管 Codex 登入已在執行。請等待完成後再新增或重新認證其他帳號。"; +"managed_login_failed" = "託管 Codex 登入未完成。請先在終端確認 `codex --version` 可以執行。如果 macOS 封鎖了 `codex` 或將它移到垃圾桶,請移除舊的重複安裝,執行 `npm install -g --include=optional @openai/codex@latest`,然後重試。"; +"managed_login_missing_email" = "Codex 登入已完成,但無法獲取帳號電子郵件。請在確認帳號已完全登入後重試。"; +"workspace_selection_cancelled" = "CodexBar 發現多個工作區,但未選擇任何工作區。"; +"unsafe_managed_home" = "CodexBar 拒絕修改意外的託管主目錄路徑:%@"; +"menu_bar_metric_title" = "選單列指標"; +"menu_bar_metric_subtitle" = "選擇哪個時段驅動選單列百分比。"; +"menu_bar_metric_subtitle_deepseek" = "在選單列顯示 DeepSeek 餘額。"; +"menu_bar_metric_subtitle_moonshot" = "在選單列顯示 Moonshot / Kimi API 餘額。"; +"menu_bar_metric_subtitle_mistral" = "在選單列顯示 Mistral API 本月支出。"; +"menu_bar_metric_subtitle_kimik2" = "在選單列顯示 Kimi K2 API 金鑰額度。"; +"automatic" = "自動"; +"primary_api_key_limit" = "主要(API 金鑰限制)"; +"section_menu_bar" = "選單列"; +"merge_icons_title" = "合併圖示"; +"merge_icons_subtitle" = "使用單個選單列圖示並帶供應商切換器。"; +"switcher_shows_icons_title" = "切換器顯示圖示"; +"switcher_shows_icons_subtitle" = "在切換器中顯示供應商圖示(否則顯示每週進度線)。"; +"show_most_used_provider_title" = "顯示用量最高的供應商"; +"show_most_used_provider_subtitle" = "選單列會自動顯示最接近速率限制的供應商。"; +"menu_bar_shows_percent_title" = "選單列顯示百分比"; +"menu_bar_shows_percent_subtitle" = "將小動物進度條替換為供應商品牌圖示和百分比。"; +"display_mode_title" = "顯示模式"; +"display_mode_subtitle" = "選擇選單列中顯示的內容(進度會顯示實際用量與預期的對比)。"; +"section_menu_content" = "選單內容"; +"show_usage_as_used_title" = "顯示已使用用量"; +"show_usage_as_used_subtitle" = "進度條會隨配額消耗而填充(而不是顯示剩餘量)。"; +"show_quota_warning_markers_title" = "顯示配額預警標記"; +"show_quota_warning_markers_subtitle" = "設定配額預警後,在用量條上繪製閾值刻度標記。"; +"weekly_progress_work_days_title" = "每週進度工作日"; +"weekly_progress_work_days_subtitle" = "在每週用量條上繪製日期邊界刻度標記。"; +"show_reset_time_as_clock_title" = "將重置時間顯示為時鐘"; +"show_reset_time_as_clock_subtitle" = "將重置時間顯示為絕對時鐘值,而不是倒計時。"; +"show_provider_changelog_links_title" = "顯示供應商變更日誌連結"; +"show_provider_changelog_links_subtitle" = "在選單中為支持的 CLI 供應商新增發布說明連結。"; +"show_credits_extra_usage_title" = "顯示額度 + 額外用量"; +"show_credits_extra_usage_subtitle" = "在選單中顯示 Codex 額度和 Claude 額外用量部分。"; +"show_all_token_accounts_title" = "顯示所有 token 帳號"; +"show_all_token_accounts_subtitle" = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; +"multi_account_layout_title" = "多帳號布局"; +"multi_account_layout_subtitle" = "選擇分段帳號切換或堆疊帳號卡片。"; +"multi_account_layout_segmented" = "分段"; +"multi_account_layout_stacked" = "堆疊"; +"overview_tab_providers_title" = "概覽標籤供應商"; +"configure" = "設定…"; +"overview_enable_merge_icons_hint" = "啟用「合併圖示」以設定「概覽」標籤中的供應商。"; +"overview_no_providers_hint" = "「概覽」中沒有可用的已啟用供應商。"; +"overview_rows_follow_order" = "概覽行始終遵循供應商順序。"; +"overview_no_providers_selected" = "未選擇供應商"; +"section_keyboard_shortcut" = "快捷鍵"; +"open_menu_shortcut_title" = "開啟選單"; +"open_menu_shortcut_subtitle" = "從任意位置觸發選單列選單。"; +"install_cli" = "安裝 CLI"; +"install_cli_subtitle" = "將 CodexBarCLI 作為 codexbar 符號連結到 /usr/local/bin 和 /opt/homebrew/bin。"; +"cli_not_found" = "在 App 套件中找不到 CodexBarCLI。"; +"no_writable_bin_dirs" = "找不到可寫的 bin 目錄。"; +"show_debug_settings_title" = "顯示除錯設定"; +"show_debug_settings_subtitle" = "在「除錯」標籤中顯示故障排除工具。"; +"surprise_me_title" = "給我驚喜"; +"surprise_me_subtitle" = "看看你是否喜歡你的 Agent 在上面找點樂子。"; +"weekly_limit_confetti_title" = "每週限制彩紙"; +"weekly_limit_confetti_subtitle" = "當每週用量重置時播放全螢幕彩紙。"; +"hide_personal_info_title" = "隱藏個人資訊"; +"hide_personal_info_subtitle" = "在選單列和選單介面中隱藏電子郵件地址。"; +"show_provider_storage_usage_title" = "顯示供應商存儲用量"; +"show_provider_storage_usage_subtitle" = "在選單中顯示本地磁碟用量。會在背景掃描已知的供應商自有路徑。"; +"section_keychain_access" = "鑰匙圈存取"; +"keychain_access_caption" = "禁用所有鑰匙圈讀寫。瀏覽器 Cookie 匯入不可用;請在「供應商」中手動貼上 Cookie 標頭。"; +"disable_keychain_access_title" = "禁用鑰匙圈存取"; +"disable_keychain_access_subtitle" = "啟用時封鎖任何鑰匙圈存取。"; +"about_tagline" = "願你的 token 永不耗盡,隨時關注 Agent 限制。"; +"link_github" = "GitHub"; +"link_website" = "網站"; +"link_twitter" = "Twitter"; +"link_email" = "電子郵件"; +"check_updates_auto" = "自動檢查更新"; +"update_channel" = "更新頻道"; +"check_for_updates" = "檢查更新…"; +"updates_unavailable" = "此建置中更新不可用。"; +"copyright" = "© 2026 Peter Steinberger。MIT 許可證。"; +"section_logging" = "日誌"; +"enable_file_logging" = "啟用檔案日誌"; +"enable_file_logging_subtitle" = "將日誌寫入 %@ 以進行除錯。"; +"verbosity_title" = "詳細程度"; +"verbosity_subtitle" = "控制日誌記錄的詳細程度。"; +"open_log_file" = "開啟日誌檔案"; +"force_animation_next_refresh" = "下次重新整理時強制動畫"; +"force_animation_next_refresh_subtitle" = "下次重新整理後暫時顯示載入動畫。"; +"section_loading_animations" = "載入動畫"; +"loading_animations_caption" = "選擇一個模式並在選單列中重放。「隨機」保持現有行為。"; +"animation_random_default" = "隨機(預設)"; +"replay_selected_animation" = "重放選中的動畫"; +"blink_now" = "立即閃爍"; +"section_probe_logs" = "探測日誌"; +"probe_logs_caption" = "獲取最新的探測輸出以進行除錯;複製會保留完整文本。"; +"fetch_log" = "獲取日誌"; +"copy" = "複製"; +"save_to_file" = "保存到檔案"; +"load_parse_dump" = "載入解析轉儲"; +"rerun_provider_autodetect" = "重新執行供應商自動檢測"; +"loading" = "載入中…"; +"no_log_yet_fetch" = "尚無日誌。獲取後載入。"; +"section_fetch_strategy" = "獲取策略嘗試"; +"fetch_strategy_caption" = "供應商上次獲取流程中的決策和錯誤。"; +"section_openai_cookies" = "OpenAI Cookie"; +"openai_cookies_caption" = "上次 OpenAI Cookie 嘗試中的 Cookie 匯入和 WebKit 抓取日誌。"; +"no_log_yet" = "尚無日誌。請在「供應商」→「Codex」中更新 OpenAI Cookie 以執行匯入。"; +"section_caches" = "快取"; +"caches_caption" = "清除快取的費用掃描結果或瀏覽器 Cookie 快取。"; +"clear_cookie_cache" = "清除 Cookie 快取"; +"clear_cost_cache" = "清除費用快取"; +"section_notifications" = "通知"; +"notifications_caption" = "觸發 5 小時工作階段時段的測試通知(耗盡/恢復)。"; +"post_depleted" = "發布耗盡通知"; +"post_restored" = "發布恢復通知"; +"section_cli_sessions" = "CLI 會話"; +"cli_sessions_caption" = "探測後保持 Codex/Claude CLI 會話存活。預設在捕獲資料後結束。"; +"keep_cli_sessions_alive" = "保持 CLI 會話存活"; +"keep_cli_sessions_alive_subtitle" = "探測之間跳過清理(僅限除錯)。"; +"reset_cli_sessions" = "重置 CLI 會話"; +"section_error_simulation" = "錯誤模擬"; +"error_simulation_caption" = "將模擬錯誤消息注入選單卡片以進行布局測試。"; +"set_menu_error" = "設定選單錯誤"; +"clear_menu_error" = "清除選單錯誤"; +"set_cost_error" = "設定費用錯誤"; +"clear_cost_error" = "清除費用錯誤"; +"section_cli_paths" = "CLI 路徑"; +"cli_paths_caption" = "解析到的 Codex 二進位檔案和 PATH 層;啟動時捕獲登入 PATH(短超時)。"; +"codex_binary" = "Codex 二進位檔案"; +"claude_binary" = "Claude 二進位檔案"; +"effective_path" = "有效 PATH"; +"unavailable" = "不可用"; +"login_shell_path" = "登入 shell PATH(啟動時捕獲)"; +"cleared" = "已清除。"; +"no_fetch_attempts" = "尚無獲取嘗試。"; +"metric_pref_automatic" = "自動"; +"metric_pref_primary" = "主要"; +"metric_pref_secondary" = "次要"; +"metric_pref_tertiary" = "第三"; +"metric_pref_extra_usage" = "額外用量"; +"metric_pref_average" = "平均"; +"display_mode_percent" = "百分比"; +"display_mode_pace" = "進度"; +"display_mode_both" = "兩者"; +"display_mode_percent_desc" = "顯示剩餘/已使用百分比(例如 45%)"; +"display_mode_pace_desc" = "顯示進度指示器(例如 +5%)"; +"display_mode_both_desc" = "同時顯示百分比和進度(例如 45% · +5%)"; +"status_operational" = "正常執行"; +"status_partial_outage" = "部分中斷"; +"status_major_outage" = "重大中斷"; +"status_critical_issue" = "嚴重問題"; +"status_maintenance" = "維護中"; +"status_unknown" = "狀態未知"; +"refresh_manual" = "手動"; +"refresh_1min" = "1 分鐘"; +"refresh_2min" = "2 分鐘"; +"refresh_5min" = "5 分鐘"; +"refresh_15min" = "15 分鐘"; +"refresh_30min" = "30 分鐘"; +"not_found" = "找不到"; +"CodexBar can't show its menu bar icon" = "CodexBar 無法顯示選單列圖示"; +"Dismiss" = "關閉"; +"Open Menu Bar Settings" = "開啟選單列設定"; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列應用。CodexBar 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 CodexBar。"; +"cost_header_estimated" = "費用(估算)"; +"cost_estimate_hint" = "根據本地日誌估算 · 可能與帳單不同"; +"copilot_device_code" = "設備代碼已複製到剪貼板:%1$@\n\n請在以下地址驗證:%2$@"; +"copilot_waiting_text" = "請在瀏覽器中完成登入。\n登入完成後,此視窗會自動關閉。"; +"vertex_ai_login_instructions" = "要追蹤 Vertex AI 用量,請通過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 按照瀏覽器提示登入\n4. 設定你的項目:gcloud config set project PROJECT_ID\n\n是否現在開啟終端?"; From 4f60fd13aba34c5cf0db91707862387aa183fccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Larry=20Hao=EF=BC=88=E9=83=9D=E5=8D=93=E8=BF=9C=EF=BC=89?= <107194248+hhh2210@users.noreply.github.com> Date: Mon, 25 May 2026 01:20:49 +0800 Subject: [PATCH 014/124] fix(codex): handle non-app spctl assessment Allow the valid Codex CLI Gatekeeper diagnostic where spctl reports a signed command-line binary as "rejected (the code is valid but does not seem to be an app)" while keeping higher-risk blocked signals denied. Validated with focused PathBuilderTests, make check, local spctl reproduction against Codex CLI, and green GitHub checks. Co-authored-by: hhh2210 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> --- Sources/CodexBarCore/PathEnvironment.swift | 35 +++++++++-- Tests/CodexBarTests/PathBuilderTests.swift | 71 ++++++++++++++++++++++ 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 1c481f41..b79d2d84 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -324,7 +324,7 @@ public enum CodexLaunchPreflight { return !hasQuarantine } - return !self.isExplicitlyBlockedAssessment(assessment) + return !self.isExplicitlyBlockedAssessment(assessment, path: native) } private static func nativeCodexExecutableCandidates(for path: String, fileManager: FileManager) -> [String] { @@ -417,14 +417,39 @@ public enum CodexLaunchPreflight { return String(data: data, encoding: .utf8) } - private static func isExplicitlyBlockedAssessment(_ assessment: String) -> Bool { - let lower = assessment.lowercased() - return lower.contains("rejected") || - lower.contains("denied") || + private static func isExplicitlyBlockedAssessment(_ assessment: String, path: String) -> Bool { + let lower = self.assessmentDiagnosticText(assessment, path: path).lowercased() + if lower.contains("denied") || lower.contains("cssmerr_tp_cert_revoked") || lower.contains("revoked") || lower.contains("malware") || lower.contains("quarantine") + { + return true + } + if lower.contains("rejected") { + return !lower.contains("code is valid but does not seem to be an app") + } + return false + } + + private static func assessmentDiagnosticText(_ assessment: String, path: String) -> String { + assessment + .split(whereSeparator: \.isNewline) + .enumerated() + .compactMap { offset, line -> String? in + var text = line.trimmingCharacters(in: .whitespacesAndNewlines) + if offset == 0, text.hasPrefix("\(path):") { + text = String(text.dropFirst(path.count + 1)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + let lower = text.lowercased() + guard !lower.hasPrefix("source="), !lower.hasPrefix("origin=") else { + return nil + } + return text + } + .joined(separator: "\n") } #endif } diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 533f8d9e..7ac329e0 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -267,6 +267,77 @@ struct PathBuilderTests { #expect(!allowed) } + + @Test + func `Codex launch preflight allows valid signed command line binary assessment`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected (the code is valid but does not seem to be an app)" }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } + + @Test + func `Codex launch preflight blocks revoked assessment even with non app rejection text`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { _ in + """ + rejected (the code is valid but does not seem to be an app) + CSSMERR_TP_CERT_REVOKED + """ + }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores benign text in path when verdict is generic rejection`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/code is valid but does not seem to be an app/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores benign text before verdict separator`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/x: code is valid but does not seem to be an app/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores blocked words in accepted path and source fields`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/rejected/quarantine/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in + """ + \(path): accepted + source=revoked quarantine marker + origin=malware test fixture + """ + }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } #endif @Test From f4a2a6a981d70e7c96ec34c324b58a087931d89e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 25 May 2026 10:33:18 +0100 Subject: [PATCH 015/124] chore(skills): add repo release skill --- .agents/skills/release-codexbar/SKILL.md | 140 +++++++++++++++++++++++ Scripts/mac-release | 4 +- 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/release-codexbar/SKILL.md diff --git a/.agents/skills/release-codexbar/SKILL.md b/.agents/skills/release-codexbar/SKILL.md new file mode 100644 index 00000000..efda8acb --- /dev/null +++ b/.agents/skills/release-codexbar/SKILL.md @@ -0,0 +1,140 @@ +--- +name: release-codexbar +description: "CodexBar release: versioning, notarization, appcast, Homebrew, post-release bump." +--- + +# CodexBar Release + +Use for releasing signed/notarized macOS apps, especially repos with Sparkle appcasts and Homebrew casks. + +## Start + +1. Work from the app repo unless asked otherwise. +2. Check repo state, current version, latest tag/release, and release docs/scripts. +3. Confirm `CHANGELOG.md` is complete, user-facing, deduped, and dated for the release. +4. Prefer the repo release script; patch small script/test blockers instead of bypassing the release path. +5. Never print key material. Keep 1Password references and local key paths as references only. + +## Key Material + +Use `$one-password` for secret handling. `op` only in tmux/persistent shell; no broad `env`, `set`, `export -p`, or secret scans. + +Known App Store Connect item: + +- 1Password item: `API Key - App Store Connect - Personal` +- fields: `private_key_p8`, `key_id`, `issuer_id` +- keep all three fields from the same 1Password item; do not mix with stale values from `~/.profile` + +Known Sparkle key: + +- private key file: `~/Library/CloudStorage/Dropbox/Backup/Sparkle/sparkle-private-key-KEEP-SECURE.txt` +- pass as `SPARKLE_PRIVATE_KEY_FILE` + +Safe env file pattern: + +```text +APP_STORE_CONNECT_API_KEY_P8=op://Private/API Key - App Store Connect - Personal/private_key_p8 +APP_STORE_CONNECT_KEY_ID=op://Private/API Key - App Store Connect - Personal/key_id +APP_STORE_CONNECT_ISSUER_ID=op://Private/API Key - App Store Connect - Personal/issuer_id +SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/sparkle-private-key-KEEP-SECURE.txt +``` + +Run with `op run --account my.1password.com --env-file --