From c3a8793fa07cfa69b452e54c131b000f123c8d9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Jun 2026 08:03:43 +0100 Subject: [PATCH 01/51] style: apply release formatting --- .../Providers/MiniMax/MiniMaxSubscriptionMetadata.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift index 56a2d45c..37f62f75 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -3,7 +3,7 @@ import Foundation import FoundationNetworking #endif -struct MiniMaxSubscriptionMetadata: Sendable, Equatable { +struct MiniMaxSubscriptionMetadata: Equatable { let planName: String? let subscriptionExpiresAt: Date? let subscriptionRenewsAt: Date? From 920997c6a365914eb0449efbed2ba8131fa4ac3e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Jun 2026 08:30:34 +0100 Subject: [PATCH 02/51] docs: update appcast for 0.32.5 --- appcast.xml | 64 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/appcast.xml b/appcast.xml index 41787fbd..af60a21d 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,49 @@ CodexBar + + 0.32.5 + Tue, 09 Jun 2026 08:30:33 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 80 + 0.32.5 + 14.0 + CodexBar 0.32.5 +

Added

+
    +
  • Localization: add French as a selectable app language (#1241). Thanks @Yuxin-Qiao!
  • +
  • Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao!
  • +
  • Localization: add Dutch as a selectable app language (#1252). Thanks @Yuxin-Qiao!
  • +
  • Localization: add Vietnamese as a selectable app language (#1247). Thanks @Yuxin-Qiao!
  • +
+

Fixed

+
    +
  • Menu bar: keep provider switching inside AppKit's menu-tracking transaction and defer structural dropdown rebuilds until mouse-up completes, preventing intermittent hangs when moving between providers and Overview.
  • +
  • Localization: cache resolved localized bundles so repeated menu/status text lookups no longer hit disk on the main thread (#1355, fixes #1347). Thanks @Yuxin-Qiao!
  • +
  • Menu bar: size hosted chart submenus directly instead of spinning up throwaway SwiftUI hosting controllers during menu layout (#1352). Thanks @Yuxin-Qiao!
  • +
  • Menu bar: avoid recomputing expensive readiness signatures on closed-menu store ticks while preserving root-open refresh correctness for deferred observations (#1351). Thanks @Yuxin-Qiao!
  • +
  • Menu bar: defer Quit from the status menu until AppKit menu tracking unwinds so shutdown does not wedge Dock autohide state (#1354, fixes #1353). Thanks @jskoiz!
  • +
  • Claude: remove transient ClaudeProbe session artifacts after CLI usage polls so background refreshes no longer fill Claude Code project history with CodexBar /usage sessions (#1301). Thanks @LPFchan and @matthewod11-stack!
  • +
  • Menu bar: keep z.ai overview rows with detail submenus in Overview so hovering quota details no longer recurses into a nested provider menu (#1279, fixes #1246). Thanks @RajvardhanPatil07!
  • +
  • Codex: backfill visible-account reset timestamps and missing 5-hour/weekly window metadata from same-workspace plan history so segmented multi-account JSON keeps machine-readable reset data (#1283). Thanks @callmepopo!
  • +
  • Antigravity: detect CLI local language-server processes and allow empty CSRF tokens only for explicit CLI matches so Antigravity CLI quota usage renders without weakening IDE CSRF detection (#1341). Thanks @oyaah!
  • +
  • Menu bar: skip closed attached-menu rebuilds during stale background data-refresh ticks so closed dropdowns are not pre-warmed while the user is not interacting (#1291). Thanks @Nicolas0315!
  • +
  • Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech!
  • +
  • Codex: time out stalled managed codex login processes so account switches no longer stay stuck in progress after OAuth completes (#1330). Thanks @dhruv-anand-aintech!
  • +
  • Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech!
  • +
  • Antigravity: make the automatic menu-bar summary choose the most constrained family quota so an exhausted Gemini lane is no longer hidden by a full Claude lane (#1334). Thanks @dhruv-anand-aintech!
  • +
  • Performance: memoize models.dev cost catalog load outcomes so large Codex history scans no longer re-read and decode the same cache file per row (#1322, refs #1311). Thanks @turbothad!
  • +
  • Menu bar: compute Claude pace/reserve from the selected menu-bar metric window so Primary (Session) no longer pairs the session percentage with the weekly reserve (#1302). Thanks @outfoxer!
  • +
  • Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210!
  • +
  • Menu bar: keep merged provider tab selection from invalidating broad settings observers so switching providers no longer triggers background refresh and status-icon work.
  • +
  • Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210!
  • +
  • Menu bar: keep provider-switcher quota bars from replacing Auto Layout constraints when the visible ratio is unchanged, making tab switches responsive with many providers enabled (#1303, #1315). Thanks @juanjoseluisgarcia!
  • +
  • Kiro: retry login-shell PATH capture when CLI discovery races a slow cold shell startup, so kiro-cli is no longer stuck as missing for the whole app session (#1316). Thanks @bt-justtrack!
  • +
+

View full changelog

+]]>
+ +
0.32.4 Tue, 02 Jun 2026 15:43:46 +0100 @@ -38,27 +81,6 @@ ]]> - - 0.32.2 - Mon, 01 Jun 2026 04:43:41 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 77 - 0.32.2 - 14.0 - CodexBar 0.32.2 -

Added

-
    -
  • QA: document the live CodexBar e2e flow and add a redacted provider-matrix helper for packaged CLI smoke tests.
  • -
-

Fixed

-
    -
  • Menu bar: add breathing room to compact Codex account rows so the provider, account, status, and plan labels no longer hug the row edges.
  • -
  • Performance: make Codex token-cost scanning faster and more memory-efficient on large local session corpora.
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From a4f278d91fe408af1facb94613808df1709f1c3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Jun 2026 08:41:16 +0100 Subject: [PATCH 03/51] chore: start 0.32.6 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f119203d..4adc9dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.32.6 — Unreleased + ## 0.32.5 — 2026-06-09 ### Added diff --git a/version.env b/version.env index 1e9901f0..977410df 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.32.5 -BUILD_NUMBER=80 +MARKETING_VERSION=0.32.6 +BUILD_NUMBER=81 From f91d779232e3947bf76afcb4c803708325bfc7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 10 Jun 2026 04:30:57 +0100 Subject: [PATCH 04/51] Add T3 Chat to the README (#1361) - Link T3 Chat in the provider list - Describe the Base and Overage usage buckets --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 16640d0b..407f3c4b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [z.ai](docs/zai.md) — API token for quota + MCP windows. - [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking. - [MiniMax](docs/minimax.md) — API token, cookie header, or browser cookies for coding-plan usage. +- [T3 Chat](docs/providers.md#t3-chat) — Browser cookies capture for Base and Overage usage buckets. - [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit. - [Kimi K2 (unofficial)](docs/kimi-k2.md) — Legacy API key flow for credit-based usage totals. - [Kilo](docs/kilo.md) — API token with CLI-auth fallback for Kilo Pass usage. From 6f6cb097dc58a5290bc61cf1f44d1dbe98eee780 Mon Sep 17 00:00:00 2001 From: Martin Hausleitner <55828102+Martin-Hausleitner@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:13:34 +0200 Subject: [PATCH 05/51] Fix Antigravity summaries for untracked quotas (#1369) * fix: default remainingPercent to 100 for untracked Antigravity models to prevent them from dominating summary bar max quota * fix: ignore untracked Antigravity summary quotas --------- Co-authored-by: vibecode-vm Co-authored-by: Peter Steinberger --- CHANGELOG.md | 3 ++ .../Antigravity/AntigravityStatusProbe.swift | 3 +- .../AntigravityStatusProbeTests.swift | 9 +++-- .../MenuBarMetricWindowResolverTests.swift | 35 +++++++++++++++++++ .../MenuCardAntigravityTests.swift | 4 +-- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4adc9dcc..71fd4c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.6 — Unreleased +### Fixed +- Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! + ## 0.32.5 — 2026-06-09 ### Added diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 79c8dd2b..99cf42c3 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -87,12 +87,13 @@ public struct AntigravityStatusSnapshot: Sendable { } let normalized = Self.normalizedModels(self.modelQuotas) - let summaryModels: [AntigravityNormalizedModel] = switch self.source { + let summaryCandidates: [AntigravityNormalizedModel] = switch self.source { case .local: normalized case .remote: normalized.filter(Self.isRemoteSummaryCandidate) } + let summaryModels = summaryCandidates.filter { $0.quota.remainingFraction != nil } let primaryQuota = Self.representative(for: .claude, in: summaryModels) let secondaryQuota = Self.representative(for: .geminiPro, in: summaryModels) let tertiaryQuota = Self.representative(for: .geminiFlash, in: summaryModels) diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index bd71cbd2..0cddce66 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -977,7 +977,7 @@ extension AntigravityStatusProbeTests { } @Test - func `model without remaining fraction keeps reset time`() throws { + func `model without remaining fraction stays out of family summary and preserves reset metadata`() throws { let resetTime = Date(timeIntervalSince1970: 1_735_000_000) let snapshot = AntigravityStatusSnapshot( modelQuotas: [ @@ -998,9 +998,12 @@ extension AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.secondary?.remainingPercent.rounded() == 0) - #expect(usage.secondary?.resetsAt == resetTime) + #expect(usage.secondary == nil) #expect(usage.tertiary?.remainingPercent.rounded() == 100) + let modelWindow = try #require(usage.extraRateWindows?.first { + $0.id == "MODEL_PLACEHOLDER_M36" + }) + #expect(modelWindow.window.resetsAt == resetTime) } @Test diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index d0e76955..097f1309 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -56,6 +56,41 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.resetDescription == "Gemini Pro") } + @Test + func `automatic metric ignores untracked antigravity family lane`() throws { + let untrackedReset = Date(timeIntervalSince1970: 1000) + let exhaustedReset = Date(timeIntervalSince1970: 2000) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet 4.6", + modelId: "claude-sonnet-4-6", + remainingFraction: nil, + resetTime: untrackedReset, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro", + modelId: "gemini-3-1-pro", + remainingFraction: 0, + resetTime: exhaustedReset, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + let snapshot = try antigravitySnapshot.toUsageSnapshot() + #expect(snapshot.primary == nil) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.resetsAt == exhaustedReset) + } + @Test func `explicit antigravity metric keeps requested family lane`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift index f2947a61..b725acf0 100644 --- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -57,7 +57,7 @@ struct MenuCardAntigravityTests { } @Test - func `antigravity zero percent metric still shows reset text`() throws { + func `antigravity untracked metric stays out of family summary`() throws { let now = Date(timeIntervalSince1970: 1_735_000_000) let resetTime = now.addingTimeInterval(3600) let antigravitySnapshot = AntigravityStatusSnapshot( @@ -108,7 +108,7 @@ struct MenuCardAntigravityTests { #expect(model.metrics[1].percent == 0) #expect(model.metrics[1].percentLabel == "0% left") - #expect(model.metrics[1].resetText != nil) + #expect(model.metrics[1].resetText == nil) } @Test From 20004f3d2e811ecb19766fd43177a2e74c3aac17 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 00:01:39 -0700 Subject: [PATCH 06/51] fix: correct Claude usage pricing (#1372) --- CHANGELOG.md | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- Sources/CodexBarCore/PiSessionCostCache.swift | 10 +- .../CodexBarCore/PiSessionCostScanner.swift | 8 + .../Vendored/CostUsage/CostUsageCache.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 148 ++++-- .../CostUsage/CostUsageScanner+Claude.swift | 155 ++++-- .../Vendored/CostUsage/CostUsageScanner.swift | 2 + Tests/CodexBarTests/CostUsageCacheTests.swift | 6 +- .../CodexBarTests/CostUsagePricingTests.swift | 156 +++++- .../CostUsageScannerClaudeFableTests.swift | 457 ++++++++++++++++++ .../CodexBarTests/CostUsageScannerTests.swift | 2 +- .../PiSessionCostScannerTests.swift | 69 ++- 13 files changed, 901 insertions(+), 117 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 71fd4c32..a45b4d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! +- Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! ## 0.32.5 — 2026-06-09 diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index a48f4f08..7cbc1d12 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "7049d0743a7cd98f" + static let value = "6162cc6387d12e86" } diff --git a/Sources/CodexBarCore/PiSessionCostCache.swift b/Sources/CodexBarCore/PiSessionCostCache.swift index a5946c2e..60abcae5 100644 --- a/Sources/CodexBarCore/PiSessionCostCache.swift +++ b/Sources/CodexBarCore/PiSessionCostCache.swift @@ -1,7 +1,7 @@ import Foundation enum PiSessionCostCacheIO { - private static let artifactVersion = 2 + private static let artifactVersion = 3 private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -35,7 +35,11 @@ enum PiSessionCostCacheIO { let data = (try? JSONEncoder().encode(cache)) ?? Data() do { try data.write(to: tmp, options: [.atomic]) - _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } } catch { try? FileManager.default.removeItem(at: tmp) } @@ -50,7 +54,7 @@ struct PiSessionCostCache: Codable { var daysByProvider: [String: [String: [String: PiPackedUsage]]] = [:] var files: [String: PiSessionFileUsage] = [:] - init(version: Int = 2) { + init(version: Int = 3) { self.version = version } } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index a1aebbda..7e1adb39 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -384,6 +384,7 @@ enum PiSessionCostScanner { provider: identity.provider, modelName: identity.modelName, message: message, + pricingDate: date, pricingContext: pricingContext) add(provider: identity.provider, dayKey: dayKey, modelName: identity.modelName, usage: usage) } @@ -524,6 +525,7 @@ enum PiSessionCostScanner { provider: UsageProvider, modelName: String, message: [String: Any], + pricingDate: Date? = nil, pricingContext: ModelsDevPricingContext? = nil) -> PiPackedUsage { let usage = (message["usage"] as? [String: Any]) ?? [:] @@ -571,10 +573,12 @@ enum PiSessionCostScanner { cacheWriteTokens: cacheWrite, outputTokens: output, totalTokens: totalTokens) + // Pi JSONL does not record Anthropic cache retention, so use Pi's persisted default tariff. let costUSD = self.computedCostUSD( provider: provider, modelName: modelName, usage: rawUsage, + pricingDate: pricingDate, pricingContext: pricingContext) let costNanos = costUSD.map { Int64(($0 * self.costScale).rounded()) } ?? 0 @@ -593,6 +597,7 @@ enum PiSessionCostScanner { provider: UsageProvider, modelName: String, usage: PiPackedUsage, + pricingDate: Date? = nil, pricingContext: ModelsDevPricingContext? = nil) -> Double? { switch provider { @@ -611,6 +616,7 @@ enum PiSessionCostScanner { cacheReadInputTokens: usage.cacheReadTokens, cacheCreationInputTokens: usage.cacheWriteTokens, outputTokens: usage.outputTokens, + pricingDate: pricingDate, modelsDevCatalog: pricingContext?.catalog, modelsDevCacheRoot: pricingContext?.cacheRoot) default: @@ -634,7 +640,9 @@ enum PiSessionCostScanner { } return 0 } +} +extension PiSessionCostScanner { private static func mappedProvider(fromPiProvider provider: String) -> UsageProvider? { switch provider.lowercased() { case "openai-codex": diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index effe1478..2fbfabee 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -6,7 +6,7 @@ enum CostUsageCacheIO { case .codex: 8 case .claude, .vertexai: - 2 + 4 default: 1 } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 410fd142..26017ae4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -57,6 +57,14 @@ enum CostUsagePricing { let cacheReadInputCostPerTokenAboveThreshold: Double? } + private struct ClaudeCostTokens { + let input: Int + let cacheRead: Int + let cacheCreation: Int + let cacheCreation1h: Int + let output: Int + } + private static let codex: [String: CodexPricing] = [ "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, @@ -205,6 +213,16 @@ enum CostUsagePricing { } private static let claude: [String: ClaudePricing] = [ + "claude-fable-5": ClaudePricing( + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheCreationInputCostPerToken: 1.25e-5, + cacheReadInputCostPerToken: 1e-6, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-haiku-4-5-20251001": ClaudePricing( inputCostPerToken: 1e-6, outputCostPerToken: 5e-6, @@ -300,11 +318,11 @@ enum CostUsagePricing { outputCostPerToken: 1.5e-5, cacheCreationInputCostPerToken: 3.75e-6, cacheReadInputCostPerToken: 3e-7, - thresholdTokens: 200_000, - inputCostPerTokenAboveThreshold: 6e-6, - outputCostPerTokenAboveThreshold: 2.25e-5, - cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, - cacheReadInputCostPerTokenAboveThreshold: 6e-7), + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5-20250929": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -347,6 +365,30 @@ enum CostUsagePricing { cacheReadInputCostPerTokenAboveThreshold: 6e-7), ] + private static let claudeFullContextStandardPricingCutoff = Date(timeIntervalSince1970: 1_773_360_000) + private static let claudeHistoricalLongContext: [String: ClaudePricing] = [ + "claude-opus-4-6": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 1e-5, + outputCostPerTokenAboveThreshold: 3.75e-5, + cacheCreationInputCostPerTokenAboveThreshold: 1.25e-5, + cacheReadInputCostPerTokenAboveThreshold: 1e-6), + "claude-sonnet-4-6": ClaudePricing( + inputCostPerToken: 3e-6, + outputCostPerToken: 1.5e-5, + cacheCreationInputCostPerToken: 3.75e-6, + cacheReadInputCostPerToken: 3e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 6e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, + cacheReadInputCostPerTokenAboveThreshold: 6e-7), + ] + private static let codexModelsDevProviderID = "openai" private static let claudeModelsDevProviderID = "anthropic" @@ -514,10 +556,29 @@ enum CostUsagePricing { inputTokens: Int, cacheReadInputTokens: Int, cacheCreationInputTokens: Int, + cacheCreationInputTokens1h: Int = 0, outputTokens: Int, + pricingDate: Date? = nil, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> Double? { + let tokens = ClaudeCostTokens( + input: inputTokens, + cacheRead: cacheReadInputTokens, + cacheCreation: cacheCreationInputTokens, + cacheCreation1h: cacheCreationInputTokens1h, + output: outputTokens) + let key = self.normalizeClaudeModel(model) + if let pricingDate, + let historicalPricing = self.claudeHistoricalLongContext[key], + let currentPricing = self.claude[key] + { + return self.claudeCostUSD( + pricing: pricingDate < self.claudeFullContextStandardPricingCutoff + ? historicalPricing + : currentPricing, + tokens: tokens) + } if let lookup = self.modelsDevLookup( providerID: self.claudeModelsDevProviderID, model: model, @@ -526,64 +587,50 @@ enum CostUsagePricing { { return self.claudeCostUSD( pricing: lookup.pricing, - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } - let key = self.normalizeClaudeModel(model) guard let pricing = self.claude[key] else { return nil } return self.claudeCostUSD( pricing: pricing, - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } private static func claudeCostUSD( pricing: ClaudePricing, - inputTokens: Int, - cacheReadInputTokens: Int, - cacheCreationInputTokens: Int, - outputTokens: Int) -> Double + tokens: ClaudeCostTokens) -> Double { - func tiered(_ tokens: Int, base: Double, above: Double?, threshold: Int?) -> Double { - guard let threshold, let above else { return Double(tokens) * base } - let below = min(tokens, threshold) - let over = max(tokens - threshold, 0) - return Double(below) * base + Double(over) * above - } + let input = max(0, tokens.input) + let cacheRead = max(0, tokens.cacheRead) + let cacheCreationTotal = max(0, tokens.cacheCreation) + let cacheCreation1h = min(max(0, tokens.cacheCreation1h), cacheCreationTotal) + let cacheCreation5m = cacheCreationTotal - cacheCreation1h + let usesLongContextRates = pricing.thresholdTokens.map { + input + cacheRead + cacheCreationTotal > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + let cacheCreation5mRate = usesLongContextRates + ? pricing.cacheCreationInputCostPerTokenAboveThreshold ?? pricing.cacheCreationInputCostPerToken + : pricing.cacheCreationInputCostPerToken + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken - return tiered( - max(0, inputTokens), - base: pricing.inputCostPerToken, - above: pricing.inputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, cacheReadInputTokens), - base: pricing.cacheReadInputCostPerToken, - above: pricing.cacheReadInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, cacheCreationInputTokens), - base: pricing.cacheCreationInputCostPerToken, - above: pricing.cacheCreationInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, outputTokens), - base: pricing.outputCostPerToken, - above: pricing.outputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(cacheCreation5m) * cacheCreation5mRate + + Double(cacheCreation1h) * inputRate * 2 + + Double(max(0, tokens.output)) * outputRate } private static func claudeCostUSD( pricing: ModelsDevPricingInfo, - inputTokens: Int, - cacheReadInputTokens: Int, - cacheCreationInputTokens: Int, - outputTokens: Int) -> Double + tokens: ClaudeCostTokens) -> Double { self.claudeCostUSD( pricing: ClaudePricing( @@ -596,10 +643,7 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, cacheCreationInputCostPerTokenAboveThreshold: pricing.cacheCreationInputCostPerTokenAboveThreshold, cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } static func modelsDevCatalog(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCatalog? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 3d1578e3..83730d3b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -3,6 +3,27 @@ import Foundation extension CostUsageScanner { // MARK: - Claude + private struct ClaudeTokens { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let cacheCreate1h: Int + let output: Int + let costNanos: Int + let costPriced: Bool + } + + private struct ClaudeDayModelKey: Hashable { + let day: String + let model: String + } + + private struct ClaudeRepricedCost { + var total: Double = 0 + var sampleCount: Int = 0 + var unresolved = false + } + private static func defaultClaudeProjectsRoots(options: Options) -> [URL] { if let override = options.claudeProjectsRoots { return override } @@ -59,21 +80,12 @@ extension CostUsageScanner { modelsDevCacheRoot: URL? = nil, checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult { - struct ClaudeTokens: Sendable { - let input: Int - let cacheRead: Int - let cacheCreate: Int - let output: Int - let costNanos: Int - let costPriced: Bool - } - func add(dayKey: String, model: String, tokens: ClaudeTokens, days: inout [String: [String: [Int]]]) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { return } let normModel = CostUsagePricing.normalizeClaudeModel(model) var dayModels = days[dayKey] ?? [:] - var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0] + var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0, 0] packed[0] = (packed[safe: 0] ?? 0) + tokens.input packed[1] = (packed[safe: 1] ?? 0) + tokens.cacheRead packed[2] = (packed[safe: 2] ?? 0) + tokens.cacheCreate @@ -81,6 +93,7 @@ extension CostUsageScanner { packed[4] = (packed[safe: 4] ?? 0) + tokens.costNanos packed[5] = (packed[safe: 5] ?? 0) + 1 packed[6] = (packed[safe: 6] ?? 0) + (tokens.costPriced ? 1 : 0) + packed[7] = (packed[safe: 7] ?? 0) + tokens.cacheCreate1h dayModels[normModel] = packed days[dayKey] = dayModels } @@ -127,7 +140,8 @@ extension CostUsageScanner { else { return } guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } - guard let tsText = obj["timestamp"] as? String else { return } + guard let tsText = obj["timestamp"] as? String, let timestamp = Self.dateFromTimestamp(tsText) + else { return } guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } @@ -137,6 +151,9 @@ extension CostUsageScanner { let input = max(0, toInt(usage["input_tokens"])) let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) + let cacheCreate1h = Self.claudeOneHourCacheCreationTokens( + usage: usage, + total: cacheCreate) let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) let output = max(0, toInt(usage["output_tokens"])) if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } @@ -146,7 +163,9 @@ extension CostUsageScanner { inputTokens: input, cacheReadInputTokens: cacheRead, cacheCreationInputTokens: cacheCreate, + cacheCreationInputTokens1h: cacheCreate1h, outputTokens: output, + pricingDate: timestamp, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 @@ -154,6 +173,7 @@ extension CostUsageScanner { input: input, cacheRead: cacheRead, cacheCreate: cacheCreate, + cacheCreate1h: cacheCreate1h, output: output, costNanos: costNanos, costPriced: cost != nil) @@ -177,11 +197,13 @@ extension CostUsageScanner { sessionId: sessionId, messageId: messageId, requestId: requestId, + timestampUnixMs: Int64((timestamp.timeIntervalSince1970 * 1000).rounded()), isSidechain: toBool(obj["isSidechain"]), pathRole: pathRole, input: tokens.input, cacheRead: tokens.cacheRead, cacheCreate: tokens.cacheCreate, + cacheCreate1h: tokens.cacheCreate1h, output: tokens.output, costNanos: tokens.costNanos, costPriced: tokens.costPriced) @@ -210,6 +232,7 @@ extension CostUsageScanner { input: row.input, cacheRead: row.cacheRead, cacheCreate: row.cacheCreate, + cacheCreate1h: row.cacheCreate1h ?? 0, output: row.output, costNanos: row.costNanos, costPriced: row.costPriced ?? (row.costNanos > 0)) @@ -219,6 +242,12 @@ extension CostUsageScanner { return ClaudeParseResult(days: days, rows: rows, parsedBytes: parsedBytes) } + private static func claudeOneHourCacheCreationTokens(usage: [String: Any], total: Int) -> Int { + guard let cacheCreation = usage["cache_creation"] as? [String: Any] else { return 0 } + let tokens = (cacheCreation["ephemeral_1h_input_tokens"] as? NSNumber)?.intValue ?? 0 + return min(total, max(0, tokens)) + } + private static func claudePathRole(fileURL: URL) -> ClaudePathRole { fileURL.path.contains("/subagents/") ? .subagent : .parent } @@ -270,29 +299,15 @@ extension CostUsageScanner { return lhs.path < rhs.path } - private static func rebuildClaudeDays(cache: inout CostUsageCache) { - var days: [String: [String: [Int]]] = [:] + private static func reconciledClaudeRows(cache: CostUsageCache) -> [ClaudeUsageRow] { + var rows: [ClaudeUsageRow] = [] var winners: [String: (path: String, row: ClaudeUsageRow)] = [:] - func addRow(_ row: ClaudeUsageRow) { - var dayModels = days[row.dayKey] ?? [:] - var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0] - packed[0] = (packed[safe: 0] ?? 0) + row.input - packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead - packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate - packed[3] = (packed[safe: 3] ?? 0) + row.output - packed[4] = (packed[safe: 4] ?? 0) + row.costNanos - packed[5] = (packed[safe: 5] ?? 0) + 1 - packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) - dayModels[row.model] = packed - days[row.dayKey] = dayModels - } - for path in cache.files.keys.sorted() { - guard let rows = cache.files[path]?.claudeRows else { continue } - for row in rows { + guard let fileRows = cache.files[path]?.claudeRows else { continue } + for row in fileRows { guard let canonicalKey = Self.claudeCanonicalRowKey(row) else { - addRow(row) + rows.append(row) continue } let candidate = (path: path, row: row) @@ -306,8 +321,26 @@ extension CostUsageScanner { } } - for winner in winners.values { - addRow(winner.row) + rows.append(contentsOf: winners.keys.sorted().compactMap { winners[$0]?.row }) + return rows + } + + private static func rebuildClaudeDays(cache: inout CostUsageCache) { + var days: [String: [String: [Int]]] = [:] + + for row in Self.reconciledClaudeRows(cache: cache) { + var dayModels = days[row.dayKey] ?? [:] + var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0, 0] + packed[0] = (packed[safe: 0] ?? 0) + row.input + packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead + packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate + packed[3] = (packed[safe: 3] ?? 0) + row.output + packed[4] = (packed[safe: 4] ?? 0) + row.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) + packed[7] = (packed[safe: 7] ?? 0) + (row.cacheCreate1h ?? 0) + dayModels[row.model] = packed + days[row.dayKey] = dayModels } cache.days = days @@ -681,6 +714,41 @@ extension CostUsageScanner { var totalCost: Double = 0 var costSeen = false let costScale = 1_000_000_000.0 + var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] + + for row in Self.reconciledClaudeRows(cache: cache) { + let key = ClaudeDayModelKey(day: row.dayKey, model: row.model) + var aggregate = repricedCosts[key] ?? ClaudeRepricedCost() + aggregate.sampleCount += 1 + let isPriced = row.costPriced ?? (row.costNanos > 0) + let currentPricingCost = CostUsagePricing.claudeCostUSD( + model: row.model, + inputTokens: row.input, + cacheReadInputTokens: row.cacheRead, + cacheCreationInputTokens: row.cacheCreate, + cacheCreationInputTokens1h: row.cacheCreate1h ?? 0, + outputTokens: row.output, + pricingDate: row.timestampUnixMs.map { + Date(timeIntervalSince1970: Double($0) / 1000) + }, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let cost: Double? = if isPriced, row.costNanos == 0 { + 0 + } else if let currentPricingCost { + currentPricingCost + } else if isPriced { + Double(row.costNanos) / costScale + } else { + nil + } + if let cost { + aggregate.total += cost + } else { + aggregate.unresolved = true + } + repricedCosts[key] = aggregate + } let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) @@ -705,10 +773,7 @@ extension CostUsageScanner { let cacheRead = packed[safe: 1] ?? 0 let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 - let cachedCost = packed[safe: 4] ?? 0 let sampleCount = packed[safe: 5] ?? 0 - let pricedSampleCount = packed[safe: 6] ?? 0 - let hasCompleteCachedCost = sampleCount > 0 && pricedSampleCount == sampleCount let totalTokens = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. @@ -717,16 +782,16 @@ extension CostUsageScanner { dayCacheCreate += cacheCreate dayOutput += output - let currentPricingCost = CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - // Cached costs are accumulated per request, which preserves Claude long-context threshold boundaries. - let cost = hasCompleteCachedCost ? Double(cachedCost) / costScale : currentPricingCost + let repricedCost = repricedCosts[ClaudeDayModelKey(day: day, model: model)] + let currentPricingCost: Double? = if let repricedCost, + repricedCost.sampleCount == sampleCount, + !repricedCost.unresolved + { + repricedCost.total + } else { + nil + } + let cost = currentPricingCost breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index ca6a285c..4316bbb7 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -381,11 +381,13 @@ enum CostUsageScanner { let sessionId: String? let messageId: String? let requestId: String? + let timestampUnixMs: Int64? let isSidechain: Bool let pathRole: ClaudePathRole let input: Int let cacheRead: Int let cacheCreate: Int + let cacheCreate1h: Int? let output: Int let costNanos: Int let costPriced: Bool? diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index f71c60e2..d59f1c95 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -4,14 +4,16 @@ import Testing struct CostUsageCacheTests { @Test - func `cache file URL uses codex specific artifact version`() { + func `cache file URL uses provider artifact versions`() { let root = URL(fileURLWithPath: "/tmp/codexbar-cost-cache", isDirectory: true) let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) + let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) #expect(codexURL.lastPathComponent == "codex-v8.json") - #expect(claudeURL.lastPathComponent == "claude-v2.json") + #expect(claudeURL.lastPathComponent == "claude-v4.json") + #expect(vertexURL.lastPathComponent == "vertexai-v4.json") } @Test diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index a9a90d2a..b1d5e14b 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -376,6 +376,153 @@ struct CostUsagePricingTests { #expect(cost == expected) } + @Test + func `claude cost supports fable5 bundled fallback`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (100.0 * 1e-5) + (20.0 * 1e-6) + (10.0 * 1.25e-5) + (5.0 * 5e-5) + #expect(cost == expected) + } + + @Test + func `claude cost preserves historical sonnet46 long context pricing`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let historical = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_359_999), + modelsDevCacheRoot: emptyCacheRoot) + let current = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_360_000), + modelsDevCacheRoot: emptyCacheRoot) + + #expect(historical == 1.44) + #expect(current == 0.72) + } + + @Test + func `claude cost ignores stale sonnet46 threshold catalog after cutover`() throws { + let cacheRoot = try Self.seedModelsDevCache(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + } + } + } + } + } + """) + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_360_000), + modelsDevCacheRoot: cacheRoot) + + #expect(cost == 0.72) + } + + @Test + func `claude cost prices one hour cache writes separately`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 30, + cacheCreationInputTokens1h: 20, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (100.0 * 1e-5) + + (20.0 * 1e-6) + + (10.0 * 1.25e-5) + + (20.0 * 2e-5) + + (5.0 * 5e-5) + #expect(cost == expected) + } + + @Test + func `claude cost applies long context rates across cache write durations`() throws { + let cacheRoot = try Self.seedModelsDevCache(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-threshold-model": { + "id": "claude-threshold-model", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + } + } + } + } + } + """) + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-threshold-model", + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 240_000, + cacheCreationInputTokens1h: 120_000, + outputTokens: 0, + modelsDevCacheRoot: cacheRoot) + let expected = (120_000.0 * 12e-6) + + (120_000.0 * 7.5e-6) + #expect(cost == expected) + } + + @Test + func `claude sonnet46 uses standard pricing across full context`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 240_000, + outputTokens: 0, + modelsDevCacheRoot: emptyCacheRoot) + #expect(cost == 240_000.0 * 3.75e-6) + } + @Test func `claude cost returns nil for unknown models`() { let cost = CostUsagePricing.claudeCostUSD( @@ -422,11 +569,10 @@ struct CostUsagePricingTests { outputTokens: 5, modelsDevCacheRoot: root) - let expected = (200_000.0 * 3e-6) - + (10.0 * 6e-6) - + (5.0 * 0.3e-6) - + (5.0 * 3.75e-6) - + (5.0 * 15e-6) + let expected = (200_010.0 * 6e-6) + + (5.0 * 0.6e-6) + + (5.0 * 7.5e-6) + + (5.0 * 22.5e-6) #expect(cost == expected) } diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift new file mode 100644 index 00000000..0d8beb4b --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift @@ -0,0 +1,457 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageScannerClaudeFableTests { + @Test + func `claude fable 5 issue row gets priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_fable_5", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].model == "claude-fable-5") + let expected = 0.001395 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + + @Test + func `claude transcript refusal remains priced without billing provenance`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5-refusal.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5_refusal", + "type": "message", + "role": "assistant", + "stop_reason": "refusal", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + "output_tokens": 0, + ], + ], + "requestId": "req_fable_5_refusal", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5_refusal", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].input == 100) + #expect(parsed.rows[0].cacheCreate == 10) + #expect(parsed.rows[0].cacheRead == 20) + #expect(parsed.rows[0].output == 0) + let expected = 0.001145 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + #expect(parsed.rows[0].costPriced == true) + } + + @Test + func `claude fable 5 prices one hour cache creation tokens`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5-cache-ttl.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5_cache_ttl", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 30, + "cache_creation": [ + "ephemeral_5m_input_tokens": 10, + "ephemeral_1h_input_tokens": 20, + ], + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_fable_5_cache_ttl", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5_cache_ttl", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].cacheCreate == 30) + #expect(parsed.rows[0].cacheCreate1h == 20) + let expected = 0.001795 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + + @Test + func `claude cached rows preserve one hour writes for deferred pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-cache-ttl.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-custom-cache-model", + "id": "msg_custom_cache_ttl", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 30, + "cache_creation": [ + "ephemeral_5m_input_tokens": 10, + "ephemeral_1h_input_tokens": 20, + ], + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_custom_cache_ttl", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_custom_cache_ttl", + ], + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let unpriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(unpriced.summary?.totalCostUSD == nil) + + let cached = CostUsageCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) + #expect(cached.days["2026-06-09"]?["claude-custom-cache-model"]?[safe: 7] == 20) + + try ModelsDevCache.save( + catalog: Self.anthropicModelsDevCatalog(model: "claude-custom-cache-model"), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + let expected = 0.001795 + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - expected) < 0.000000001) + } + + @Test + func `claude deferred pricing preserves request long context boundaries`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-threshold.jsonl", + contents: env.jsonl([ + Self.claudeUsageEvent( + model: "claude-custom-threshold-model", + messageID: "msg_custom_threshold_1", + requestID: "req_custom_threshold_1", + inputTokens: 150_000), + Self.claudeUsageEvent( + model: "claude-custom-threshold-model", + messageID: "msg_custom_threshold_2", + requestID: "req_custom_threshold_2", + inputTokens: 150_000), + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let unpriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(unpriced.summary?.totalCostUSD == nil) + + try ModelsDevCache.save( + catalog: Self.anthropicThresholdModelsDevCatalog(model: "claude-custom-threshold-model"), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 3) < 0.000000001) + } + + @Test + func `claude cached rows reprice after models dev catalog changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let model = "claude-custom-repricing-model" + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-repricing.jsonl", + contents: env.jsonl([ + Self.claudeUsageEvent( + model: model, + messageID: "msg_custom_repricing", + requestID: "req_custom_repricing", + inputTokens: 240_000), + ])) + try ModelsDevCache.save( + catalog: Self.anthropicThresholdModelsDevCatalog(model: model), + fetchedAt: day, + cacheRoot: env.cacheRoot) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let premium = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(abs((premium.summary?.totalCostUSD ?? 0) - 4.8) < 0.000000001) + + try ModelsDevCache.save( + catalog: Self.anthropicModelsDevCatalog(model: model), + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 2.4) < 0.000000001) + } + + @Test + func `claude cached historical rows keep original tariff`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 3, day: 12) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/sonnet-46-historical.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-sonnet-4-6", + "id": "msg_sonnet_46_historical", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 240_000, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + ], + ], + "requestId": "req_sonnet_46_historical", + "type": "assistant", + "timestamp": "2026-03-12T12:00:00.000Z", + "sessionId": "session_sonnet_46_historical", + ], + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let initial = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(abs((initial.summary?.totalCostUSD ?? 0) - 1.44) < 0.000000001) + + try ModelsDevCache.save( + catalog: Self.anthropicSonnet46StandardCatalog(), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let cached = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + #expect(abs((cached.summary?.totalCostUSD ?? 0) - 1.44) < 0.000000001) + } + + private static func anthropicModelsDevCatalog(model: String) throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func anthropicThresholdModelsDevCatalog(model: String) throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5, + "context_over_200k": { + "input": 20, + "output": 75, + "cache_read": 2, + "cache_write": 25 + } + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func anthropicSonnet46StandardCatalog() throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func claudeUsageEvent( + model: String, + messageID: String, + requestID: String, + inputTokens: Int) -> [String: Any] + { + [ + "message": [ + "model": model, + "id": messageID, + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": inputTokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + ], + ], + "requestId": requestID, + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_\(requestID)", + ] + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 89e0ddcb..7cd12393 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -158,7 +158,7 @@ struct CostUsageScannerTests { let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) let first = env.isoString(for: day) let second = env.isoString(for: day.addingTimeInterval(1)) - let model = "claude-sonnet-4-6" + let model = "claude-sonnet-4-5" let firstEntry: [String: Any] = [ "type": "assistant", "timestamp": first, diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index 1a063ce7..14a6bfe3 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -91,6 +91,58 @@ struct PiSessionCostScannerTests { #expect(claudeReport.data.first?.modelBreakdowns?.map(\.modelName) == ["claude-sonnet-4-6"]) } + @Test + func `pi scanner keeps ambiguous claude errors priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let claudeEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-fable-5", + "stopReason": "error", + "errorMessage": "An unknown error occurred", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 100, + "output": 0, + "cacheRead": 20, + "cacheWrite": 10, + "totalTokens": 130, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-06-09T10-00-00-000Z_refusal.jsonl", + contents: env.jsonl([claudeEntry])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 130) + let expectedCost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 0) + + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + @Test func `pi scanner uses model change fallback and assistant timestamp day`() throws { let env = try CostUsageTestEnvironment() @@ -425,7 +477,7 @@ struct PiSessionCostScannerTests { defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) - let model = "claude-sonnet-4-6" + let model = "claude-sonnet-4-5" let firstAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), @@ -494,12 +546,12 @@ struct PiSessionCostScannerTests { } @Test - func `pi scanner ignores v1 cache missing usage sample counts`() throws { + func `pi scanner ignores v2 cache with stale claude pricing`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) - let model = "claude-sonnet-4-6" + let model = "claude-sonnet-4-5" let firstAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), @@ -557,7 +609,7 @@ struct PiSessionCostScannerTests { totalTokens: 300_000, costNanos: Int64((aggregateCost * 1_000_000_000).rounded()), costSampleCount: 2, - usageSampleCount: nil) + usageSampleCount: 2) let dayKey = "2026-05-10" let contributions = [ UsageProvider.claude.rawValue: [ @@ -572,7 +624,7 @@ struct PiSessionCostScannerTests { parsedBytes: size, lastModelContext: nil, contributions: contributions) - var oldCache = PiSessionCostCache(version: 1) + var oldCache = PiSessionCostCache(version: 2) oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) oldCache.scanSinceKey = dayKey oldCache.scanUntilKey = dayKey @@ -580,7 +632,7 @@ struct PiSessionCostScannerTests { oldCache.files = [fileURL.path: oldFileUsage] let oldCacheURL = env.cacheRoot .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("pi-sessions-v1.json", isDirectory: false) + .appendingPathComponent("pi-sessions-v2.json", isDirectory: false) try FileManager.default.createDirectory( at: oldCacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) @@ -602,9 +654,12 @@ struct PiSessionCostScannerTests { #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + let newCacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.claude.rawValue]?[dayKey]?[model] - #expect(newCache.version == 2) + #expect(newCacheURL.lastPathComponent == "pi-sessions-v3.json") + #expect(newCache.version == 3) #expect(rebuilt?.usageSampleCount == 2) #expect(rebuilt?.costSampleCount == 2) } From e97bfb0db9eb0883ad92d173d8accc547879bcd2 Mon Sep 17 00:00:00 2001 From: Jang Isaac <78341411+jangisaac-dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:50:38 +0900 Subject: [PATCH 07/51] fix: defer tracked menu refresh rebuilds (#1376) Defer parent-menu recomposition caused by provider data refreshes until menu tracking ends. Keep explicit provider switching and hosted submenu updates immediate. Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../StatusItemController+Actions.swift | 5 +++- .../StatusItemController+MenuTracking.swift | 15 +++++++++-- ...tusItemController+ProviderNavigation.swift | 20 ++++++++++++-- ...tatusItemController+ProviderSwitcher.swift | 2 +- .../StatusMenuOpenRefreshTests.swift | 27 ++++++++++--------- .../StatusMenuSwitcherClickTests.swift | 21 +++++++++++++-- 7 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45b4d72..c8a853b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! +- Menu bar: defer data-refresh rebuilds until the tracked menu closes, avoiding multi-second WindowServer stalls with slower providers such as Grok (#1376). Thanks @jangisaac-dev! ## 0.32.5 — 2026-06-09 diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index cd93de3d..67c399e4 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -42,7 +42,10 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } func refreshOpenMenusAfterExplicitStoreAction() { - self.invalidateMenus(refreshOpenMenus: true) + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) } @objc func refreshNow() { diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 2df36372..9d437f01 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -240,14 +240,25 @@ extension StatusItemController { } func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + let key = ObjectIdentifier(menu) + guard self.openMenus[key] != nil else { return } + if self.isHostedSubviewMenu(menu) { + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + return + } + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) } func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + let key = ObjectIdentifier(menu) + guard self.openMenus[key] != nil else { return } guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) + self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.applyIcon(phase: nil) #if DEBUG self._test_openMenuRebuildObserver?(menu) diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift index 6611fdd5..ca096bb6 100644 --- a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore extension StatusItemController { @@ -34,7 +35,10 @@ extension StatusItemController { self.applyIcon(phase: phase) } - func navigateProviderSwitcher(_ direction: StatusItemMenuProviderNavigationDirection) { + func navigateProviderSwitcher( + _ direction: StatusItemMenuProviderNavigationDirection, + menu: NSMenu? = nil) + { guard self.shouldMergeIcons else { return } let enabledProviders = self.store.enabledProvidersForDisplay() guard enabledProviders.count > 1 else { return } @@ -59,6 +63,12 @@ extension StatusItemController { let delta = direction == .next ? 1 : -1 let nextIndex = (currentIndex + delta + selections.count) % selections.count let selection = selections[nextIndex] + let menuProvider: UsageProvider = switch selection { + case .overview: + self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex + case let .provider(provider): + provider + } self.preservingMergedSwitcherContentCachesDuringInvalidation { switch selection { case .overview: @@ -70,7 +80,13 @@ extension StatusItemController { self.lastMenuProvider = provider } self.lastMergedSwitcherSelection = selection - self.refreshProviderSelectionDependentUI(refreshOpenMenus: true, deferRendering: true) + self.refreshProviderSelectionDependentUI(deferRendering: true) + } + let trackedMenu = menu ?? self.providerSwitcherShortcutMenuID.flatMap { self.openMenus[$0] } + if let trackedMenu { + self.requestProviderSwitcherMenuRebuild( + trackedMenu, + provider: menuProvider) } } diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 034d1699..b8eb310c 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -109,7 +109,7 @@ extension StatusItemController { return self.selectProviderSwitcherSegment(at: index, menu: menu) } if let direction = StatusItemMenu.providerNavigationDirection(for: event) { - self.navigateProviderSwitcher(direction) + self.navigateProviderSwitcher(direction, menu: menu) return true } return false diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 21e57ae4..e25496bb 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -629,7 +629,7 @@ extension StatusMenuTests { } @Test - func `explicit store actions refresh a visible open menu`() async { + func `explicit store actions defer visible parent menu rebuild`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -660,17 +660,18 @@ extension StatusMenuTests { defer { controller._test_openMenuRebuildObserver = nil } controller.refreshOpenMenusAfterExplicitStoreAction() - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } #expect(controller.menuContentVersion != openedVersion) - #expect(rebuildCount == 1) - #expect(controller.menuVersions[key] != openedVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) } @Test - func `repeated explicit store actions coalesce to one open menu rebuild`() async { + func `repeated explicit store actions keep parent rebuild deferred`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -703,16 +704,17 @@ extension StatusMenuTests { controller.refreshOpenMenusAfterExplicitStoreAction() controller.refreshOpenMenusAfterExplicitStoreAction() - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } - #expect(rebuildCount == 1) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) } @Test - func `explicit refresh rebuilds stale parent after hosted submenu closes`() async { + func `explicit refresh keeps stale parent deferred after hosted submenu closes`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -755,13 +757,14 @@ extension StatusMenuTests { #expect(controller.menuVersions[menuKey] == openedVersion) controller.menuDidClose(submenu) - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } #expect(controller.openMenus[submenuKey] == nil) - #expect(rebuildCount == 1) - #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[menuKey] == openedVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(menuKey)) } @Test diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 1da133c6..b8c5e885 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -494,20 +494,37 @@ struct StatusMenuSwitcherClickTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + controller.menuRefreshEnabledOverrideForTesting = true + defer { controller.releaseStatusItemsForTesting() } let menu = try #require(controller.makeMenu() as? StatusItemMenu) controller.menuWillOpen(menu) #expect(menu.items.first?.view is ProviderSwitcherView) + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true) - await Task.yield() + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .claude) + #expect(rebuildCount == 1) #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true) - await Task.yield() + for _ in 0..<100 where rebuildCount == 1 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 2) } @Test From 050b13925da472722b913e2ecb0bc745af7017db Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:44:57 +0800 Subject: [PATCH 08/51] feat: support more MiMo browsers (#1350) Add bounded automatic cookie discovery for Safari, Chrome variants, Firefox, and Edge. Surface browser permission failures and defer cookie I/O to fetch time. Co-authored-by: Peter Steinberger Co-authored-by: Yuxin Qiao --- CHANGELOG.md | 1 + .../MiMo/MiMoProviderImplementation.swift | 4 +- .../Resources/ca.lproj/Localizable.strings | 2 +- .../Resources/en.lproj/Localizable.strings | 2 +- .../Resources/es.lproj/Localizable.strings | 2 +- .../Resources/fr.lproj/Localizable.strings | 2 +- .../Resources/nl.lproj/Localizable.strings | 2 +- .../Resources/pt-BR.lproj/Localizable.strings | 2 +- .../Resources/sv.lproj/Localizable.strings | 2 +- .../Resources/uk.lproj/Localizable.strings | 2 +- .../Resources/vi.lproj/Localizable.strings | 2 +- .../zh-Hans.lproj/Localizable.strings | 2 +- .../zh-Hant.lproj/Localizable.strings | 2 +- .../Providers/MiMo/MiMoCookieImporter.swift | 33 +++++++++++++-- .../MiMo/MiMoProviderDescriptor.swift | 42 ++++--------------- .../Providers/MiMo/MiMoUsageFetcher.swift | 13 ++++-- .../CodexBarCore/Providers/Providers.swift | 11 +++++ .../BrowserCookieOrderLabelTests.swift | 12 ++++++ Tests/CodexBarTests/MiMoProviderTests.swift | 23 ++++++++++ docs/mimo.md | 7 +++- 20 files changed, 112 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a853b1..99c139e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! - Menu bar: defer data-refresh rebuilds until the tracked menu closes, avoiding multi-second WindowServer stalls with slower providers such as Grok (#1376). Thanks @jangisaac-dev! +- Xiaomi MiMo: import automatic session cookies from Safari, Chrome variants, Firefox, and Edge instead of limiting discovery to Chrome (#1304). Thanks @Yuxin-Qiao! ## 0.32.5 — 2026-06-09 diff --git a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift index bcb9b68c..361f2d51 100644 --- a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift @@ -39,7 +39,7 @@ struct MiMoProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.miMoCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + auto: "Automatic imports browser cookies from Xiaomi MiMo.", manual: "Paste a Cookie header from platform.xiaomimimo.com.", off: "Xiaomi MiMo cookies are disabled.") } @@ -48,7 +48,7 @@ struct MiMoProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "mimo-cookie-source", title: "Cookie source", - subtitle: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + subtitle: "Automatic imports browser cookies from Xiaomi MiMo.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 4004139a..2fe10733 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -765,7 +765,7 @@ "Antigravity login failed" = "L'inici de sessió d'Antigravity ha fallat"; "Antigravity login timed out" = "L'inici de sessió d'Antigravity ha esgotat el temps"; "Auth source" = "Font d'autenticació"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automàticament les galetes de Chrome de Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automàticament les galetes del navegador de Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automàticament dades de sessió de Windsurf del localStorage de Chromium."; "Automatic imports browser cookies from Bailian." = "Importa automàticament galetes del navegador de Bailian."; "Automatically imports browser cookies." = "Importa automàticament galetes del navegador."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 5fada87c..114cfdf9 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -908,7 +908,7 @@ "Antigravity login failed" = "Antigravity login failed"; "Antigravity login timed out" = "Antigravity login timed out"; "Auth source" = "Auth source"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Automatic imports Chrome browser cookies from Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Automatic imports browser cookies from Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatic imports Windsurf session data from Chromium browser localStorage."; "Automatic imports browser cookies from Bailian." = "Automatic imports browser cookies from Bailian."; "Automatically imports browser cookies." = "Automatically imports browser cookies."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 8da82dde..308756f6 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -765,7 +765,7 @@ "Antigravity login failed" = "Error al iniciar sesión en Antigravity"; "Antigravity login timed out" = "El inicio de sesión en Antigravity agotó el tiempo"; "Auth source" = "Fuente de autenticación"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automáticamente las cookies de Chrome desde Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automáticamente las cookies del navegador desde Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automáticamente datos de sesión de Windsurf desde localStorage de Chromium."; "Automatic imports browser cookies from Bailian." = "Importa automáticamente cookies del navegador desde Bailian."; "Automatically imports browser cookies." = "Importa automáticamente cookies del navegador."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 8932f84a..4baa2812 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -909,7 +909,7 @@ "Antigravity login failed" = "La connexion antigravité a échoué"; "Antigravity login timed out" = "La connexion antigravité a expiré"; "Auth source" = "Source d'authentification"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importe automatiquement les cookies du navigateur Chrome de Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importe automatiquement les cookies du navigateur de Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importe automatiquement les données de session Windsurf à partir du navigateur Chromium localStorage."; "Automatic imports browser cookies from Bailian." = "Importe automatiquement les cookies du navigateur depuis Bailian."; "Automatically imports browser cookies." = "Importe automatiquement les cookies du navigateur."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 6034738e..2d5a860f 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -909,7 +909,7 @@ "Antigravity login failed" = "Antigravity-aanmelding mislukt"; "Antigravity login timed out" = "Er is een time-out opgetreden bij het inloggen op anti-zwaartekracht"; "Auth source" = "Authenticatiebron"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importeert automatisch Chrome-browsercookies van Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importeert automatisch browsercookies van Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatische import van windsurfsessiegegevens uit de Chromium-browser localStorage."; "Automatic imports browser cookies from Bailian." = "Importeert automatisch browsercookies van Bailian."; "Automatically imports browser cookies." = "Importeert automatisch browsercookies."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 93304a89..f58ef465 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -908,7 +908,7 @@ "Antigravity login failed" = "Falha no login do Antigravity"; "Antigravity login timed out" = "Tempo esgotado no login do Antigravity"; "Auth source" = "Fonte de autenticação"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automaticamente cookies do Chrome do Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automaticamente cookies do navegador do Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automaticamente dados de sessão do Windsurf do localStorage do Chromium."; "Automatic imports browser cookies from Bailian." = "Importa automaticamente cookies do navegador do Bailian."; "Automatically imports browser cookies." = "Importa automaticamente cookies do navegador."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index b777ad41..ca7e2295 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1045,7 +1045,7 @@ "stale data" = "inaktuella data"; "Quota" = "Kvot"; "Auth source" = "Autentiseringskälla"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importerar Chrome-cookies från Xiaomi MiMo automatiskt."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importerar webbläsarcookies från Xiaomi MiMo automatiskt."; "No usage configured." = "Ingen användning konfigurerad."; "Extra usage" = "Extra användning"; "That account is no longer available in CodexBar. Refresh the account list and try again." = "Kontot finns inte längre i CodexBar. Uppdatera kontolistan och försök igen."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index fbcadb84..84322b58 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -909,7 +909,7 @@ "Antigravity login failed" = "Помилка входу в Antigravity"; "Antigravity login timed out" = "Час очікування входу в антигравітацію минув"; "Auth source" = "Джерело авторизації"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Автоматично імпортує файли cookie браузера Chrome із Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Автоматично імпортує файли cookie браузера з Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Автоматично імпортує дані сесії Windsurf із локального сховища браузера Chromium."; "Automatic imports browser cookies from Bailian." = "Автоматично імпортує файли cookie браузера з Bailian."; "Automatically imports browser cookies." = "Автоматично імпортує файли cookie браузера."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 48749b29..fd111651 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -908,7 +908,7 @@ "Antigravity login failed" = "Đăng nhập chống trọng lực không thành công"; "Antigravity login timed out" = "Đã hết thời gian đăng nhập chống trọng lực"; "Auth source" = "Nguồn xác thực"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Tự động nhập cookie trình duyệt Chrome từ Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Tự động nhập cookie trình duyệt từ Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Tự động nhập dữ liệu phiên Windsurf từ localStorage của trình duyệt Chrome."; "Automatic imports browser cookies from Bailian." = "Tự động nhập cookie trình duyệt từ Bailian."; "Automatically imports browser cookies." = "Tự động nhập cookie trình duyệt."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index c500c714..6b1c03a8 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -882,7 +882,7 @@ "Antigravity login failed" = "Antigravity 登录失败"; "Antigravity login timed out" = "Antigravity 登录超时"; "Auth source" = "认证来源"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自动导入 Xiaomi MiMo 的 Chrome 浏览器 Cookie。"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自动导入 Xiaomi MiMo 的浏览器 Cookie。"; "Automatic imports Windsurf session data from Chromium browser localStorage." = "自动从 Chromium 浏览器 localStorage 导入 Windsurf 会话数据。"; "Automatic imports browser cookies from Bailian." = "自动导入 Bailian 的浏览器 Cookie。"; "Automatically imports browser cookies." = "自动导入浏览器 Cookie。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index efe33c21..6b3589c5 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -778,7 +778,7 @@ "Antigravity login failed" = "Antigravity 登入失敗"; "Antigravity login timed out" = "Antigravity 登入逾時"; "Auth source" = "認證來源"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自動匯入 Xiaomi MiMo 的 Chrome 瀏覽器 Cookie。"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自動匯入 Xiaomi MiMo 的瀏覽器 Cookie。"; "Automatic imports Windsurf session data from Chromium browser localStorage." = "自動從 Chromium 瀏覽器 localStorage 匯入 Windsurf 工作階段資料。"; "Automatic imports browser cookies from Bailian." = "自動匯入 Bailian 的瀏覽器 Cookie。"; "Automatically imports browser cookies." = "自動匯入瀏覽器 Cookie。"; diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift index 6cb2470a..857fbe10 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -129,8 +129,26 @@ public enum MiMoCookieImporter { return try override(browserDetection, logger) } + return try self.importSessions( + browserDetection: browserDetection, + logger: logger, + loadRecords: { browserSource, query, log in + try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: log) + }) + } + + static func importSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil, + loadRecords: (Browser, BrowserCookieQuery, ((String) -> Void)?) throws + -> [BrowserCookieStoreRecords]) throws -> [SessionInfo] + { let log: (String) -> Void = { msg in logger?("[mimo-cookie] \(msg)") } var sessions: [SessionInfo] = [] + var accessDeniedHints: [String] = [] let installed = miMoCookieImportOrder.cookieImportCandidates(using: browserDetection) let labels = installed.map(\.displayName).joined(separator: ", ") log("Cookie import candidates: \(labels)") @@ -138,17 +156,24 @@ public enum MiMoCookieImporter { for browserSource in installed { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( - matching: query, - in: browserSource, - logger: log) + let sources = try loadRecords(browserSource, query, log) sessions.append(contentsOf: self.sessionInfos(from: sources, origin: query.origin)) + } catch let error as BrowserCookieError { + BrowserCookieAccessGate.recordIfNeeded(error) + if let hint = error.accessDeniedHint { + accessDeniedHints.append(hint) + } + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") } catch { BrowserCookieAccessGate.recordIfNeeded(error) log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") } } + if sessions.isEmpty, !accessDeniedHints.isEmpty { + let details = Array(Set(accessDeniedHints)).sorted().joined(separator: " ") + throw MiMoSettingsError.missingCookie(details: details) + } return sessions } diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift index 42e3eb6c..add4447b 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift @@ -1,25 +1,11 @@ import CodexBarMacroSupport import Foundation -#if os(macOS) -import SweetCookieKit -#endif - @ProviderDescriptorRegistration @ProviderDescriptorDefinition public enum MiMoProviderDescriptor { static func makeDescriptor() -> ProviderDescriptor { - #if os(macOS) - let browserOrder: BrowserCookieImportOrder = [ - .chrome, - .chromeBeta, - .chromeCanary, - ] - #else - let browserOrder: BrowserCookieImportOrder? = nil - #endif - - return ProviderDescriptor( + ProviderDescriptor( id: .mimo, metadata: ProviderMetadata( id: .mimo, @@ -35,7 +21,7 @@ public enum MiMoProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, - browserCookieOrder: browserOrder, + browserCookieOrder: ProviderBrowserCookieDefaults.mimoCookieImportOrder, dashboardURL: "https://platform.xiaomimimo.com/#/console/balance", statusPageURL: nil), branding: ProviderBranding( @@ -64,25 +50,13 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { if context.settings?.mimo?.cookieSource == .manual { return Self.resolveManualCookieHeader(context: context) != nil } - if Self.resolveManualCookieHeader(context: context) != nil { - return true - } - - #if os(macOS) - if let cached = CookieHeaderCache.load(provider: .mimo), - MiMoCookieHeader.normalizedHeader(from: cached.cookieHeader) != nil - { - return true - } - return MiMoCookieImporter.hasSession(browserDetection: context.browserDetection) - #else - return false - #endif + // Fetch resolves the session so missing-cookie and browser-permission errors stay actionable. + return true } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { guard context.settings?.mimo?.cookieSource != .off else { - throw MiMoSettingsError.missingCookie + throw MiMoSettingsError.missingCookie() } if context.settings?.mimo?.cookieSource == .manual { guard let manualCookie = Self.resolveManualCookieHeader(context: context) else { @@ -123,7 +97,7 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { let sessions = try MiMoCookieImporter.importSessions(browserDetection: context.browserDetection) guard !sessions.isEmpty else { if let lastError { throw lastError } - throw MiMoSettingsError.missingCookie + throw MiMoSettingsError.missingCookie() } for session in sessions { @@ -146,9 +120,9 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { } if let lastError { throw lastError } - throw MiMoSettingsError.missingCookie + throw MiMoSettingsError.missingCookie() #else - throw MiMoSettingsError.missingCookie + throw MiMoSettingsError.missingCookie() #endif } diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift index 7f443d58..446e4291 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift @@ -3,14 +3,19 @@ import Foundation import FoundationNetworking #endif -public enum MiMoSettingsError: LocalizedError, Sendable { - case missingCookie +public enum MiMoSettingsError: LocalizedError, Sendable, Equatable { + case missingCookie(details: String? = nil) case invalidCookie public var errorDescription: String? { switch self { - case .missingCookie: - "No Xiaomi MiMo browser session found. Log in at platform.xiaomimimo.com first." + case let .missingCookie(details): + [ + "No Xiaomi MiMo browser session found. Log in at platform.xiaomimimo.com first.", + details, + ] + .compactMap(\.self) + .joined(separator: " ") case .invalidCookie: "Xiaomi MiMo requires the api-platform_serviceToken and userId cookies." } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 0727067a..aa8d0a3c 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -220,4 +220,15 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// MiMo Auto: Safari first (no Keychain prompt), keep the existing Chrome-family + /// entries from main, and add Firefox/Edge per #1304. Other Chromium forks stay on + /// Manual import to avoid scanning the full SweetCookieKit default order. + public static var mimoCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.safari, .chrome, .chromeBeta, .chromeCanary, .firefox, .edge] + #else + nil + #endif + } } diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index ac46fe82..666007d0 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -42,5 +42,17 @@ struct BrowserCookieOrderStatusStringTests { func `opencode automatic cookies keep chrome only default`() { #expect(OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) == [.chrome]) } + + @Test + func `mimo cookie import order supports safari firefox and edge`() { + let order = ProviderDefaults.metadata[.mimo]?.browserCookieOrder ?? Browser.defaultImportOrder + #expect(order == ProviderBrowserCookieDefaults.mimoCookieImportOrder) + #expect(order == [.safari, .chrome, .chromeBeta, .chromeCanary, .firefox, .edge]) + #expect(order.first == .safari) + #expect(order.contains(.firefox)) + #expect(order.contains(.edge)) + #expect(!order.contains(.arc)) + } + #endif } diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 411ed124..fe27c441 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -488,6 +488,29 @@ struct MiMoProviderTests { } } + @Test + func `mimo cookie importer surfaces safari access denial`() throws { + let detection = BrowserDetection( + homeDirectory: "/tmp/codexbar-mimo-browser-test", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil }) + + do { + _ = try MiMoCookieImporter.importSessions( + browserDetection: detection, + loadRecords: { browser, _, _ in + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Grant CodexBar Full Disk Access to read Safari cookies.") + }) + Issue.record("Expected Safari access denial") + } catch let error as MiMoSettingsError { + #expect(error.localizedDescription.contains("Full Disk Access")) + #expect(error.localizedDescription.contains("Safari")) + } + } + @Test func `mimo web strategy retries imported sessions after decode failure`() async throws { KeychainCacheStore.setTestStoreForTesting(true) diff --git a/docs/mimo.md b/docs/mimo.md index be6fb063..772f334f 100644 --- a/docs/mimo.md +++ b/docs/mimo.md @@ -22,6 +22,10 @@ The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo consol 2. Enable **Xiaomi MiMo** 3. Leave **Cookie source** on **Auto** (recommended) +CodexBar imports cookies from these browsers in order: **Safari**, **Chrome** / **Chrome Beta** / **Chrome Canary**, **Firefox**, and **Microsoft Edge**. Switch to **Manual** and paste a `Cookie:` header if your active MiMo session lives in Arc, Brave, or another browser profile CodexBar does not auto-detect. + +Safari cookie import may require granting CodexBar Full Disk Access in **System Settings → Privacy & Security**. + ### Manual cookie import (optional) 1. Open `https://platform.xiaomimimo.com/#/console/balance` @@ -39,12 +43,13 @@ The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo consol - MiMo currently exposes **balance only** - Token cost, status polling, debug log output, and widgets are not supported yet +- Auto import covers Safari, Chrome variants, Firefox, and Edge only; other browsers use **Manual** mode ## Troubleshooting ### “No Xiaomi MiMo browser session found” -Log in at `https://platform.xiaomimimo.com/#/console/balance` in Chrome, then refresh CodexBar. +Log in at `https://platform.xiaomimimo.com/#/console/balance` in Safari, Chrome, Firefox, or Edge, then refresh CodexBar. If your session lives in another browser, switch the MiMo provider to **Cookie source → Manual** and paste the `Cookie:` header instead. ### “Xiaomi MiMo requires the api-platform_serviceToken and userId cookies” From f51db0e892ef38249caf43f8527dbc333875c511 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:46:15 +0800 Subject: [PATCH 09/51] fix: add Claude web session recovery action (#1378) Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../Claude/ClaudeProviderImplementation.swift | 27 +++ .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/vi.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../ClaudeWebRecoveryMenuTests.swift | 157 ++++++++++++++++++ 14 files changed, 196 insertions(+) create mode 100644 Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c139e1..2f923746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! +- Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! - Menu bar: defer data-refresh rebuilds until the tracked menu closes, avoiding multi-second WindowServer stalls with slower providers such as Grok (#1376). Thanks @jangisaac-dev! - Xiaomi MiMo: import automatic session cookies from Safari, Chrome variants, Firefox, and Edge instead of limiting discovery to Chrome (#1304). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 9f7032ed..f90dff11 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -228,10 +228,37 @@ struct ClaudeProviderImplementation: ProviderImplementation { func loginMenuAction(context: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { + if self.shouldOpenBrowserForWebSessionError(context: context) { + return ("Re-login at claude.ai", .loginToProvider(url: "https://claude.ai/")) + } guard self.shouldOpenTerminalForOAuthError(store: context.store) else { return nil } return ("Open Terminal", .openTerminal(command: "claude")) } + @MainActor + private func shouldOpenBrowserForWebSessionError(context: ProviderMenuLoginContext) -> Bool { + let settings = context.settings.claudeSettingsSnapshot(tokenOverride: nil) + let source = settings.usageDataSource + guard source == .auto || source == .web, + settings.cookieSource == .auto, + let error = context.store.error(for: .claude) + else { return false } + + let sessionErrors = [ + ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + ClaudeWebAPIFetcher.FetchError.noSessionKeyFound.localizedDescription, + ClaudeWebAPIFetcher.FetchError.invalidSessionKey.localizedDescription, + ] + if sessionErrors.contains(error) { + return true + } + + guard error == ProviderFetchError.noAvailableStrategy(.claude).localizedDescription else { return false } + return context.store.fetchAttempts(for: .claude).contains { + $0.strategyID == "claude.web" && !$0.wasAvailable + } + } + @MainActor private func shouldOpenTerminalForOAuthError(store: UsageStore) -> Bool { guard store.error(for: .claude) != nil else { return false } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 2fe10733..a0ae225d 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -865,6 +865,7 @@ "Personal account" = "Compte personal"; "Project ID" = "ID de projecte"; "Re-auth" = "Reautentica"; +"Re-login at claude.ai" = "Torna a iniciar sessió a claude.ai"; "Re-authenticating…" = "S'està reautenticant…"; "Refresh Session" = "Actualitza la sessió"; "Refresh organizations" = "Actualitza organitzacions"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 114cfdf9..ee3c0b8f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1008,6 +1008,7 @@ "Personal account" = "Personal account"; "Project ID" = "Project ID"; "Re-auth" = "Re-auth"; +"Re-login at claude.ai" = "Re-login at claude.ai"; "Re-authenticating…" = "Re-authenticating…"; "Refresh Session" = "Refresh Session"; "Refresh organizations" = "Refresh organizations"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 308756f6..f4aa0057 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -865,6 +865,7 @@ "Personal account" = "Cuenta personal"; "Project ID" = "ID de proyecto"; "Re-auth" = "Reautenticar"; +"Re-login at claude.ai" = "Volver a iniciar sesión en claude.ai"; "Re-authenticating…" = "Reautenticando…"; "Refresh Session" = "Actualizar sesión"; "Refresh organizations" = "Actualizar organizaciones"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 4baa2812..6ae6c7e6 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1009,6 +1009,7 @@ "Personal account" = "Compte personnel"; "Project ID" = "ID du projet"; "Re-auth" = "Se reconnecter"; +"Re-login at claude.ai" = "Se reconnecter à claude.ai"; "Re-authenticating…" = "Réauthentification…"; "Refresh Session" = "Session de rafraîchissement"; "Refresh organizations" = "Actualiser les organisations"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 2d5a860f..ae229905 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1009,6 +1009,7 @@ "Personal account" = "Persoonlijk account"; "Project ID" = "Project-ID"; "Re-auth" = "Opnieuw verifiëren"; +"Re-login at claude.ai" = "Opnieuw aanmelden bij claude.ai"; "Re-authenticating…" = "Opnieuw authenticeren…"; "Refresh Session" = "Sessie vernieuwen"; "Refresh organizations" = "Vernieuw organisaties"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index f58ef465..300ee8ea 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1008,6 +1008,7 @@ "Personal account" = "Conta pessoal"; "Project ID" = "ID do projeto"; "Re-auth" = "Reautenticar"; +"Re-login at claude.ai" = "Entrar novamente no claude.ai"; "Re-authenticating…" = "Reautenticando…"; "Refresh Session" = "Atualizar sessão"; "Refresh organizations" = "Atualizar organizações"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ca7e2295..737a3f80 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1004,6 +1004,7 @@ "Credits used" = "Använda krediter"; "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din OpenAI-cookie-header så att extra Codex-översiktsdata kan hämtas. Klicka på OK för att fortsätta."; "Re-auth" = "Autentisera igen"; +"Re-login at claude.ai" = "Logga in igen på claude.ai"; "cache-miss input" = "cachemiss-indata"; "Day" = "Dag"; "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Valfritt. Gäller den konfigurerade Admin API-nyckeln. Valda tokenkonton ärver inte OPENAI_PROJECT_ID."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 84322b58..bcdf9bc6 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1009,6 +1009,7 @@ "Personal account" = "Особистий рахунок"; "Project ID" = "ID проекту"; "Re-auth" = "Повторна авторизація"; +"Re-login at claude.ai" = "Повторно увійти на claude.ai"; "Re-authenticating…" = "Повторна автентифікація…"; "Refresh Session" = "Оновити сеанс"; "Refresh organizations" = "Оновити організації"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index fd111651..b62fdbd1 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1008,6 +1008,7 @@ "Personal account" = "Tài khoản cá nhân"; "Project ID" = "ID dự án"; "Re-auth" = "Xác thực lại"; +"Re-login at claude.ai" = "Đăng nhập lại vào claude.ai"; "Re-authenticating…" = "Xác thực lại…"; "Refresh Session" = "Làm mới phiên"; "Refresh organizations" = "Làm mới tổ chức"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 6b1c03a8..76c5225f 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -982,6 +982,7 @@ "Personal account" = "个人账号"; "Project ID" = "项目 ID"; "Re-auth" = "重新认证"; +"Re-login at claude.ai" = "重新登录 claude.ai"; "Re-authenticating…" = "正在重新认证…"; "Refresh Session" = "刷新会话"; "Refresh organizations" = "刷新组织"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 6b3589c5..583994b0 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -878,6 +878,7 @@ "Personal account" = "個人帳號"; "Project ID" = "專案 ID"; "Re-auth" = "重新認證"; +"Re-login at claude.ai" = "重新登入 claude.ai"; "Re-authenticating…" = "正在重新認證…"; "Refresh Session" = "重新整理工作階段"; "Refresh organizations" = "重新整理組織"; diff --git a/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift b/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift new file mode 100644 index 00000000..e68ba10e --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift @@ -0,0 +1,157 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct ClaudeWebRecoveryMenuTests { + private func makeSettings() -> SettingsStore { + let suite = "ClaudeWebRecoveryMenuTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func actions( + error: String, + source: ClaudeUsageDataSource, + cookieSource: ProviderCookieSource = .auto, + selectedSessionKey: Bool = false, + attempts: [ProviderFetchAttempt] = []) -> [(String, MenuDescriptor.MenuAction)] + { + let settings = self.makeSettings() + settings.claudeUsageDataSource = source + if selectedSessionKey { + settings.addTokenAccount(provider: .claude, label: "Session", token: "sk-ant-session-token") + } + settings.claudeCookieSource = cookieSource + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store.errors[.claude] = error + store.lastFetchAttempts[.claude] = attempts + + return MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + .sections + .flatMap(\.entries) + .compactMap { entry in + guard case let .action(label, action) = entry else { return nil } + return (label, action) + } + } + + @Test + func `web session errors show claude relogin action`() { + let errors = [ + ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + ClaudeWebAPIFetcher.FetchError.noSessionKeyFound.localizedDescription, + ClaudeWebAPIFetcher.FetchError.invalidSessionKey.localizedDescription, + ] + + for error in errors { + let actions = self.actions(error: error, source: .web) + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + } + + @Test + func `auto source shows relogin action for terminal web session error`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .auto) + + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + + @Test + func `non-web source does not replace account action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .oauth) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `manual cookies do not show browser relogin action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .web, + cookieSource: .manual) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `selected session account does not show browser relogin action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .web, + cookieSource: .auto, + selectedSessionKey: true) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `unavailable web strategy shows relogin action`() { + let actions = self.actions( + error: ProviderFetchError.noAvailableStrategy(.claude).localizedDescription, + source: .web, + attempts: [ + ProviderFetchAttempt( + strategyID: "claude.web", + kind: .web, + wasAvailable: false, + errorDescription: nil), + ]) + + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + + @Test + func `generic unavailable error without web attempt keeps account action`() { + let actions = self.actions( + error: ProviderFetchError.noAvailableStrategy(.claude).localizedDescription, + source: .auto, + attempts: [ + ProviderFetchAttempt( + strategyID: "claude.cli", + kind: .cli, + wasAvailable: false, + errorDescription: nil), + ]) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `unrelated web error does not replace account action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.serverError(statusCode: 500).localizedDescription, + source: .web) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } +} From 7c0ed036e2e6dc826208236f1a792d0a02246c78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 03:55:49 -0700 Subject: [PATCH 10/51] perf: reuse hosted chart submenus (#1384) --- CHANGELOG.md | 1 + .../PlanUtilizationHistoryChartMenuView.swift | 48 +++--- .../PlanUtilizationHistoryStore.swift | 2 +- .../StatusItemController+HostedSubmenus.swift | 152 ++++++++++++++++-- .../CodexBar/StatusItemController+Menu.swift | 5 +- ...ItemController+MenuRefreshScheduling.swift | 2 +- Sources/CodexBar/StatusItemController.swift | 1 + .../CodexBar/UsageStore+PlanUtilization.swift | 8 +- ...UtilizationHistoryChartMenuViewTests.swift | 27 ++++ .../StatusMenuHostedSubmenuRefreshTests.swift | 124 ++++++++++++-- 10 files changed, 321 insertions(+), 49 deletions(-) create mode 100644 Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f923746..4a314180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! - Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! +- Menu: reuse unchanged hosted chart submenus and precompute utilization history models to reduce expand and hover stalls (#1379). Thanks @hhh2210! - Menu bar: defer data-refresh rebuilds until the tracked menu closes, avoiding multi-second WindowServer stalls with slower providers such as Grok (#1376). Thanks @jangisaac-dev! - Xiaomi MiMo: import automatic session cookies from Safari, Chrome variants, Firefox, and Edge instead of limiting discovery to Chrome (#1304). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index ae995cc1..5a5b36f8 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -64,8 +64,9 @@ struct PlanUtilizationHistoryChartMenuView: View { } private let provider: UsageProvider - private let histories: [PlanUtilizationSeriesHistory] - private let snapshot: UsageSnapshot? + private let visibleSeries: [VisibleSeries] + private let modelsBySeriesID: [String: Model] + private let emptyModel: Model private let width: CGFloat @State private var selectedSeriesID: String? @@ -78,32 +79,33 @@ struct PlanUtilizationHistoryChartMenuView: View { width: CGFloat) { self.provider = provider - self.histories = histories - self.snapshot = snapshot + let visibleSeries = Self.visibleSeries( + histories: histories, + provider: provider, + snapshot: snapshot) + let referenceDate = Date() + self.visibleSeries = visibleSeries + self.modelsBySeriesID = Dictionary(uniqueKeysWithValues: visibleSeries.map { + ($0.id, Self.makeModel(history: $0.history, provider: provider, referenceDate: referenceDate)) + }) + self.emptyModel = Self.emptyModel(provider: provider) self.width = width } var body: some View { - let visibleSeries = Self.visibleSeries( - histories: self.histories, - provider: self.provider, - snapshot: self.snapshot) - let effectiveSelectedSeries = visibleSeries.first(where: { $0.id == self.selectedSeriesID }) ?? visibleSeries - .first - let model = Self.makeModel( - history: effectiveSelectedSeries?.history, - provider: self.provider, - referenceDate: Date()) + let effectiveSelectedSeries = self.visibleSeries.first(where: { $0.id == self.selectedSeriesID }) + ?? self.visibleSeries.first + let model = effectiveSelectedSeries.flatMap { self.modelsBySeriesID[$0.id] } ?? self.emptyModel VStack(alignment: .leading, spacing: 10) { - if visibleSeries.count > 1 { + if self.visibleSeries.count > 1 { Picker(selection: Binding( get: { effectiveSelectedSeries?.id ?? "" }, set: { newValue in self.selectedSeriesID = newValue self.selectedPointID = nil })) { - ForEach(visibleSeries) { series in + ForEach(self.visibleSeries) { series in Text(series.title).tag(series.id) } } label: { @@ -172,9 +174,9 @@ struct PlanUtilizationHistoryChartMenuView: View { .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading) - .task(id: visibleSeries.map(\.id).joined(separator: ",")) { - guard let firstVisibleSeries = visibleSeries.first else { return } - guard !visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return } + .task(id: self.visibleSeries.map(\.id).joined(separator: ",")) { + guard let firstVisibleSeries = self.visibleSeries.first else { return } + guard !self.visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return } self.selectedSeriesID = firstVisibleSeries.id self.selectedPointID = nil } @@ -228,12 +230,12 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private nonisolated static func mergedEntries( + nonisolated static func mergedEntries( _ entries: [PlanUtilizationHistoryEntry]) -> [PlanUtilizationHistoryEntry] { - entries.reduce(into: []) { result, entry in - guard !result.contains(entry) else { return } - result.append(entry) + var seen: Set = [] + return entries.filter { entry in + seen.insert(entry).inserted } } diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 13b6b4d9..e9843f30 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -28,7 +28,7 @@ struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, Expressib } } -struct PlanUtilizationHistoryEntry: Codable, Equatable { +struct PlanUtilizationHistoryEntry: Codable, Equatable, Hashable { let capturedAt: Date let usedPercent: Double let resetsAt: Date? diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index b4c7bfad..71b1d417 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -3,6 +3,12 @@ import CodexBarCore import SwiftUI extension StatusItemController { + private struct HostedSubviewIdentity { + let chartID: String + let provider: UsageProvider? + let providerRawValue: String? + } + func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ Self.usageBreakdownChartID, @@ -37,16 +43,21 @@ extension StatusItemController { return submenu } - func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu, width requestedWidth: CGFloat? = nil) { + @discardableResult + func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu, width requestedWidth: CGFloat? = nil) -> Bool { guard let placeholder = menu.items.first, menu.items.count == 1, placeholder.view == nil, let chartID = placeholder.representedObject as? String else { - return + return false } let width = requestedWidth ?? self.renderedMenuWidth(for: menu.supermenu ?? menu) + let identity = HostedSubviewIdentity( + chartID: chartID, + provider: placeholder.toolTip.flatMap(UsageProvider.init(rawValue:)), + providerRawValue: placeholder.toolTip) menu.removeAllItems() let didHydrate: Bool = switch chartID { @@ -90,8 +101,14 @@ extension StatusItemController { false } - guard !didHydrate else { return } - self.appendHostedSubviewUnavailableItem(to: menu, chartID: chartID, providerRawValue: placeholder.toolTip) + if !didHydrate { + self.appendHostedSubviewUnavailableItem( + to: menu, + chartID: chartID, + providerRawValue: placeholder.toolTip) + } + self.recordHostedSubviewRenderSignature(for: menu, identity: identity, width: width) + return true } func refreshHostedSubviewMenu(_ menu: NSMenu) { @@ -100,6 +117,13 @@ extension StatusItemController { self.refreshHostedSubviewHeights(in: menu) return } + let signature = self.hostedSubviewRenderSignature(identity: identity, width: width) + if self.hostedSubviewRenderSignatures.object(forKey: menu) as String? == signature { + if identity.chartID == Self.zaiHourlyUsageChartID { + self.refreshHostedSubviewHeights(in: menu) + } + return + } menu.removeAllItems() let didHydrate: Bool = switch identity.chartID { @@ -135,22 +159,21 @@ extension StatusItemController { false } - if didHydrate { - self.refreshHostedSubviewHeights(in: menu) - } else { + if !didHydrate { self.appendHostedSubviewUnavailableItem( to: menu, chartID: identity.chartID, providerRawValue: identity.provider?.rawValue ?? identity.providerRawValue) } + self.hostedSubviewRenderSignatures.setObject(signature as NSString, forKey: menu) } private func hostedSubviewIdentity(for menu: NSMenu) - -> (chartID: String, provider: UsageProvider?, providerRawValue: String?)? { + -> HostedSubviewIdentity? { for item in menu.items { guard let chartID = item.representedObject as? String else { continue } let providerRawValue = item.toolTip - return ( + return HostedSubviewIdentity( chartID: chartID, provider: providerRawValue.flatMap(UsageProvider.init(rawValue:)), providerRawValue: providerRawValue) @@ -158,6 +181,117 @@ extension StatusItemController { return nil } + private func recordHostedSubviewRenderSignature( + for menu: NSMenu, + identity: HostedSubviewIdentity, + width: CGFloat) + { + let signature = self.hostedSubviewRenderSignature(identity: identity, width: width) + self.hostedSubviewRenderSignatures.setObject(signature as NSString, forKey: menu) + } + + private func hostedSubviewRenderSignature( + identity: HostedSubviewIdentity, + width: CGFloat) -> String + { + let contentSignature: String = switch identity.chartID { + case Self.usageBreakdownChartID: + Self.dashboardBreakdownReadinessSignature( + OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? [])) + case Self.creditsHistoryChartID: + Self.dashboardBreakdownReadinessSignature(self.store.openAIDashboard?.dailyBreakdown ?? []) + case Self.costHistoryChartID: + identity.provider.map(self.costHistoryRenderSignature(for:)) ?? "missing-provider" + case Self.usageHistoryChartID: + identity.provider.map(self.usageHistoryRenderSignature(for:)) ?? "missing-provider" + case Self.storageBreakdownID: + identity.provider.map(self.storageBreakdownRenderSignature(for:)) ?? "missing-provider" + case Self.zaiHourlyUsageChartID: + identity.provider.map(self.zaiHourlyUsageRenderSignature(for:)) ?? "missing-provider" + default: + "unknown" + } + return [ + identity.chartID, + identity.providerRawValue ?? "", + String(Double(width).bitPattern, radix: 16), + contentSignature, + ].joined(separator: "|") + } + + private func costHistoryRenderSignature(for provider: UsageProvider) -> String { + guard let snapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return "none" } + return [ + snapshot.currencyCode, + "\(snapshot.historyDays)", + snapshot.historyLabel ?? "", + snapshot.last30DaysCostUSD.map { String($0.bitPattern, radix: 16) } ?? "nil", + String(reflecting: snapshot.daily), + ].joined(separator: "|") + } + + private func usageHistoryRenderSignature(for provider: UsageProvider) -> String { + let snapshot = self.store.snapshot(for: provider) + let selection = self.store.planUtilizationHistorySelection(for: provider) + return [ + "\(self.store.planUtilizationHistoryRevision)", + "\(Int(Date().timeIntervalSince1970 / 60))", + selection.accountKey ?? "unscoped", + snapshot?.primary == nil ? "0" : "1", + snapshot?.secondary == nil ? "0" : "1", + snapshot?.tertiary == nil ? "0" : "1", + ].joined(separator: "|") + } + + private func storageBreakdownRenderSignature(for provider: UsageProvider) -> String { + guard let footprint = self.store.storageFootprint(for: provider) else { return "none" } + let components = footprint.components + .map { "\($0.path)=\($0.totalBytes)" } + .joined(separator: ";") + return [ + "\(footprint.totalBytes)", + footprint.paths.joined(separator: ";"), + footprint.missingPaths.joined(separator: ";"), + footprint.unreadablePaths.joined(separator: ";"), + components, + String(Double(self.storageBreakdownMenuMaxHeight()).bitPattern, radix: 16), + ].joined(separator: "|") + } + + private func zaiHourlyUsageRenderSignature(for provider: UsageProvider) -> String { + guard let modelUsage = self.store.snapshot(for: provider)?.zaiUsage?.modelUsage else { return "none" } + return Self.zaiHourlyUsageRenderSignature(modelUsage: modelUsage, now: Date()) + } + + static func zaiHourlyUsageRenderSignature(modelUsage: ZaiModelUsageData, now: Date) -> String { + let models = modelUsage.modelDataList + .map { model in + let usage = model.tokensUsage + .map { $0.map(String.init) ?? "nil" } + .joined(separator: ",") + return "\(model.modelName ?? "")=\(usage)" + } + .joined(separator: ";") + let ranges: [ZaiHourlyRange] = [.today(referenceDate: now), .last24h] + let visibleBars = ranges + .map { range in + ZaiHourlyBars.from(modelData: modelUsage, range: range, now: now) + .map { bar in + let segments = bar.segments + .map { "\($0.model)=\($0.tokens)" } + .joined(separator: ",") + return "\(bar.label):\(segments)" + } + .joined(separator: ";") + } + return [ + modelUsage.xTime.joined(separator: ","), + models, + visibleBars.joined(separator: "|"), + ].joined(separator: "|") + } + private func appendHostedSubviewUnavailableItem( to menu: NSMenu, chartID: String, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index db4b49ab..c0e254bb 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -84,8 +84,9 @@ extension StatusItemController { let menuTrackingWasIdle = self.openMenus.isEmpty if self.isHostedSubviewMenu(menu) { - self.hydrateHostedSubviewMenuIfNeeded(menu) - self.refreshHostedSubviewHeights(in: menu) + if !self.hydrateHostedSubviewMenuIfNeeded(menu) { + self.refreshHostedSubviewMenu(menu) + } if self.isMenuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "submenu open") } diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index f9aab228..4980a019 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -127,7 +127,7 @@ extension StatusItemController { return parts.joined(separator: "|") } - private static func dashboardBreakdownReadinessSignature( + static func dashboardBreakdownReadinessSignature( _ breakdown: [OpenAIDashboardDailyBreakdown]) -> String { breakdown diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dd246f1a..7d28152d 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -118,6 +118,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var latestRequiredMenuRebuildVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] var menuReadinessSignatures: [ObjectIdentifier: String] = [:] + let hostedSubviewRenderSignatures = NSMapTable.weakToStrongObjects() var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var measuredStandardMenuWidthCache: [String: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index ad01e746..29406373 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -36,6 +36,12 @@ extension UsageStore { } func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationSeriesHistory] { + self.planUtilizationHistorySelection(for: provider).histories + } + + func planUtilizationHistorySelection(for provider: UsageProvider) + -> (accountKey: String?, histories: [PlanUtilizationSeriesHistory]) + { var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let originalProviderBuckets = providerBuckets let accountKey = self.resolvePlanUtilizationAccountKey( @@ -50,7 +56,7 @@ extension UsageStore { await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } } - return providerBuckets.histories(for: accountKey) + return (accountKey, providerBuckets.histories(for: accountKey)) } func codexPlanUtilizationHistories(forVisibleAccount account: CodexVisibleAccount) diff --git a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift new file mode 100644 index 00000000..3d41598d --- /dev/null +++ b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct PlanUtilizationHistoryChartMenuViewTests { + @Test + func `merged entries preserve first occurrence order while removing duplicates`() { + let first = PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 100), + usedPercent: 10, + resetsAt: Date(timeIntervalSince1970: 200)) + let second = PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 300), + usedPercent: 20, + resetsAt: nil) + + let merged = PlanUtilizationHistoryChartMenuView.mergedEntries([ + first, + second, + first, + second, + ]) + + #expect(merged == [first, second]) + } +} diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 2a632305..86ad13c1 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -196,6 +196,81 @@ struct StatusMenuHostedSubmenuRefreshTests { } } + @Test + func `zai chart render signature follows time range boundaries`() throws { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + let beforeMidnight = try #require(formatter.date(from: "2026-01-01 23:30")) + let afterMidnight = try #require(formatter.date(from: "2026-01-02 00:30")) + let modelUsage = ZaiModelUsageData( + xTime: ["2026-01-01 23:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [100]), + ]) + + let before = StatusItemController.zaiHourlyUsageRenderSignature( + modelUsage: modelUsage, + now: beforeMidnight) + let after = StatusItemController.zaiHourlyUsageRenderSignature( + modelUsage: modelUsage, + now: afterMidnight) + + #expect(before != after) + } + + @Test + func `utilization chart invalidates when active account changes`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + Self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + let accounts = settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedClaudeSnapshots(in: store) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ + aliceKey: [Self.makePlanHistory(usedPercent: 20)], + bobKey: [Self.makePlanHistory(usedPercent: 50)], + ]) + settings.setActiveTokenAccountIndex(0, for: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + let aliceView = try #require(submenu.items.first?.view) + + settings.setActiveTokenAccountIndex(1, for: .claude) + controller.refreshHostedSubviewMenu(submenu) + + let bobView = try #require(submenu.items.first?.view) + #expect(bobView !== aliceView) + } + private func assertHostedChartItemHeightMatchesRefresh( chartID: String, provider: UsageProvider, @@ -286,6 +361,14 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(hydratedItem.toolTip == provider.rawValue) #expect(hydratedItem.view != nil) #expect(hydratedItem.title != "No data available") + let hydratedView = hydratedItem.view + let inflatedHeight = hydratedView.map { view -> CGFloat in + let inflatedHeight = view.frame.height + 100 + if chartID == StatusItemController.zaiHourlyUsageChartID { + view.frame.size.height = inflatedHeight + } + return inflatedHeight + } controller.refreshHostedSubviewMenu(submenu) @@ -294,6 +377,19 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(refreshedItem.toolTip == provider.rawValue) #expect(refreshedItem.view != nil) #expect(refreshedItem.title != "No data available") + #expect(refreshedItem.view === hydratedView) + if chartID == StatusItemController.zaiHourlyUsageChartID { + #expect(refreshedItem.view?.frame.height != inflatedHeight) + } + + if chartID == StatusItemController.costHistoryChartID, provider == .claude { + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(dailyCost: 2.34), provider: .claude) + controller.refreshHostedSubviewMenu(submenu) + + let changedItem = try #require(submenu.items.first) + #expect(changedItem.view != nil) + #expect(changedItem.view !== hydratedView) + } } private static func makeSettings() -> SettingsStore { @@ -370,15 +466,19 @@ struct StatusMenuHostedSubmenuRefreshTests { self.seedClaudeSnapshots(in: store) store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( unscoped: [ - PlanUtilizationSeriesHistory( - name: .session, - windowMinutes: 300, - entries: [ - PlanUtilizationHistoryEntry( - capturedAt: Date(timeIntervalSince1970: 1_700_000_000), - usedPercent: 24, - resetsAt: Date(timeIntervalSince1970: 1_700_018_000)), - ]), + self.makePlanHistory(usedPercent: 24), + ]) + } + + private static func makePlanHistory(usedPercent: Double) -> PlanUtilizationSeriesHistory { + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: usedPercent, + resetsAt: Date(timeIntervalSince1970: 1_700_018_000)), ]) } @@ -419,19 +519,19 @@ struct StatusMenuHostedSubmenuRefreshTests { store._setSnapshotForTesting(snapshot, provider: .zai) } - private static func makeTokenSnapshot() -> CostUsageTokenSnapshot { + private static func makeTokenSnapshot(dailyCost: Double = 1.23) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, last30DaysTokens: 123, - last30DaysCostUSD: 1.23, + last30DaysCostUSD: dailyCost, daily: [ CostUsageDailyReport.Entry( date: "2025-12-23", inputTokens: nil, outputTokens: nil, totalTokens: 123, - costUSD: 1.23, + costUSD: dailyCost, modelsUsed: nil, modelBreakdowns: nil), ], From 1246ec6fc3f7a06ced32922ae0580cf88f36c012 Mon Sep 17 00:00:00 2001 From: naoterumaker <64459858+naoterumaker@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:41:00 +0900 Subject: [PATCH 11/51] Fix: schedule idle prune so cached dashboard WebViews are evicted (#1386) * Schedule idle prune so cached dashboard WebViews are actually evicted The webview cache evicts entries idle longer than 60s, but prune() only ran on the next acquire/release. With an hourly refresh cadence the hidden ChatGPT WebView - and its WebContent (~500MB), GPU and Networking helper processes - stayed resident for the whole hour, effectively permanently. Schedule a prune after each release so eviction happens at the idle timeout the comment already promises. Co-Authored-By: Claude Fable 5 * fix: harden dashboard webview idle pruning --------- Co-authored-by: Naoteru Nakamura Co-authored-by: Claude Fable 5 Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../OpenAIDashboardWebViewCache.swift | 56 ++++++++++++++-- .../OpenAIDashboardWebViewCacheTests.swift | 67 +++++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a314180..66b12e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! - Menu: reuse unchanged hosted chart submenus and precompute utilization history models to reduce expand and hover stalls (#1379). Thanks @hhh2210! - Menu bar: defer data-refresh rebuilds until the tracked menu closes, avoiding multi-second WindowServer stalls with slower providers such as Grok (#1376). Thanks @jangisaac-dev! +- OpenAI Web: evict cached dashboard WebViews after their idle timeout even when no later cache activity occurs, releasing hidden WebKit helper processes (#1386). Thanks @naoterumaker! - Xiaomi MiMo: import automatic session cookies from Safari, Chrome variants, Firefox, and Edge instead of limiting discovery to Chrome (#1304). Thanks @Yuxin-Qiao! ## 0.32.5 — 2026-06-09 diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 116731b3..42170965 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -110,7 +110,9 @@ final class OpenAIDashboardWebViewCache { private var entries: [ObjectIdentifier: Entry] = [:] /// Keep the WebView alive only long enough for immediate retries/menu reopens. /// Long-lived hidden ChatGPT tabs still consume noticeable energy on some setups. - private let idleTimeout: TimeInterval = 60 + private let idleTimeout: TimeInterval + private var idlePruneWorkItem: DispatchWorkItem? + private var idlePruneGeneration = 0 /// Reuse the validated analytics page only for the immediate next handoff. private let preservedPageHandoffTimeout: TimeInterval = 5 private let blankURL = URL(string: "about:blank")! @@ -153,26 +155,34 @@ final class OpenAIDashboardWebViewCache { })(); """ + init(idleTimeout: TimeInterval = 60) { + self.idleTimeout = idleTimeout + } + private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) { entry.isBusy = false - entry.lastUsedAt = Date() + let now = Date() + entry.lastUsedAt = now self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage) self.prepareCachedWebViewForIdle( entry.webView, host: entry.host, preserveLoadedPage: preserveLoadedPage) - self.prune(now: Date()) + self.prune(now: now) + self.scheduleNextIdlePrune(now: now) } private func releaseNewEntry(_ entry: Entry, webView: WKWebView, preserveLoadedPage: Bool) { entry.isBusy = false - entry.lastUsedAt = Date() + let now = Date() + entry.lastUsedAt = now self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage) self.prepareCachedWebViewForIdle( webView, host: entry.host, preserveLoadedPage: preserveLoadedPage) - self.prune(now: Date()) + self.prune(now: now) + self.scheduleNextIdlePrune(now: now) } // MARK: - Testing support @@ -248,6 +258,7 @@ final class OpenAIDashboardWebViewCache { /// Clear all cached entries (for test isolation). func clearAllForTesting() { + self.cancelIdlePrune() for (_, entry) in self.entries { entry.clearPreservedPage() entry.host.close() @@ -430,9 +441,11 @@ final class OpenAIDashboardWebViewCache { entry.clearPreservedPage() Self.log.debug("OpenAI webview evicted") entry.host.close() + self.scheduleNextIdlePrune() } func evictAll() { + self.cancelIdlePrune() let existing = self.entries self.entries.removeAll() for (_, entry) in existing { @@ -464,6 +477,37 @@ final class OpenAIDashboardWebViewCache { host.hide() } + /// Schedule against the oldest idle entry so later releases cannot postpone its eviction. + private func scheduleNextIdlePrune(now: Date = Date()) { + self.cancelIdlePrune() + + guard let nextExpiry = self.entries.values + .filter({ !$0.isBusy }) + .map({ $0.lastUsedAt.addingTimeInterval(self.idleTimeout) }) + .min() + else { return } + + let generation = self.idlePruneGeneration + let workItem = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { + guard let self, self.idlePruneGeneration == generation else { return } + self.idlePruneWorkItem = nil + let pruneTime = Date() + self.prune(now: pruneTime) + self.scheduleNextIdlePrune(now: pruneTime) + } + } + self.idlePruneWorkItem = workItem + let delay = max(0, nextExpiry.timeIntervalSince(now)) + 0.01 + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func cancelIdlePrune() { + self.idlePruneGeneration &+= 1 + self.idlePruneWorkItem?.cancel() + self.idlePruneWorkItem = nil + } + private func prune(now: Date) { for entry in self.entries.values where !entry.isBusy && entry.hasExpiredPreservedPage(now: now) { entry.clearPreservedPage() @@ -475,7 +519,7 @@ final class OpenAIDashboardWebViewCache { } let expired = self.entries.filter { _, entry in - !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout + !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) >= self.idleTimeout } for (key, entry) in expired { entry.host.close() diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index fb88a410..5dfe093b 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -206,6 +206,73 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test + func `Idle prune is scheduled without future cache activity`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache(idleTimeout: 0.2) + let store = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + var lease: OpenAIDashboardWebViewLease? = try await cache.acquire( + websiteDataStore: store, + usageURL: url, + logger: nil) + lease?.release() + lease = nil + + #expect(cache.hasCachedEntry(for: store), "WebView should remain cached right after release") + + let deadline = Date().addingTimeInterval(5) + while cache.hasCachedEntry(for: store), Date() < deadline { + try? await Task.sleep(for: .milliseconds(100)) + } + + #expect( + !cache.hasCachedEntry(for: store), + "Expected the scheduled idle prune to evict the WebView without any further cache activity") + + cache.clearAllForTesting() + } + + @Test + func `Later release does not postpone an older idle entry`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache(idleTimeout: 0.5) + let firstStore = WKWebsiteDataStore.nonPersistent() + let secondStore = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let firstLease = try await cache.acquire( + websiteDataStore: firstStore, + usageURL: url, + logger: nil) + firstLease.release() + + try await Task.sleep(for: .milliseconds(250)) + + let secondLease = try await cache.acquire( + websiteDataStore: secondStore, + usageURL: url, + logger: nil) + secondLease.release() + + let firstDeadline = Date().addingTimeInterval(1.5) + while cache.hasCachedEntry(for: firstStore), Date() < firstDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + + #expect(!cache.hasCachedEntry(for: firstStore), "Expected the oldest idle entry to be pruned first") + #expect(cache.hasCachedEntry(for: secondStore), "A later release should keep its own idle window") + + let secondDeadline = Date().addingTimeInterval(1) + while cache.hasCachedEntry(for: secondStore), Date() < secondDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + + #expect(!cache.hasCachedEntry(for: secondStore), "Expected the next idle deadline to be scheduled") + cache.clearAllForTesting() + } + @Test func `Reused page reset clears one shot scraper globals`() async throws { if self.shouldSkipOnCI() { return } From a317098387970949c6b15edbfb8294e1edda94ef Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:22:02 +0800 Subject: [PATCH 12/51] Fix Doubao false 100% request usage (#1383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(doubao): treat 200 + limit>0 + remaining=0 as unreliable headers, not 100% Volcano Ark returns HTTP 200 with `x-ratelimit-limit-requests > 0` and `x-ratelimit-remaining-requests = 0` on some account tiers (notably unverified personal keys) without actually rate-limiting the request — a genuine throttle would return 429. The previous math computed `used = limit` and clamped to 100%, so the Doubao card always showed 100% used for affected users. Tighten the normal-math guard to `limitRequests > 0 && remainingRequests > 0` so the unreliable-headers state falls through to the existing "Active - check dashboard for details" fallback (which was already used when both headers are missing). Also emit a `log.warning` when the pattern is observed so users hitting this can attach evidence from `~/Library/Logs/CodexBar/CodexBar.log` to bug reports. Adds `Tests/CodexBarTests/DoubaoUsageFetcherTests.swift` covering the normal path, the boundary near-full path, the unreliable-headers path, the both-headers-missing path, the invalid-key path, and provider identity tagging. Fixes #1382. Reported by @foobra on PR #498. * fix: preserve Doubao throttle state * fix: confirm ambiguous Doubao request limits * fix: preserve Doubao confirmation semantics * fix: require complete Doubao request limits * fix: classify Doubao request throttles * fix: preserve confirmed Doubao exhaustion --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../Providers/Doubao/DoubaoUsageFetcher.swift | 99 ++++++- .../DoubaoUsageFetcherTests.swift | 259 ++++++++++++++++++ 3 files changed, 351 insertions(+), 8 deletions(-) create mode 100644 Tests/CodexBarTests/DoubaoUsageFetcherTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b12e3f..a2502683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.32.6 — Unreleased ### Fixed +- Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! - Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 9bfad55d..be675cd7 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -10,13 +10,15 @@ public struct DoubaoUsageSnapshot: Sendable { public let updatedAt: Date public let apiKeyValid: Bool public let totalTokens: Int? + public let requestLimitsReliable: Bool public init( remainingRequests: Int, limitRequests: Int, resetTime: Date?, updatedAt: Date, apiKeyValid: Bool = false, - totalTokens: Int? = nil) + totalTokens: Int? = nil, + requestLimitsReliable: Bool = true) { self.remainingRequests = remainingRequests self.limitRequests = limitRequests @@ -24,13 +26,14 @@ public struct DoubaoUsageSnapshot: Sendable { self.updatedAt = updatedAt self.apiKeyValid = apiKeyValid self.totalTokens = totalTokens + self.requestLimitsReliable = requestLimitsReliable } public func toUsageSnapshot() -> UsageSnapshot { let usedPercent: Double let resetDescription: String - if self.limitRequests > 0 { + if self.limitRequests > 0, self.requestLimitsReliable { let used = max(0, self.limitRequests - self.remainingRequests) usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100)) resetDescription = "\(used)/\(self.limitRequests) requests" @@ -96,7 +99,22 @@ public struct DoubaoUsageFetcher: Sendable { "doubao-lite-32k", ] - public static func fetchUsage(apiKey: String) async throws -> DoubaoUsageSnapshot { + private struct ProbeResult { + let snapshot: DoubaoUsageSnapshot + let statusCode: Int + + var hasAmbiguousZeroRemaining: Bool { + self.statusCode == 200 + && self.snapshot.requestLimitsReliable + && self.snapshot.limitRequests > 0 + && self.snapshot.remainingRequests == 0 + } + } + + public static func fetchUsage( + apiKey: String, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DoubaoUsageSnapshot + { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw DoubaoUsageError.missingCredentials } @@ -104,7 +122,16 @@ public struct DoubaoUsageFetcher: Sendable { var lastError: Error? for model in self.probeModels { do { - return try await self.probe(apiKey: apiKey, model: model) + let result = try await self.probe(apiKey: apiKey, model: model, transport: transport) + guard result.hasAmbiguousZeroRemaining else { + return result.snapshot + } + + return try await self.confirmAmbiguousZeroRemaining( + initial: result, + apiKey: apiKey, + model: model, + transport: transport) } catch let error as DoubaoUsageError { if case let .apiError(code, _) = error, code == 404 || code == 403 { Self.log.debug("Doubao probe model \(model) unavailable (\(code)), trying next") @@ -117,7 +144,57 @@ public struct DoubaoUsageFetcher: Sendable { throw lastError ?? DoubaoUsageError.apiError(0, "All probe models failed") } - private static func probe(apiKey: String, model: String) async throws -> DoubaoUsageSnapshot { + private static func confirmAmbiguousZeroRemaining( + initial: ProbeResult, + apiKey: String, + model: String, + transport: any ProviderHTTPTransport) async throws -> DoubaoUsageSnapshot + { + do { + let confirmation = try await self.probe(apiKey: apiKey, model: model, transport: transport) + // This path starts only after a complete HTTP 200 request-limit pair + // reported zero. An immediate 429 confirms that exhausted state even + // when Ark omits the headers from the throttle response. + if confirmation.statusCode == 429 { + return confirmation.snapshot.requestLimitsReliable + ? confirmation.snapshot + : initial.snapshot + } + guard confirmation.hasAmbiguousZeroRemaining else { + return confirmation.snapshot + } + + Self.log.warning( + """ + Doubao Ark returned limit=\(confirmation.snapshot.limitRequests) remaining=0 \ + with HTTP 200 twice; treating request-limit headers as unreliable. + """) + return DoubaoUsageSnapshot( + remainingRequests: confirmation.snapshot.remainingRequests, + limitRequests: confirmation.snapshot.limitRequests, + resetTime: confirmation.snapshot.resetTime, + updatedAt: confirmation.snapshot.updatedAt, + apiKeyValid: confirmation.snapshot.apiKeyValid, + totalTokens: confirmation.snapshot.totalTokens, + requestLimitsReliable: false) + } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw error + } + self.log.warning( + """ + Doubao zero-remaining confirmation failed; preserving the initial exhausted state: \ + \(error.localizedDescription) + """) + return initial.snapshot + } + } + + private static func probe( + apiKey: String, + model: String, + transport: any ProviderHTTPTransport) async throws -> ProbeResult + { var request = URLRequest(url: self.apiURL) request.httpMethod = "POST" request.timeoutInterval = 15 @@ -135,7 +212,7 @@ public struct DoubaoUsageFetcher: Sendable { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers. @@ -163,6 +240,11 @@ public struct DoubaoUsageFetcher: Sendable { // 429 means the key is valid but rate-limited; treat it as valid so the UI // shows "Active" instead of "No usage data" when headers are absent. let keyValid = response.statusCode == 200 || response.statusCode == 429 + // A request-limit header on 429 identifies request-bucket exhaustion even + // when Ark omits remaining. A bare 429 may describe another throttle. + let requestLimitsReliable = response.statusCode == 429 + ? limit != nil + : limit != nil && remaining != nil let snapshot = DoubaoUsageSnapshot( remainingRequests: remaining ?? 0, @@ -170,7 +252,8 @@ public struct DoubaoUsageFetcher: Sendable { resetTime: resetTime, updatedAt: Date(), apiKeyValid: keyValid, - totalTokens: totalTokens) + totalTokens: totalTokens, + requestLimitsReliable: requestLimitsReliable) Self.log.debug( """ @@ -178,7 +261,7 @@ public struct DoubaoUsageFetcher: Sendable { limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid) """) - return snapshot + return ProbeResult(snapshot: snapshot, statusCode: response.statusCode) } private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? { diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift new file mode 100644 index 00000000..c0a66bd4 --- /dev/null +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -0,0 +1,259 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DoubaoUsageSnapshotTests { + @Test + func `normal usage with both headers present and non-empty reports correct percent`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 750, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "250/1000 requests") + } + + @Test + func `boundary normal usage at near-full reports correct percent`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 1, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 99.9) + #expect(usage.primary?.resetDescription == "999/1000 requests") + } + + @Test + func `unreliable headers limit positive remaining zero falls back to Active hint`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true, + requestLimitsReliable: false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + } + + @Test + func `explicit rate limit with zero remaining reports exhausted quota`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + } + + @Test + func `both headers missing but key valid falls back to Active hint`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + } + + @Test + func `invalid key with no headers reports No usage data`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "No usage data") + } + + @Test + func `provider identity is correctly tagged as doubao`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 500, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .doubao) + #expect(usage.identity?.accountEmail == nil) + } +} + +struct DoubaoUsageFetcherTests { + @Test + func `repeated successful zero remaining responses use active fallback`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 200, limit: 1000, remaining: 0), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + #expect(await transport.requestCount() == 2) + } + + @Test + func `successful final request followed by rate limit reports exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 429, limit: 1000, remaining: 0), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `headerless rate limit confirmation preserves exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 429, limit: nil, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `rate limit with request limit header reports exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 429, limit: 1000, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 1) + } + + @Test + func `bare rate limit uses active fallback`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 429, limit: nil, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + #expect(await transport.requestCount() == 1) + } + + @Test + func `failed zero remaining confirmation preserves exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .failure(URLError(.timedOut)), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `task cancellation during confirmation propagates`() async { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .cancellation, + ]) + + await #expect(throws: CancellationError.self) { + _ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + } + #expect(await transport.requestCount() == 2) + } + + @Test + func `url cancellation during confirmation propagates`() async { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .failure(URLError(.cancelled)), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + } throws: { error in + (error as? URLError)?.code == .cancelled + } + #expect(await transport.requestCount() == 2) + } +} + +private actor DoubaoScriptedTransport: ProviderHTTPTransport { + enum Result { + case response(statusCode: Int, limit: Int?, remaining: Int?) + case failure(URLError) + case cancellation + } + + private var results: [Result] + private var requests = 0 + + init(results: [Result]) { + self.results = results + } + + func requestCount() -> Int { + self.requests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.requests += 1 + let result = self.results.removeFirst() + switch result { + case let .response(statusCode, limit, remaining): + var headers: [String: String] = [:] + if let limit { + headers["x-ratelimit-limit-requests"] = String(limit) + } + if let remaining { + headers["x-ratelimit-remaining-requests"] = String(remaining) + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers)! + return (Data(#"{"usage":{"total_tokens":1}}"#.utf8), response) + case let .failure(error): + throw error + case .cancellation: + throw CancellationError() + } + } +} From 4c092ce344c59c042425905fede537279609db67 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:02:38 +0800 Subject: [PATCH 13/51] fix(menu): defer copy-icon click work off the NSMenu tracking loop (macOS 26 beachball) (#1389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(menu): defer copy-icon click work off the NSMenu tracking loop On macOS 26.4.1, clicking the doc.on.doc "Copy error" icon inside a hosted menu card freezes the cursor for several seconds. Two SwiftUI / NSView handlers run work synchronously inside the live NSMenu tracking event loop: - `CopyIconButton.body` (MenuCardView.swift:316) calls `withAnimation { didCopy = true }` immediately on click and queues a second `withAnimation { didCopy = false }` 0.9s later. Each withAnimation inside a tracking-mode hosted view forces a synchronous SwiftUI hosting layout pass on the main thread that the menu engine cannot service mid-tracking. - `ClickToCopyView.mouseDown` (ClickToCopyOverlay.swift) writes to `NSPasteboard.general` synchronously. Pasteboard writes emit distributed notifications whose synchronous watchers can re-enter the menu engine; the tighter main-thread budget on macOS 26 makes this user-visible. Both handlers now `DispatchQueue.main.async` their work off the current tracking tick (so it runs after AppKit unwinds back to a normal mode), drop `withAnimation` in favour of plain state mutation, and guard `updateNSView` against no-op writes so a stable parent card re-render does not invalidate the NSView. The checkmark feedback still works (it flips on the next tick and reverts after 0.9s) and the UX — clicking the icon next to the error to copy — is preserved. Adds `Tests/CodexBarTests/ClickToCopyOverlayTests.swift` covering the pasteboard sentinel write, `acceptsFirstMouse` behaviour, and `copyText` storage. Fixes #1388. * fix: defer all in-menu copy work --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Sources/CodexBar/ClickToCopyOverlay.swift | 45 +++++++++++++++-- Sources/CodexBar/MenuCardView.swift | 26 ++++------ .../CodexBar/StorageBreakdownMenuView.swift | 19 ++----- .../ClickToCopyOverlayTests.swift | 50 +++++++++++++++++++ .../ProviderStorageFootprintTests.swift | 9 ---- 6 files changed, 108 insertions(+), 42 deletions(-) create mode 100644 Tests/CodexBarTests/ClickToCopyOverlayTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a2502683..50a57315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! +- Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! - Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! diff --git a/Sources/CodexBar/ClickToCopyOverlay.swift b/Sources/CodexBar/ClickToCopyOverlay.swift index 11460285..b6ea3ea7 100644 --- a/Sources/CodexBar/ClickToCopyOverlay.swift +++ b/Sources/CodexBar/ClickToCopyOverlay.swift @@ -1,6 +1,35 @@ import AppKit import SwiftUI +@MainActor +enum MenuPasteboardCopy { + typealias DeferredAction = @MainActor @Sendable () -> Void + typealias Scheduler = @MainActor @Sendable (@escaping DeferredAction) -> Void + typealias Writer = @MainActor @Sendable (String) -> Void + + static func perform( + _ text: String, + scheduler: Scheduler = Self.schedule, + writer: @escaping Writer = Self.write, + completion: @escaping DeferredAction = {}) + { + scheduler { + writer(text) + completion() + } + } + + private static func schedule(_ action: @escaping DeferredAction) { + DispatchQueue.main.async(execute: action) + } + + private static func write(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } +} + struct ClickToCopyOverlay: NSViewRepresentable { let copyText: String @@ -9,15 +38,25 @@ struct ClickToCopyOverlay: NSViewRepresentable { } func updateNSView(_ nsView: ClickToCopyView, context: Context) { + // Guard against no-op writes to avoid AppKit view invalidation on every + // parent card SwiftUI diff (each MenuCardView body re-eval runs through + // .overlay { ClickToCopyOverlay(...) }, which calls updateNSView even + // when copyText is unchanged). + guard nsView.copyText != self.copyText else { return } nsView.copyText = self.copyText } } final class ClickToCopyView: NSView { var copyText: String + private let copyAction: (String) -> Void - init(copyText: String) { + init( + copyText: String, + copyAction: @escaping (String) -> Void = { MenuPasteboardCopy.perform($0) }) + { self.copyText = copyText + self.copyAction = copyAction super.init(frame: .zero) self.wantsLayer = false } @@ -33,8 +72,6 @@ final class ClickToCopyView: NSView { override func mouseDown(with event: NSEvent) { _ = event - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) + self.copyAction(self.copyText) } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1f5d6a8d..ae567bf7 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -322,17 +322,7 @@ private struct CopyIconButton: View { var body: some View { Button { - self.copyToPasteboard() - withAnimation(.easeOut(duration: 0.12)) { - self.didCopy = true - } - self.resetTask?.cancel() - self.resetTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(0.9)) - withAnimation(.easeOut(duration: 0.2)) { - self.didCopy = false - } - } + self.handleCopy() } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) @@ -343,10 +333,16 @@ private struct CopyIconButton: View { .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy error")) } - private func copyToPasteboard() { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) + private func handleCopy() { + let text = self.copyText + self.resetTask?.cancel() + MenuPasteboardCopy.perform(text, completion: { + self.didCopy = true + self.resetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(0.9)) + self.didCopy = false + } + }) } } diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 6e26c985..4e20eb66 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -191,17 +191,14 @@ struct StoragePathCopyButton: View { var body: some View { Button { - Self.copyToPasteboard(self.path) - withAnimation(.easeOut(duration: 0.12)) { - self.didCopy = true - } self.resetTask?.cancel() - self.resetTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(0.9)) - withAnimation(.easeOut(duration: 0.2)) { + MenuPasteboardCopy.perform(self.path, completion: { + self.didCopy = true + self.resetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(0.9)) self.didCopy = false } - } + }) } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) @@ -213,10 +210,4 @@ struct StoragePathCopyButton: View { .help(self.didCopy ? L("Copied") : L("Copy path")) .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy path")) } - - static func copyToPasteboard(_ path: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(path, forType: .string) - } } diff --git a/Tests/CodexBarTests/ClickToCopyOverlayTests.swift b/Tests/CodexBarTests/ClickToCopyOverlayTests.swift new file mode 100644 index 00000000..449e3ad6 --- /dev/null +++ b/Tests/CodexBarTests/ClickToCopyOverlayTests.swift @@ -0,0 +1,50 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +struct ClickToCopyOverlayTests { + @Test + func `view stores the latest copyText`() { + let view = ClickToCopyView(copyText: "original") + #expect(view.copyText == "original") + view.copyText = "updated" + #expect(view.copyText == "updated") + } + + @Test + func `pasteboard copy waits for deferred scheduler`() { + var pendingAction: (() -> Void)? + var copiedText: String? + var completed = false + + MenuPasteboardCopy.perform( + "copy me", + scheduler: { pendingAction = $0 }, + writer: { copiedText = $0 }, + completion: { completed = true }) + + #expect(copiedText == nil) + #expect(!completed) + pendingAction?() + #expect(copiedText == "copy me") + #expect(completed) + } + + @Test + func `mouseDown forwards the latest copyText`() { + var copiedText: String? + let view = ClickToCopyView(copyText: "original") { copiedText = $0 } + view.copyText = "updated" + + view.mouseDown(with: NSEvent()) + + #expect(copiedText == "updated") + } + + @Test + func `accepts first mouse so error text overlay is clickable on first focus`() { + let view = ClickToCopyView(copyText: "x") + #expect(view.acceptsFirstMouse(for: nil) == true) + } +} diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift index 8879df6d..2b1a8201 100644 --- a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -272,15 +272,6 @@ struct ProviderStorageFootprintTests { #expect(detailView.copyablePaths.contains("\(root)/file-history")) } - @Test - @MainActor - func `storage path copy button writes exact path to pasteboard`() { - let path = "/Users/test/.claude/projects/example" - StoragePathCopyButton.copyToPasteboard(path) - - #expect(NSPasteboard.general.string(forType: .string) == path) - } - @Test @MainActor func `manual storage refresh updates deleted provider data`() async throws { From 2e42427db2425bc589a599157d4bef84b0a608c6 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:51:06 +0800 Subject: [PATCH 14/51] Add configurable terminal app for Open Terminal (#1225) * Add configurable terminal app for Open Terminal * Use configured terminal for Vertex AI login flow * test: harden terminal launcher behavior --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 3 + Sources/CodexBar/PreferencesGeneralPane.swift | 20 ++++ .../VertexAI/VertexAILoginFlow.swift | 22 +---- .../Resources/en.lproj/Localizable.strings | 2 + Sources/CodexBar/SettingsStore+Defaults.swift | 8 ++ Sources/CodexBar/SettingsStore.swift | 4 +- Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+Actions.swift | 49 +++++++--- Sources/CodexBar/TerminalApp.swift | 57 +++++++++++ Tests/CodexBarTests/TerminalAppTests.swift | 94 +++++++++++++++++++ 10 files changed, 225 insertions(+), 35 deletions(-) create mode 100644 Sources/CodexBar/TerminalApp.swift create mode 100644 Tests/CodexBarTests/TerminalAppTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a57315..6a8839a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.6 — Unreleased +### Added +- Settings: choose Terminal.app or iTerm for Open Terminal actions, including Vertex AI login commands (#1225, fixes #1147). Thanks @Yuxin-Qiao! + ### Fixed - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 0854310f..5f68c4bc 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -74,6 +74,26 @@ struct GeneralPane: View { } } + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L("terminal_app_title")) + .font(.body) + Text(L("terminal_app_subtitle")) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Picker(L("terminal_app_title"), selection: self.$settings.terminalApp) { + ForEach(TerminalApp.allCases) { option in + Text(option.label).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + PreferenceToggleRow( title: L("start_at_login_title"), subtitle: L("start_at_login_subtitle"), diff --git a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift index 2741f091..8e8e0ed9 100644 --- a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift +++ b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift @@ -16,7 +16,8 @@ extension StatusItemController { let response = alert.runModal() if response == .alertFirstButtonReturn { - Self.openTerminalWithGcloudCommand() + self.openTerminal( + command: "gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform") } // Refresh after user may have logged in @@ -26,23 +27,4 @@ extension StatusItemController { await self.store.refresh() } } - - private static func openTerminalWithGcloudCommand() { - let script = """ - tell application "Terminal" - activate - do script "gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform" - end tell - """ - - if let appleScript = NSAppleScript(source: script) { - var error: NSDictionary? - appleScript.executeAndReturnError(&error) - if let error { - CodexBarLog.logger(LogCategories.terminal).error( - "Failed to open Terminal", - metadata: ["error": String(describing: error)]) - } - } - } } diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ee3c0b8f..8c16cfd6 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -305,6 +305,8 @@ "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; "System" = "System"; "Temporarily shows the loading animation after the next refresh." = "Temporarily shows the loading animation after the next refresh."; +"terminal_app_subtitle" = "Terminal used by the Open Terminal action"; +"terminal_app_title" = "Default Terminal"; "Tertiary (\\(label))" = "Tertiary (\\(label))"; "Tertiary (\\(tertiaryTitle))" = "Tertiary (\\(tertiaryTitle))"; "The default Codex account on this Mac." = "The default Codex account on this Mac."; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 2e4639d2..cc966c14 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -662,6 +662,14 @@ extension SettingsStore { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } } + + var terminalApp: TerminalApp { + get { TerminalApp(rawValue: self.defaultsState.terminalAppRaw ?? "") ?? .terminal } + set { + self.defaultsState.terminalAppRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "terminalApp") + } + } } extension SettingsStore { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 0840f800..c6135391 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -399,7 +399,6 @@ extension SettingsStore { let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false let appLanguageRaw = userDefaults.string(forKey: "appLanguage") - return SettingsDefaultsState( refreshFrequency: refreshFrequency, launchAtLogin: launchAtLogin, @@ -449,7 +448,8 @@ extension SettingsStore { mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, providerDetectionCompleted: providerDetectionCompleted, - appLanguageRaw: appLanguageRaw) + appLanguageRaw: appLanguageRaw, + terminalAppRaw: userDefaults.string(forKey: "terminalApp")) } private static func loadMenuBarMetricPreferences(userDefaults: UserDefaults) -> [String: String] { diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index cd46f186..9ed022a9 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -50,4 +50,5 @@ struct SettingsDefaultsState { var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool var appLanguageRaw: String? + var terminalAppRaw: String? } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 67c399e4..9ce0d39d 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -187,7 +187,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { @objc func openTerminalCommand(_ sender: NSMenuItem) { let command = sender.representedObject as? String ?? "claude" - Self.openTerminal(command: command) + self.openTerminal(command: command) } @objc func openLoginToProvider(_ sender: NSMenuItem) { @@ -351,25 +351,48 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } } - private static func openTerminal(command: String) { - let escaped = command - .replacingOccurrences(of: "\\\\", with: "\\\\\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let script = """ - tell application "Terminal" - activate - do script "\(escaped)" - end tell - """ - if let appleScript = NSAppleScript(source: script) { + func openTerminal(command: String) { + let terminal = self.settings.terminalApp + + if terminal == .iTerm, !terminal.isInstalled { + CodexBarLog.logger(LogCategories.terminal).warning( + "iTerm is not installed, falling back to Terminal.app", + metadata: ["terminal": terminal.rawValue]) + Self.openTerminalInDefaultTerminal(command: command) + return + } + + if Self.executeAppleScript(terminal.appleScript(command: command)) { + return + } + guard terminal != .terminal else { return } + + CodexBarLog.logger(LogCategories.terminal).warning( + "\(terminal.label) AppleScript failed, falling back to Terminal.app", + metadata: ["terminal": terminal.rawValue]) + Self.openTerminalInDefaultTerminal(command: command) + } + + private static func openTerminalInDefaultTerminal(command: String) { + self.executeAppleScript(TerminalApp.terminal.appleScript(command: command)) + } + + /// Executes an AppleScript and returns `true` on success, `false` on failure. + @discardableResult + private static func executeAppleScript(_ source: String) -> Bool { + if let appleScript = NSAppleScript(source: source) { var error: NSDictionary? appleScript.executeAndReturnError(&error) if let error { CodexBarLog.logger(LogCategories.terminal).error( - "Failed to open Terminal", + "Failed to execute AppleScript", metadata: ["error": String(describing: error)]) + return false } + return true } + CodexBarLog.logger(LogCategories.terminal).error("Failed to compile AppleScript") + return false } private func resolvedShortcutProvider() -> UsageProvider { diff --git a/Sources/CodexBar/TerminalApp.swift b/Sources/CodexBar/TerminalApp.swift new file mode 100644 index 00000000..2db1fdd5 --- /dev/null +++ b/Sources/CodexBar/TerminalApp.swift @@ -0,0 +1,57 @@ +import AppKit + +enum TerminalApp: String, CaseIterable, Identifiable { + case terminal + case iTerm + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .terminal: "Terminal" + case .iTerm: "iTerm" + } + } + + var bundleIdentifier: String { + switch self { + case .terminal: "com.apple.Terminal" + case .iTerm: "com.googlecode.iterm2" + } + } + + var isInstalled: Bool { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: self.bundleIdentifier) != nil + } + + func appleScript(command: String) -> String { + let escaped = Self.escapeForAppleScript(command) + return switch self { + case .terminal: + """ + tell application "Terminal" + activate + do script "\(escaped)" + end tell + """ + case .iTerm: + """ + tell application "iTerm" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + write text "\(escaped)" + end tell + end tell + """ + } + } + + static func escapeForAppleScript(_ command: String) -> String { + command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Tests/CodexBarTests/TerminalAppTests.swift b/Tests/CodexBarTests/TerminalAppTests.swift new file mode 100644 index 00000000..97a6b491 --- /dev/null +++ b/Tests/CodexBarTests/TerminalAppTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import CodexBar + +@Suite("TerminalApp") +struct TerminalAppTests { + @Test + @MainActor + func `default is terminal`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(store.terminalApp == .terminal) + } + + @Test + @MainActor + func `setting terminal app persists it`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.terminalApp = .iTerm + #expect(store.terminalApp == .iTerm) + #expect(defaults.string(forKey: "terminalApp") == "iTerm") + } + + @Test + @MainActor + func `invalid stored value falls back to terminal`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.set("nonexistent", forKey: "terminalApp") + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(store.terminalApp == .terminal) + } + + @Test + func `only two cases exist`() { + #expect(TerminalApp.allCases.count == 2) + } + + @Test + func `all cases have unique bundle identifiers`() { + let ids = TerminalApp.allCases.map(\.bundleIdentifier) + #expect(Set(ids).count == TerminalApp.allCases.count) + } + + @Test + func `all cases have non-empty labels`() { + for app in TerminalApp.allCases { + #expect(!app.label.isEmpty) + } + } + + @Test + func `round-trip all cases through raw value`() { + for app in TerminalApp.allCases { + #expect(TerminalApp(rawValue: app.rawValue) == app) + } + } + + @Test + func `escapes commands embedded in AppleScript strings`() { + let escaped = TerminalApp.escapeForAppleScript(#"echo "C:\tmp""#) + + #expect(escaped == #"echo \"C:\\tmp\""#) + } + + @Test + func `builds terminal-specific launch scripts`() { + let command = #"echo "hello""# + let terminalScript = TerminalApp.terminal.appleScript(command: command) + let iTermScript = TerminalApp.iTerm.appleScript(command: command) + + #expect(terminalScript.contains(#"tell application "Terminal""#)) + #expect(terminalScript.contains(#"do script "echo \"hello\"""#)) + #expect(iTermScript.contains(#"tell application "iTerm""#)) + #expect(iTermScript.contains(#"write text "echo \"hello\"""#)) + } +} From a6bb67720277aade0f336a738e52ebe599c117b1 Mon Sep 17 00:00:00 2001 From: soumikbhatta <29822748+soumikbhatta@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:52:03 -0400 Subject: [PATCH 15/51] Fix Copilot unlimited chat quota display (#1320) * Fix Copilot unlimited chat quota display * fix: harden Copilot unlimited quota handling --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Sources/CodexBarCore/CopilotUsageModels.swift | 21 ++++++-- .../CopilotUsageFetcherTests.swift | 34 ++++++++++++ .../CopilotUsageModelsTests.swift | 53 +++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8839a9..aac5ba1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Settings: choose Terminal.app or iTerm for Open Terminal actions, including Vertex AI login commands (#1225, fixes #1147). Thanks @Yuxin-Qiao! ### Fixed +- Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index dc8dd1db..d9597f19 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -22,6 +22,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { public let percentRemaining: Double public let quotaId: String public let hasPercentRemaining: Bool + public let unlimited: Bool private let entitlementWasDecoded: Bool private let remainingWasDecoded: Bool public var usedPercent: Double { @@ -33,6 +34,10 @@ public struct CopilotUsageResponse: Sendable, Decodable { } public var isPlaceholder: Bool { + if self.unlimited { + return false + } + if self.entitlement == 0, self.remaining == 0, self.percentRemaining == 0, @@ -55,6 +60,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { case remaining case percentRemaining = "percent_remaining" case quotaId = "quota_id" + case unlimited } public init( @@ -62,13 +68,15 @@ public struct CopilotUsageResponse: Sendable, Decodable { remaining: Double, percentRemaining: Double, quotaId: String, - hasPercentRemaining: Bool = true) + hasPercentRemaining: Bool = true, + unlimited: Bool = false) { self.entitlement = entitlement self.remaining = remaining - self.percentRemaining = percentRemaining + self.percentRemaining = unlimited ? 100 : percentRemaining self.quotaId = quotaId - self.hasPercentRemaining = hasPercentRemaining + self.hasPercentRemaining = unlimited || hasPercentRemaining + self.unlimited = unlimited self.entitlementWasDecoded = true self.remainingWasDecoded = true } @@ -81,8 +89,12 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.remaining = decodedRemaining ?? 0 self.entitlementWasDecoded = decodedEntitlement != nil self.remainingWasDecoded = decodedRemaining != nil + let decodedUnlimited = try container.decodeIfPresent(Bool.self, forKey: .unlimited) ?? false let decodedPercent = Self.decodeNumberIfPresent(container: container, key: .percentRemaining) - if let decodedPercent { + if decodedUnlimited { + self.percentRemaining = 100 + self.hasPercentRemaining = true + } else if let decodedPercent { self.percentRemaining = decodedPercent self.hasPercentRemaining = true } else if let entitlement = decodedEntitlement, @@ -98,6 +110,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.hasPercentRemaining = false } self.quotaId = try container.decodeIfPresent(String.self, forKey: .quotaId) ?? "" + self.unlimited = decodedUnlimited } private static func decodeNumberIfPresent( diff --git a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift index c5083bec..3c286502 100644 --- a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift @@ -67,6 +67,40 @@ struct CopilotUsageFetcherTests { #expect(snapshot.identity?.loginMethod == "Business") } + @Test + func `fetch keeps explicitly unlimited chat quota`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token gh-token") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "chat_messages": { + "entitlement": 0, + "remaining": 0, + "quota_id": "chat_messages", + "unlimited": true + } + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "gh-token", transport: transport) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary?.usedPercent == 0) + #expect(snapshot.identity?.loginMethod == "Individual") + } + @Test func `makeRateWindow drops business token billing placeholder quota`() { // entitlement=0/remaining=0/percent_remaining=100 must not become a "0% used" diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index 48f99b98..4d42656c 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -476,6 +476,59 @@ struct CopilotUsageModelsTests { #expect(response.quotaSnapshots.chat == nil) } + @Test + func `keeps unlimited chat fallback quota without percent remaining`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 200, + "remaining": 191, + "percent_remaining": 95.5, + "quota_id": "premium_interactions" + }, + "chat_messages": { + "entitlement": 0, + "remaining": 0, + "quota_id": "chat_messages", + "unlimited": true + } + } + } + """) + + #expect(response.quotaSnapshots.premiumInteractions?.quotaId == "premium_interactions") + #expect(response.quotaSnapshots.chat?.quotaId == "chat_messages") + #expect(response.quotaSnapshots.chat?.unlimited == true) + #expect(response.quotaSnapshots.chat?.usedPercent == 0) + } + + @Test + func `unlimited quota overrides placeholder percent remaining`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "chat": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 0, + "quota_id": "chat", + "unlimited": true + } + } + } + """) + + let chat = try #require(response.quotaSnapshots.chat) + #expect(chat.percentRemaining == 100) + #expect(chat.usedPercent == 0) + #expect(!chat.isPlaceholder) + } + @Test func `flags zero entitlement snapshot as placeholder`() { let snapshot = CopilotUsageResponse.QuotaSnapshot( From f561834141acabc2d4307aa19221ae1779e6f2fb Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:04:47 +0800 Subject: [PATCH 16/51] Avoid repeated CodexBarCache keychain prompts in dev builds (#1271) * Document ad-hoc dev build keychain prompt diagnostics * Avoid dev-build keychain prompt loops * Fix dev-build keychain warning lint * Refine dev-build keychain guard * fix: narrow unbundled keychain guard --------- Co-authored-by: Yuxin Qiao Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../CodexBar/KeychainPromptCoordinator.swift | 28 +++++++++++++ Sources/CodexBarCore/KeychainAccessGate.swift | 21 ++++++++++ .../ClaudeOAuthKeychainAccessGateTests.swift | 12 ++++++ .../KeychainPromptCoordinatorTests.swift | 40 +++++++++++++++++++ docs/DEVELOPMENT_SETUP.md | 5 +++ 6 files changed, 107 insertions(+) create mode 100644 Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index aac5ba1f..8626d658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! +- Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! - Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! diff --git a/Sources/CodexBar/KeychainPromptCoordinator.swift b/Sources/CodexBar/KeychainPromptCoordinator.swift index 7a458651..d3678e01 100644 --- a/Sources/CodexBar/KeychainPromptCoordinator.swift +++ b/Sources/CodexBar/KeychainPromptCoordinator.swift @@ -65,6 +65,34 @@ enum KeychainPromptCoordinator { BrowserCookieKeychainPromptHandler.handler = { context in self.presentBrowserCookiePrompt(context) } + self.disableKeychainForUnbundledExecutableIfNeeded() + } + + private static let unbundledExecutableCheckLock = NSLock() + private nonisolated(unsafe) static var didCheckUnbundledExecutable = false + + static func disableKeychainForUnbundledExecutableIfNeeded() { + self.unbundledExecutableCheckLock.lock() + guard !self.didCheckUnbundledExecutable else { + self.unbundledExecutableCheckLock.unlock() + return + } + self.didCheckUnbundledExecutable = true + self.unbundledExecutableCheckLock.unlock() + + let executablePath = Bundle.main.executableURL?.path ?? "" + guard Self.isUnbundledCodexBarExecutable(executablePath) else { return } + KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable") + Self.log.warning( + "Unbundled CodexBar executable detected; disabling keychain access to avoid repeated prompts", + metadata: ["doc": "docs/DEVELOPMENT_SETUP.md"]) + } + + static func isUnbundledCodexBarExecutable(_ executablePath: String) -> Bool { + guard executablePath.hasPrefix("/") else { return false } + let executableURL = URL(fileURLWithPath: executablePath).standardizedFileURL + return executableURL.lastPathComponent == "CodexBar" + && !executableURL.pathComponents.contains(where: { $0.hasSuffix(".app") }) } private static func presentKeychainPrompt(_ context: KeychainPromptContext) { diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 86451a75..0e44c5f0 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -7,6 +7,8 @@ public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" @TaskLocal private static var taskOverrideValue: Bool? private nonisolated(unsafe) static var overrideValue: Bool? + private static let processForceDisabledLock = NSLock() + private nonisolated(unsafe) static var processForceDisabledReason: String? public nonisolated(unsafe) static var isDisabled: Bool { get { @@ -16,6 +18,7 @@ public enum KeychainAccessGate { return true } #endif + if self.processDisableReason != nil { return true } if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { @@ -31,6 +34,21 @@ public enum KeychainAccessGate { } } + public static func forceDisabledForProcess(reason: String) { + self.processForceDisabledLock.lock() + self.processForceDisabledReason = reason + self.processForceDisabledLock.unlock() + #if os(macOS) && canImport(SweetCookieKit) + BrowserCookieKeychainAccessGate.isDisabled = self.isDisabled + #endif + } + + public static var processDisableReason: String? { + self.processForceDisabledLock.lock() + defer { self.processForceDisabledLock.unlock() } + return self.processForceDisabledReason + } + #if DEBUG private nonisolated(unsafe) static var forcesDisabledUnderTests: Bool { self.isRunningUnderTests @@ -70,6 +88,9 @@ public enum KeychainAccessGate { #if DEBUG static func resetOverrideForTesting() { self.overrideValue = nil + self.processForceDisabledLock.lock() + self.processForceDisabledReason = nil + self.processForceDisabledLock.unlock() #if os(macOS) && canImport(SweetCookieKit) BrowserCookieKeychainAccessGate.isDisabled = self.isDisabled #endif diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift index 03e098e2..9eab2592 100644 --- a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift @@ -58,6 +58,18 @@ struct ClaudeOAuthKeychainAccessGateTests { #expect(KeychainAccessGate.isDisabled) } + @Test + func `process force disable survives settings override`() { + KeychainAccessGate.resetOverrideForTesting() + defer { KeychainAccessGate.resetOverrideForTesting() } + + KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable") + KeychainAccessGate.isDisabled = false + + #expect(KeychainAccessGate.isDisabled) + #expect(KeychainAccessGate.processDisableReason == "unbundled-executable") + } + @Test func `clear denied allows immediate retry`() { KeychainAccessGate.withTaskOverrideForTesting(false) { diff --git a/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift b/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift new file mode 100644 index 00000000..215efc1a --- /dev/null +++ b/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import CodexBar + +struct KeychainPromptCoordinatorTests { + @Test + func `detects raw SwiftPM debug executable`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/arm64-apple-macosx/debug/CodexBar")) + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/debug/CodexBar")) + } + + @Test + func `detects raw SwiftPM release executable`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/arm64-apple-macosx/release/CodexBar")) + } + + @Test + func `detects custom SwiftPM scratch path`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/tmp/codexbar-build/arm64-apple-macosx/debug/CodexBar")) + } + + @Test + func `keeps packaged app keychain behavior`() { + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Applications/CodexBar.app/Contents/MacOS/CodexBar")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/package/CodexBar.app/Contents/MacOS/CodexBar")) + } + + @Test + func `ignores unrelated executable paths`() { + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/debug/CodexBarCLI")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable("")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable("CodexBar")) + } +} diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index 48343efe..887daea2 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -104,6 +104,11 @@ This script: 5. Launches `CodexBar.app` 6. Verifies it stays running +Launching an unbundled `CodexBar` executable, including SwiftPM builds using `.build` or a custom scratch path, disables +Keychain access for that process to avoid repeated password prompts. Use the packaged `CodexBar.app` when local +validation needs browser cookies or stored credentials; packaged app bundles keep their normal Keychain behavior +regardless of signing mode. + When the script falls back to ad-hoc signing, it preserves CodexBar-owned keychain state by default. That means you may still see keychain prompts for existing CodexBar cache entries, but allowing those prompts keeps the cached browser/OAuth state available across normal rebuilds. From 2eaa313dc35f264cf650d3982efb346e6e1e1975 Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:33:33 -0700 Subject: [PATCH 17/51] Make Augment keepalive stop idempotent (#1262) * Make Augment keepalive stop idempotent * Cover idempotent Augment runtime stop * Assert Augment stop logging is idempotent * test: stabilize Augment keepalive stop coverage --------- Co-authored-by: Peter Steinberger --- .../Augment/AugmentProviderRuntime.swift | 12 +++++- .../AugmentProviderRuntimeTests.swift | 42 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/AugmentProviderRuntimeTests.swift diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift index 0a6bb90d..173822d1 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift @@ -5,6 +5,12 @@ import Foundation final class AugmentProviderRuntime: ProviderRuntime { let id: UsageProvider = .augment private var keepalive: AugmentSessionKeepalive? + #if DEBUG + private(set) var _test_keepaliveStopCount = 0 + var _test_isKeepaliveRunning: Bool { + self.keepalive != nil + } + #endif func start(context: ProviderRuntimeContext) { self.updateKeepalive(context: context) @@ -83,8 +89,12 @@ final class AugmentProviderRuntime: ProviderRuntime { private func stopKeepalive(context: ProviderRuntimeContext, reason: String) { #if os(macOS) - self.keepalive?.stop() + guard let keepalive = self.keepalive else { return } + keepalive.stop() self.keepalive = nil + #if DEBUG + self._test_keepaliveStopCount += 1 + #endif context.store.augmentLogger.info("Augment keepalive stopped (\(reason))") #endif } diff --git a/Tests/CodexBarTests/AugmentProviderRuntimeTests.swift b/Tests/CodexBarTests/AugmentProviderRuntimeTests.swift new file mode 100644 index 00000000..889da065 --- /dev/null +++ b/Tests/CodexBarTests/AugmentProviderRuntimeTests.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct AugmentProviderRuntimeTests { + @Test + func `repeated stop only reports a running keepalive once`() throws { + let suite = "AugmentProviderRuntimeTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore()) + let metadata = try #require(ProviderRegistry.shared.metadata[.augment]) + settings.setProviderEnabled(provider: .augment, metadata: metadata, enabled: true) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let runtime = AugmentProviderRuntime() + let context = ProviderRuntimeContext(provider: .augment, settings: settings, store: store) + defer { runtime.stop(context: context) } + + runtime.start(context: context) + #expect(runtime._test_isKeepaliveRunning) + runtime.stop(context: context) + settings.setProviderEnabled(provider: .augment, metadata: metadata, enabled: false) + runtime.stop(context: context) + runtime.settingsDidChange(context: context) + + #expect(!runtime._test_isKeepaliveRunning) + #expect(runtime._test_keepaliveStopCount == 1) + } +} From 04ac21cb19834c293167658c2d7b00bc8d5b9e4c Mon Sep 17 00:00:00 2001 From: naoterumaker <64459858+naoterumaker@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:23:20 +0900 Subject: [PATCH 18/51] Add Japanese (ja) localization (#1385) * Add Japanese (ja) localization - Add ja.lproj/Localizable.strings with full translation of all 1033 keys - Add AppLanguage.japanese case and language picker label - Add language_japanese key to all existing language catalogs - Extend LocalizationLanguageCatalogTests language key list Co-Authored-By: Claude Fable 5 * test: verify Japanese language switching * fix: preserve Japanese accessibility arguments --------- Co-authored-by: Naoteru Nakamura Co-authored-by: Claude Fable 5 Co-authored-by: Peter Steinberger --- 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/fr.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1069 +++++++++++++++++ .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/vi.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../LocalizationLanguageCatalogTests.swift | 20 + .../PreferencesPaneSmokeTests.swift | 7 + 16 files changed, 1110 insertions(+) create mode 100644 Sources/CodexBar/Resources/ja.lproj/Localizable.strings diff --git a/CHANGELOG.md b/CHANGELOG.md index 8626d658..ee2f9508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Settings: choose Terminal.app or iTerm for Open Terminal actions, including Vertex AI login commands (#1225, fixes #1147). Thanks @Yuxin-Qiao! +- Localization: add Japanese as a selectable app language (#1385). Thanks @naoterumaker! ### Fixed - Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 5f68c4bc..ee10ab1f 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -15,6 +15,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case dutch = "nl" case ukrainian = "uk" case vietnamese = "vi" + case japanese = "ja" var id: String { self.rawValue @@ -34,6 +35,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .dutch: L("language_dutch") case .ukrainian: L("language_ukrainian") case .vietnamese: L("language_vietnamese") + case .japanese: L("language_japanese") } } } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index a0ae225d..d079294d 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -403,6 +403,7 @@ "language_dutch" = "Nederlands"; "language_french" = "Francès"; "language_ukrainian" = "Ucraïnès"; +"language_japanese" = "Japonès"; "start_at_login_title" = "Obrir en iniciar la sessió"; "start_at_login_subtitle" = "Obre el CodexBar automàticament en iniciar el Mac."; "show_cost_summary" = "Mostra el resum de cost"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 8c16cfd6..b52d91e1 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -405,6 +405,7 @@ "language_swedish" = "Svenska"; "language_french" = "French"; "language_ukrainian" = "Українська"; +"language_japanese" = "Japanese"; "start_at_login_title" = "Start at Login"; "start_at_login_subtitle" = "Automatically opens CodexBar when you start your Mac."; "show_cost_summary" = "Show cost summary"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index f4aa0057..994ffd80 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -403,6 +403,7 @@ "language_dutch" = "Nederlands"; "language_french" = "Francés"; "language_ukrainian" = "Ucraniano"; +"language_japanese" = "Japonés"; "start_at_login_title" = "Abrir al iniciar sesión"; "start_at_login_subtitle" = "Abre CodexBar automáticamente al iniciar tu Mac."; "show_cost_summary" = "Mostrar resumen de coste"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 6ae6c7e6..aca685e7 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -403,6 +403,7 @@ "language_french" = "Français"; "language_dutch" = "Néerlandais"; "language_ukrainian" = "Ukrainien"; +"language_japanese" = "Japonais"; "language_vietnamese" = "Vietnamien"; "start_at_login_title" = "Lancer à l'ouverture de session"; "start_at_login_subtitle" = "Ouvre automatiquement CodexBar au démarrage de votre Mac."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings new file mode 100644 index 00000000..7f16a37b --- /dev/null +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,1069 @@ +/* Japanese localization for CodexBar */ + +" providers" = " 件のプロバイダ"; +"(System)" = "(システム)"; +"30d" = "30日"; +"A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; +"API key" = "API キー"; +"API region" = "API リージョン"; +"API token" = "API トークン"; +"API tokens" = "API トークン"; +"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 キーチェーンリーダーにのみ適用されます。"; +"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" = "自動更新: 1時間ごと · タイムアウト: 10分"; +"Automatic" = "自動"; +"Automatic imports browser cookies and WorkOS tokens." = "自動では、ブラウザの Cookie と WorkOS トークンを読み込みます。"; +"Automatic imports browser cookies and local storage tokens." = "自動では、ブラウザの Cookie とローカルストレージのトークンを読み込みます。"; +"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." = "エージェントがメニューバーで楽しく動き回るのがお好みならチェックしてください。"; +"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 can't show its menu bar icon" = "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 トークンの値を貼り付けてください"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"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" = "デフォルト"; +"Disable Keychain access" = "キーチェーンへのアクセスを無効にする"; +"Disabled" = "無効"; +"Dismiss" = "閉じる"; +"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" = "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" = "最終試行"; +"Link" = "リンク"; +"Loading animations" = "読み込み中アニメーション"; +"Loading…" = "読み込み中…"; +"Local" = "ローカル"; +"Logging" = "ログ"; +"Login failed" = "ログインに失敗しました"; +"Login shell PATH (startup capture)" = "ログインシェルの 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." = "トークンが尽きませんように — エージェントの上限を常に見守りましょう。"; +"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 data available" = "データがありません"; +"No data yet" = "まだデータがありません"; +"No enabled providers available for Overview." = "概要に表示できる有効なプロバイダがありません。"; +"No providers selected" = "プロバイダが選択されていません"; +"No token accounts yet." = "トークンアカウントはまだありません。"; +"No usage breakdown data." = "使用量の内訳データがありません。"; +"None" = "なし"; +"Notifications" = "通知"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "5時間セッションのクォータが 0% になったとき、および再び利用可能になったときに通知します "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "メニューバーとメニュー 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" = "コーディングプランを開く"; +"Open Console" = "コンソールを開く"; +"Open Dashboard" = "ダッシュボードを開く"; +"Open Mistral Admin" = "Mistral 管理画面を開く"; +"Open Menu Bar Settings" = "メニューバー設定を開く"; +"Open Ollama Settings" = "Ollama の設定を開く"; +"Open Terminal" = "ターミナルを開く"; +"Open Usage Page" = "使用状況ページを開く"; +"Open Warp API Key Guide" = "Warp API キーガイドを開く"; +"Open menu" = "メニューを開く"; +"Open token file" = "トークンファイルを開く"; +"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 トークンを貼り付け…"; +"Paste key…" = "キーを貼り付け…"; +"Paste sessionKey or OAuth token…" = "sessionKey または OAuth トークンを貼り付け…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "admin.mistral.ai へのリクエストの Cookie ヘッダーを貼り付けてください。"; +"Paste 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" = "更新"; +"Refresh cadence" = "更新間隔"; +"Remote" = "リモート"; +"Remove" = "削除"; +"Remove Codex account?" = "Codex アカウントを削除しますか?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "\\(account.email) を CodexBar から削除しますか?管理対象の Codex ホームは削除されます。"; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "\\(email) を CodexBar から削除しますか?管理対象の Codex ホームは削除されます。"; +"Remove selected account" = "選択したアカウントを削除"; +"Replace critter bars with provider branding icons and a percentage." = "クリッターバーをプロバイダのブランドアイコンとパーセント表示に置き換えます。"; +"Replay selected animation" = "選択したアニメーションを再生"; +"Requires authentication via GitHub Device Flow." = "GitHub Device Flow による認証が必要です。"; +"Resets: \\(reset)" = "リセット: \\(reset)"; +"Rolling five-hour limit" = "5時間のローリング上限"; +"Search hourly" = "1時間ごとに検索"; +"Secondary (\\(label))" = "セカンダリ (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "セカンダリ (\\(metadata.weeklyLabel))"; +"Select a provider" = "プロバイダを選択"; +"Select the IDE to monitor" = "監視する IDE を選択"; +"Session quota notifications" = "セッションクォータ通知"; +"Session tokens" = "セッショントークン"; +"Settings" = "設定"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Codex クレジットと Claude 追加使用量のセクションをメニューに表示します。"; +"Show Debug Settings" = "デバッグ設定を表示"; +"Show all token accounts" = "すべてのトークンアカウントを表示"; +"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)." = "プローブ間のティアダウンをスキップします(デバッグ専用)。"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "メニューにトークンアカウントを積み重ねて表示します(オフの場合はアカウント切替バーを表示します)。"; +"Start at Login" = "ログイン時に起動"; +"Status" = "ステータス"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Claude の sessionKey Cookie または OAuth アクセストークンを保存します。"; +"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))" = "第 3(\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "第 3(\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "この Mac のデフォルトの Codex アカウントです。"; +"Toggle" = "切り替え"; +"Toggle subtitle" = "サブタイトルを切り替え"; +"Token" = "トークン"; +"Trigger the menu bar menu from anywhere." = "どこからでもメニューバーのメニューを開きます。"; +"True" = "True"; +"Twitter" = "Twitter"; +"Unsupported" = "未対応"; +"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)." = "中国本土向けエンドポイント(open.bigmodel.cn)には BigModel を使用します。"; +"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" = "Web サイト"; +"Weekly limit confetti" = "週間上限の紙吹雪"; +"Weekly token limit" = "週間トークン上限"; +"Weekly usage" = "週間使用量"; +"Weekly usage unavailable for this account." = "このアカウントでは週間使用量を取得できません。"; +"Window: \\(window)" = "ウインドウ: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "デバッグ用にログを \\(self.fileLogPath) に書き込みます。"; +"Yes" = "はい"; +"\\(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" = "デバイスコードをクリップボードにコピーしました: %1$@\n\n確認先: %2$@"; +"copilot_device_code_copied" = "デバイスコードをコピーしました。"; +"copilot_verify_at" = "%@ で確認してください"; +"copilot_waiting_text" = "ブラウザでサインインを完了してください。\nサインインが完了すると、このウインドウは自動的に閉じます。"; +"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" = "第 3(%@)"; +"multiple_workspaces_found" = "CodexBar は %@ の複数のワークスペースを見つけました。追加するワークスペースを選択してください。"; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "最大 %@ 個のプロバイダを選択"; +"remove_account_message" = "%@ を CodexBar から削除しますか?マネージド Codex ホームも削除されます。"; +"version_format" = "バージョン %@"; +"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今すぐターミナルを開きますか?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID が設定されていますが、workspaceID に対応しているのは opencode、opencodego、deepgram のみです。"; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT License."; + +/* General Pane */ +"section_system" = "システム"; +"section_usage" = "使用量"; +"section_automation" = "自動化"; +"language_title" = "言語"; +"language_subtitle" = "表示言語を変更します。完全に反映するにはアプリの再起動が必要です。"; +"language_system" = "システム"; +"language_english" = "英語"; +"language_spanish" = "スペイン語"; +"language_catalan" = "カタロニア語"; +"language_chinese_simplified" = "簡体字中国語"; +"language_chinese_traditional" = "繁体字中国語"; +"language_portuguese_brazilian" = "ポルトガル語(ブラジル)"; +"language_dutch" = "オランダ語"; +"language_swedish" = "スウェーデン語"; +"language_french" = "フランス語"; +"language_ukrainian" = "ウクライナ語"; +"language_japanese" = "日本語"; +"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" = "自動更新: 1 時間ごと · タイムアウト: 10 分"; +"refresh_cadence_title" = "更新間隔"; +"refresh_cadence_subtitle" = "CodexBar がバックグラウンドでプロバイダをポーリングする頻度です。"; +"manual_refresh_hint" = "自動更新はオフです。メニューの「更新」コマンドを使用してください。"; +"check_provider_status_title" = "プロバイダのステータスを確認"; +"check_provider_status_subtitle" = "OpenAI/Claude のステータスページと Gemini/Antigravity 用の Google Workspace をポーリングし、障害情報をアイコンとメニューに表示します。"; +"session_quota_notifications_title" = "セッションクォータ通知"; +"session_quota_notifications_subtitle" = "5 時間のセッションクォータが 0% になったとき、および再び利用可能になったときに通知します。"; +"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_notification_title" = "%1$@ の%2$@クォータが残りわずか"; +"quota_warning_notification_body" = "残り %1$@。設定した %2$d%% の%3$@警告しきい値に達しました。"; +"quota_warning_notification_body_with_account" = "アカウント %1$@。残り %2$@。設定した %3$d%% の%4$@警告しきい値に達しました。"; +"session_depleted_notification_title" = "%@ のセッションを使い切りました"; +"session_depleted_notification_body" = "残り 0% です。再び利用可能になったら通知します。"; +"session_restored_notification_title" = "%@ のセッションが回復しました"; +"session_restored_notification_body" = "セッションクォータが再び利用可能になりました。"; +"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 titles */ +"tab_general" = "一般"; +"tab_providers" = "プロバイダ"; +"tab_display" = "表示"; +"tab_advanced" = "詳細"; +"tab_about" = "情報"; +"tab_debug" = "デバッグ"; + +/* Providers Pane */ +"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` を実行してから再試行してください。"; +"codex_login_output" = "codex login の出力:"; +"managed_login_missing_email" = "Codex ログインは完了しましたが、アカウントのメールアドレスを取得できませんでした。アカウントが完全にサインインしていることを確認してから、再試行してください。"; +"login_success_notification_title" = "%@ のログインに成功しました"; +"login_success_notification_body" = "アプリに戻れます。認証が完了しました。"; +"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 キー上限)"; + +/* Display Pane */ +"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" = "すべてのトークンアカウントを表示"; +"show_all_token_accounts_subtitle" = "メニューにトークンアカウントを積み重ねて表示します(オフの場合はアカウント切替バーを表示します)。"; +"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" = "プロバイダが選択されていません"; + +/* Advanced Pane */ +"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" = "アプリバンドル内に CodexBarCLI が見つかりません。"; +"no_writable_bin_dirs" = "書き込み可能な bin ディレクトリが見つかりません。"; +"show_debug_settings_title" = "デバッグ設定を表示"; +"show_debug_settings_subtitle" = "デバッグタブにトラブルシューティングツールを表示します。"; +"surprise_me_title" = "サプライズ"; +"surprise_me_subtitle" = "エージェントたちがメニューバーで少し遊ぶのが好きか試してみてください。"; +"weekly_limit_confetti_title" = "週間上限の紙吹雪"; +"weekly_limit_confetti_subtitle" = "週間使用量がリセットされたときに全画面の紙吹雪を再生します。"; +"hide_personal_info_title" = "個人情報を隠す"; +"hide_personal_info_subtitle" = "メニューバーとメニュー UI のメールアドレスを伏せ字にします。"; +"show_provider_storage_usage_title" = "プロバイダのストレージ使用量を表示"; +"show_provider_storage_usage_subtitle" = "ローカルディスクの使用量をメニューに表示します。既知のプロバイダ所有パスをバックグラウンドでスキャンします。"; +"section_keychain_access" = "キーチェーンアクセス"; +"keychain_access_caption" = "キーチェーンの読み書きをすべて無効にします。「常に許可」をクリックしても macOS が「Chrome/Brave/Edge Safe Storage」のプロンプトを表示し続ける場合に使用してください。有効中はブラウザの Cookie 読み込みが利用できないため、プロバイダで Cookie ヘッダーを手動で貼り付けてください。CLI 経由の Claude/Codex OAuth は引き続き動作します。"; +"disable_keychain_access_title" = "キーチェーンアクセスを無効にする"; +"disable_keychain_access_subtitle" = "有効中はキーチェーンへのアクセスを一切行いません。"; + +/* About Pane */ +"about_tagline" = "トークンが尽きませんように—エージェントの上限を常に見守りましょう。"; +"link_github" = "GitHub"; +"link_website" = "Webサイト"; +"link_twitter" = "Twitter"; +"link_email" = "メール"; +"check_updates_auto" = "アップデートを自動的に確認"; +"update_channel" = "アップデートチャンネル"; +"check_for_updates" = "アップデートを確認…"; +"updates_unavailable" = "このビルドではアップデートを利用できません。"; +"copyright" = "© 2026 Peter Steinberger. MIT License."; + +/* Debug Pane */ +"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" = "ログインシェル PATH(起動時に取得)"; +"cleared" = "消去しました。"; +"no_fetch_attempts" = "取得の試行はまだありません。"; +"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 をオンにしてください。"; + +/* Metric preferences */ +"metric_pref_automatic" = "自動"; +"metric_pref_primary" = "プライマリ"; +"metric_pref_secondary" = "セカンダリ"; +"metric_pref_tertiary" = "ターシャリ"; +"metric_pref_extra_usage" = "追加使用量"; +"metric_pref_average" = "平均"; + +/* Display modes */ +"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%)"; + +/* Provider status */ +"status_operational" = "正常稼働中"; +"status_partial_outage" = "一部障害"; +"status_major_outage" = "重大な障害"; +"status_critical_issue" = "致命的な問題"; +"status_maintenance" = "メンテナンス中"; +"status_unknown" = "ステータス不明"; + +/* Refresh frequency */ +"refresh_manual" = "手動"; +"refresh_1min" = "1分"; +"refresh_2min" = "2分"; +"refresh_5min" = "5分"; +"refresh_15min" = "15分"; +"refresh_30min" = "30分"; + +/* Additional keys */ +"not_found" = "見つかりません"; + +/* Cost estimation */ +"cost_header_estimated" = "コスト(推定)"; +"cost_estimate_hint" = "ローカルログからの推定値 · 請求額と異なる場合があります"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "AI Assistant 対応の JetBrains IDE が検出されませんでした。JetBrains IDE をインストールし、AI Assistant を有効にしてください。"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API トークンが設定されていません。環境変数 OPENROUTER_API_KEY を設定するか、設定で構成してください。"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API トークンが見つかりません。~/.codexbar/config.json の apiKey または Z_AI_API_KEY を設定してください。"; +"Missing DeepSeek API key." = "DeepSeek API キーがありません。"; +"%@ is unavailable in the current environment." = "%@ は現在の環境では利用できません。"; +"All Systems Operational" = "全システム正常稼働中"; +"Last 30 days" = "過去30日間"; +"Last 30 days:" = "過去30日間:"; +"This month" = "今月"; +"Store multiple OpenAI API keys." = "複数の OpenAI API キーを保存します。"; +"Admin API key" = "管理者 API キー"; +"Open billing" = "請求情報を開く"; +"Google accounts" = "Google アカウント"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "複数の Antigravity Google OAuth アカウントを保存して素早く切り替えられます。"; +"Add Google Account" = "Google アカウントを追加"; +"Open Token Plan" = "トークンプランを開く"; +"Text Generation" = "テキスト生成"; +"Text to Speech" = "音声合成"; +"Music Generation" = "音楽生成"; +"Image Generation" = "画像生成"; +"No local data found" = "ローカルデータが見つかりません"; +"Credits unavailable; keep Codex running to refresh." = "クレジット情報を取得できません。更新するには Codex を実行したままにしてください。"; +"No available fetch strategy for minimax." = "minimax に利用可能な取得戦略がありません。"; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Cursor のセッションが見つかりません。Safari、Chrome、Microsoft Edge、Brave、Arc、Dia、ChatGPT Atlas、Chromium、Helium、Vivaldi、Yandex Browser、Firefox、Zen、Colibri、Sidekick、Opera、Opera GX、または Edge Canary で cursor.com にログインしてください。Safari をお使いの場合は、システム設定 ▸ プライバシーとセキュリティ で CodexBar にフルディスクアクセスを許可してください。CodexBar のメニューから Cursor にサインインすることもできます(アカウントを追加/切り替え)。"; +"No OpenCode session cookies found in browsers." = "ブラウザに OpenCode のセッション Cookie が見つかりません。"; +"No available fetch strategy for %@." = "%@ に利用可能な取得戦略がありません。"; +"Today" = "今日"; +"Today tokens" = "今日のトークン"; +"30d cost" = "30日間のコスト"; +"30d tokens" = "30日間のトークン"; +"Latest tokens" = "最新のトークン"; +"Top model" = "最多使用モデル"; +"Storage" = "ストレージ"; +"Add Account..." = "アカウントを追加..."; +"Usage Dashboard" = "使用状況ダッシュボード"; +"Status Page" = "ステータスページ"; +"Settings..." = "設定..."; +"About CodexBar" = "CodexBar について"; +"Quit" = "終了"; +"Last %d day" = "過去%d日間"; +"Last %d days" = "過去%d日間"; +"%@ tokens" = "%@ トークン"; +"Latest billing day" = "直近の請求日"; +"Latest billing day (%@)" = "直近の請求日(%@)"; +"%@ left" = "残り %@"; +"Resets %@" = "%@ にリセット"; +"Resets in %@" = "%@ 後にリセット"; +"Resets now" = "まもなくリセット"; +"Lasts until reset" = "リセットまで持続"; +"Updated %@" = "%@ に更新"; +"Updated %@h ago" = "%@時間前に更新"; +"Updated %@m ago" = "%@分前に更新"; +"Updated just now" = "たった今更新"; +"Projected empty in %@" = "%@ 後に枯渇する見込み"; +"Runs out in %@" = "%@ 後に使い切る見込み"; +"Pace: %@" = "ペース: %@"; +"Pace: %@ · %@" = "ペース: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "枯渇リスク ≈ %d%%"; +"%d%% in deficit" = "%d%% 不足"; +"%d%% in reserve" = "%d%% 余裕"; +"usage_percent_suffix_left" = "残り"; +"usage_percent_suffix_used" = "使用済み"; +"Store multiple DeepSeek API keys." = "複数の DeepSeek API キーを保存します。"; +"This week" = "今週"; +"Week" = "週"; +"Month" = "月"; +"Models" = "モデル"; +"24h tokens" = "24時間のトークン"; +"Latest hour" = "直近1時間"; +"Peak hour" = "ピーク時間帯"; +"Top method" = "最多使用メソッド"; +"30d cash" = "30日間の支出"; +"30d billing history from MiniMax web session" = "MiniMax Web セッションからの30日間の請求履歴"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer の請求情報は反映が遅れることがあります。"; +"Rate limit: %d / %@" = "レート制限: %d / %@"; +"Key remaining" = "キーの残量"; +"No limit set for the API key" = "API キーに上限が設定されていません"; +"API key limit unavailable right now" = "API キーの上限は現在取得できません"; +"This month: %@ tokens" = "今月: %@ トークン"; +"No utilization data yet." = "使用率データはまだありません。"; +"No %@ utilization data yet." = "%@ の使用率データはまだありません。"; +"%@: %@%% used" = "%@: %@%% 使用済み"; +"%dd" = "%d日"; +"today" = "今日"; +"just now" = "たった今"; +"On pace" = "想定ペース"; +"Runs out now" = "まもなく使い切ります"; +"Projected empty now" = "まもなく枯渇する見込み"; +"Switch Account..." = "アカウントを切り替え..."; +"Update ready, restart now?" = "アップデートの準備ができました。今すぐ再起動しますか?"; +"Daily" = "日別"; +"Hourly Tokens" = "時間別トークン"; +"No data" = "データなし"; +"No usage breakdown data available." = "使用状況の内訳データがありません。"; + +"Today: %@ · %@ tokens" = "今日: %@ · %@ トークン"; +"Today: %@" = "今日: %@"; +"Today: %@ tokens" = "今日: %@ トークン"; +"Last 30 days: %@ · %@ tokens" = "過去30日間: %@ · %@ トークン"; +"Last 30 days: %@" = "過去30日間: %@"; +"Est. total (30d): %@" = "推定合計(30日間): %@"; +"Est. total (%@): %@" = "推定合計(%@): %@"; +"Hover a bar for details" = "バーにポインタを合わせると詳細が表示されます"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ トークン"; +"No providers selected for Overview." = "概要に表示するプロバイダが選択されていません。"; +"No overview data available." = "概要データがありません。"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "自動では、まずローカルの IDE API を使用し、IDE が閉じている場合は Google OAuth を使用します。"; +"Login with Google" = "Google でログイン"; + +/* Popup panels */ +"No usage configured." = "使用状況が設定されていません。"; +"Quota" = "クォータ"; +"tokens" = "トークン"; +"requests" = "リクエスト"; +"Latest" = "最新"; +"Monthly" = "月間"; +"Sonnet" = "Sonnet"; +"Overages" = "超過分"; +"Activity" = "アクティビティ"; +"Copied" = "コピーしました"; +"Copy error" = "エラーをコピー"; +"Copy path" = "パスをコピー"; +"Extra usage spent" = "追加使用分の支出"; +"Credits remaining" = "残りクレジット"; +"Using CLI fallback" = "CLI フォールバックを使用中"; +"Balance updates in near-real time (up to 5 min lag)" = "残高はほぼリアルタイムで更新されます(最大5分の遅延)"; +"Daily billing data finalizes at 07:00 UTC" = "日次請求データは 07:00 UTC に確定します"; +"%@ of %@ credits left" = "クレジット残り %@ / %@"; +"%@ of %@ bonus credits left" = "ボーナスクレジット残り %@ / %@"; +"%@ / %@ (%@ remaining)" = "%@ / %@(残り %@)"; +"%@/%@ left" = "残り %@/%@"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "%@ に再生成"; +"used after next regen" = "次回再生成後の使用率"; +"after next regen" = "次回再生成後"; +"Near full" = "ほぼ満タン"; +"Full in ~1 regen" = "約1回の再生成で満タン"; +"Full in ~%.0f regens" = "約%.0f回の再生成で満タン"; +"Overage usage" = "超過使用量"; +"Overage cost" = "超過コスト"; +"credits" = "クレジット"; +"Zen balance" = "Zen 残高"; +"API spend" = "API 支出"; +"Extra usage" = "追加使用量"; +"Quota usage" = "クォータ使用量"; +"%.0f%% used" = "%.0f%% 使用済み"; +"Usage history (today)" = "使用履歴(今日)"; +"Usage history (%d days)" = "使用履歴(%d日間)"; +"%d percent remaining" = "残り %d パーセント"; +"Unknown" = "不明"; +"stale data" = "古いデータ"; +"No credits history data." = "クレジット履歴データがありません。"; +"No credits history data available." = "利用可能なクレジット履歴データがありません。"; +"Credits history chart" = "クレジット履歴チャート"; +"%d days of credits data" = "%d日間のクレジットデータ"; +"Usage breakdown chart" = "使用状況の内訳チャート"; +"%d days of usage data across %d services" = "%2$dサービスにわたる%1$d日間の使用状況データ"; +"Cost history chart" = "コスト履歴チャート"; +"%d days of cost data" = "%d日間のコストデータ"; +"Plan utilization chart" = "プラン使用率チャート"; +"%d utilization samples" = "%d 件の使用率サンプル"; +"Hourly Usage" = "時間別使用量"; +"Usage remaining" = "残りの使用量"; +"Usage used" = "使用済みの使用量"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "APIキーを確認しました。OllamaはAPI経由でCloudクォータ上限を公開していません。"; +"Last 30 days: %@ tokens" = "過去30日間: %@ トークン"; +"7d spend" = "7日間の支出"; +"30d spend" = "30日間の支出"; +"Cache read" = "キャッシュ読み取り"; +"Claude Admin API 30 day spend trend" = "Claude Admin API の30日間支出推移"; +"OpenRouter API key spend trend" = "OpenRouter APIキーの支出推移"; +"z.ai hourly token trend" = "z.ai の時間別トークン推移"; +"MiniMax 30 day token usage trend" = "MiniMax の30日間トークン使用量推移"; +"Today cash" = "本日の現金"; +"DeepSeek 30 day token usage trend" = "DeepSeek の30日間トークン使用量推移"; +"cache-hit input" = "キャッシュヒット入力"; +"cache-miss input" = "キャッシュミス入力"; +"output" = "出力"; +"Requests" = "リクエスト"; +"Reported by OpenAI Admin API organization usage." = "OpenAI Admin API の組織使用量から報告されています。"; +"Reported by Mistral billing usage." = "Mistral の請求使用量から報告されています。"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "選択したホスト上で GitHub OAuth Device Flow を使ってアカウントを追加します。"; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "サインイン済みの各 Google アカウントを保存し、Antigravity をすばやく切り替えられるようにします。利用可能な場合は Antigravity.app の OAuth を使用し、上書き設定として ANTIGRAVITY_OAUTH_CLIENT_ID と ANTIGRAVITY_OAUTH_CLIENT_SECRET を使用できます。"; +"Manual cleanup: past sessions" = "手動クリーンアップ: 過去のセッション"; +"Clearing removes past resume, continue, and rewind history." = "消去すると、過去の再開・継続・巻き戻しの履歴が削除されます。"; +"Manual cleanup: file checkpoints" = "手動クリーンアップ: ファイルチェックポイント"; +"Clearing removes checkpoint restore data for previous edits." = "消去すると、過去の編集のチェックポイント復元データが削除されます。"; +"Manual cleanup: saved plans" = "手動クリーンアップ: 保存済みプラン"; +"Clearing removes old plan-mode files." = "消去すると、古いプランモードのファイルが削除されます。"; +"Manual cleanup: debug logs" = "手動クリーンアップ: デバッグログ"; +"Clearing removes past debug logs." = "消去すると、過去のデバッグログが削除されます。"; +"Manual cleanup: attachment cache" = "手動クリーンアップ: 添付ファイルキャッシュ"; +"Clearing removes cached large pastes or attached images." = "消去すると、キャッシュされた大きなペースト内容や添付画像が削除されます。"; +"Manual cleanup: session metadata" = "手動クリーンアップ: セッションメタデータ"; +"Clearing removes per-session environment metadata." = "消去すると、セッションごとの環境メタデータが削除されます。"; +"Manual cleanup: shell snapshots" = "手動クリーンアップ: シェルスナップショット"; +"Clearing removes leftover runtime shell snapshot files." = "消去すると、残存しているランタイムシェルのスナップショットファイルが削除されます。"; +"Manual cleanup: legacy todos" = "手動クリーンアップ: レガシー ToDo"; +"Clearing removes legacy per-session task lists." = "消去すると、セッションごとのレガシータスクリストが削除されます。"; +"Manual cleanup: sessions" = "手動クリーンアップ: セッション"; +"Clearing removes past Codex session history." = "消去すると、過去の Codex セッション履歴が削除されます。"; +"Manual cleanup: archived sessions" = "手動クリーンアップ: アーカイブ済みセッション"; +"Clearing removes archived Codex session history." = "消去すると、アーカイブされた Codex セッション履歴が削除されます。"; +"Manual cleanup: cache" = "手動クリーンアップ: キャッシュ"; +"Clearing removes provider-owned cached data." = "消去すると、プロバイダが保持するキャッシュデータが削除されます。"; +"Manual cleanup: logs" = "手動クリーンアップ: ログ"; +"Clearing removes local diagnostic logs." = "消去すると、ローカルの診断ログが削除されます。"; +"Manual cleanup: file history" = "手動クリーンアップ: ファイル履歴"; +"Clearing removes local edit checkpoint history." = "消去すると、ローカルの編集チェックポイント履歴が削除されます。"; +"Manual cleanup: temporary data" = "手動クリーンアップ: 一時データ"; +"Clearing removes local temporary provider data." = "消去すると、ローカルのプロバイダ一時データが削除されます。"; +"Total: %@" = "合計: %@"; +"%d more items" = "他 %d 件の項目"; +"Cleanup ideas" = "クリーンアップの候補"; +"%d unreadable item(s) skipped" = "読み取れない項目 %d 件をスキップしました"; + +"API key limit" = "APIキー上限"; +"Auth" = "認証"; +"Auto" = "自動"; +"Disabled — no recent data" = "無効 — 最近のデータなし"; +"Limits not available" = "上限情報なし"; +"No usage yet" = "まだ使用量がありません"; +"Not fetched yet" = "未取得"; +"Refreshing" = "更新中"; +"Session" = "セッション"; +"Source" = "ソース"; +"State" = "状態"; +"Unavailable" = "利用不可"; +"Weekly" = "週間"; +"not detected" = "未検出"; +"Estimated from local Codex logs for the selected account." = "選択したアカウントのローカル Codex ログから推定しています。"; +"minimax_usage_amount_format" = "使用量: %@ / %@"; +"minimax_used_percent_format" = "使用済み %@"; +"minimax_service_text_generation" = "テキスト生成"; +"minimax_service_text_to_speech" = "音声合成"; +"minimax_service_music_generation" = "音楽生成"; +"minimax_service_image_generation" = "画像生成"; +"minimax_service_lyrics_generation" = "歌詞生成"; +"minimax_service_coding_plan_vlm" = "コーディングプラン VLM"; +"minimax_service_coding_plan_search" = "コーディングプラン検索"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ は許可を待っています"; +"%@ requests" = "%@ リクエスト"; +"%@: %@ credits" = "%@: %@ クレジット"; +"30d requests" = "30日間のリクエスト"; +"4 days" = "4日間"; +"5 days" = "5日間"; +"7 days" = "7日間"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "APIキーで Ollama Cloud へのアクセスを確認できますが、クォータ上限の取得には引き続き Cookie が必要です。"; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS アクセスキー ID。AWS_ACCESS_KEY_ID でも設定できます。"; +"AWS region. Can also be set with AWS_REGION." = "AWS リージョン。AWS_REGION でも設定できます。"; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS シークレットアクセスキー。AWS_SECRET_ACCESS_KEY でも設定できます。"; +"Access key ID" = "アクセスキー ID"; +"Add Account" = "アカウントを追加"; +"Adding Account…" = "アカウントを追加中…"; +"Antigravity login failed" = "Antigravity のログインに失敗しました"; +"Antigravity login timed out" = "Antigravity のログインがタイムアウトしました"; +"Auth source" = "認証ソース"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自動では Xiaomi MiMo のブラウザ Cookie を読み込みます。"; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "自動では Chromium ブラウザの localStorage から Windsurf セッションデータを読み込みます。"; +"Automatic imports browser cookies from Bailian." = "自動では Bailian のブラウザ Cookie を読み込みます。"; +"Automatically imports browser cookies." = "ブラウザの Cookie を自動的に読み込みます。"; +"Automatically imports browser session cookies." = "ブラウザのセッション Cookie を自動的に読み込みます。"; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI のデプロイメント名。AZURE_OPENAI_DEPLOYMENT_NAME もサポートされています。"; +"Azure OpenAI key" = "Azure OpenAI キー"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI リソースのエンドポイント。AZURE_OPENAI_ENDPOINT もサポートされています。"; +"Base URL" = "ベース URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy インスタンスのベース URL。"; +"Browser cookies" = "ブラウザ Cookie"; +"Cap end" = "上限終了"; +"Cap start" = "上限開始"; +"Capacity End" = "キャパシティ終了"; +"Capacity Start" = "キャパシティ開始"; +"Changelog" = "変更履歴"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "国際アカウントまたは中国本土アカウント用の Moonshot/Kimi API ホストを選択します。"; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar は、APIキーのみの構成でサインインしているシステムアカウントを置き換えることはできません。"; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar はそのアカウントの保存済み認証情報を見つけられませんでした。再認証してからやり直してください。"; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar は管理アカウントのストレージを読み取れませんでした。別のアカウントを追加する前にストアを復旧してください。"; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar はそのアカウントの保存済み認証情報を読み取れませんでした。再認証してからやり直してください。"; +"CodexBar could not read the current system account on this Mac." = "CodexBar はこの Mac の現在のシステムアカウントを読み取れませんでした。"; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar はこの Mac の現在使用中の Codex 認証情報を置き換えられませんでした。"; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar は切り替え前に現在のシステムアカウントを安全に保全できませんでした。"; +"CodexBar could not save the current system account before switching." = "CodexBar は切り替え前に現在のシステムアカウントを保存できませんでした。"; +"CodexBar could not update managed account storage." = "CodexBar は管理アカウントのストレージを更新できませんでした。"; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar は、現在のシステムアカウントをすでに使用している別の管理アカウントを検出しました。切り替える前に重複アカウントを解消してください。"; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar はブラウザの Cookie を復号してアカウントを認証するために、macOS キーチェーンに「%@」へのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar は Claude の使用量を取得するために、macOS キーチェーンに Claude Code の OAuth トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Amp の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Augment の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar は Claude ウェブの使用量を取得するために、macOS キーチェーンに Claude の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Cursor の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Factory の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに GitHub Copilot のトークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Kimi K2 の APIキーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Kimi の認証トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに MiniMax の API トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに MiniMax の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar は Codex ダッシュボードの追加情報を取得するために、macOS キーチェーンに OpenAI の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに OpenCode の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Synthetic の APIキーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに z.ai の API トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"Could not open Cursor login in your browser." = "ブラウザで Cursor のログイン画面を開けませんでした。"; +"Could not open browser for Antigravity" = "Antigravity 用のブラウザを開けませんでした"; +"Credits used" = "使用済みクレジット"; +"Day" = "日"; +"Deployment" = "デプロイメント"; +"Drag to reorder" = "ドラッグして並べ替え"; +"Endpoint" = "エンドポイント"; +"Enterprise host" = "Enterprise ホスト"; +"Extra usage balance: %@" = "追加使用量の残高: %@"; +"Keychain Access Required" = "キーチェーンへのアクセスが必要です"; +"Kiro menu bar value" = "Kiro メニューバー表示値"; +"Label" = "ラベル"; +"No organizations loaded. Click Refresh after setting your API key." = "組織が読み込まれていません。APIキーを設定してから「更新」をクリックしてください。"; +"No output captured." = "出力は取得されませんでした。"; +"No system account" = "システムアカウントなし"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Augment を開く(ログアウトして再ログイン)"; +"Open Codebuff Dashboard" = "Codebuff ダッシュボードを開く"; +"Open Command Code Settings" = "Command Code 設定を開く"; +"Open Crof dashboard" = "Crof ダッシュボードを開く"; +"Open Manus" = "Manus を開く"; +"Open MiMo Balance" = "MiMo 残高を開く"; +"Open Moonshot Console" = "Moonshot コンソールを開く"; +"Open Ollama API Keys" = "Ollama APIキーを開く"; +"Open StepFun Platform" = "StepFun プラットフォームを開く"; +"Open T3 Chat Settings" = "T3 Chat 設定を開く"; +"Open Volcengine Ark Console" = "Volcengine Ark コンソールを開く"; +"Open legacy provider docs" = "レガシープロバイダのドキュメントを開く"; +"Open projects" = "プロジェクトを開く"; +"Open this URL manually to continue login:\n\n%@" = "ログインを続けるには、この URL を手動で開いてください:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "複数の Anthropic 組織にリンクされたアカウント用のオプションの組織 ID。"; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "オプション。設定済みの Admin APIキーに適用されます。選択したトークンアカウントには OPENAI_PROJECT_ID は引き継がれません。"; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "オプション。GitHub Enterprise のホストを入力してください(例: octocorp.ghe.com)。github.com の場合は空欄のままにしてください。"; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "オプション。空欄のままにすると、APIキーから参照できるプロジェクトを検出して集計します。"; +"Org ID (optional)" = "組織 ID(オプション)"; +"Organizations" = "組織"; +"Password" = "パスワード"; +"%@ authentication is disabled." = "%@ の認証は無効になっています。"; +"%@ cookies are disabled." = "%@ の Cookie は無効になっています。"; +"%@ web API access is disabled." = "%@ のウェブ API アクセスは無効になっています。"; +"Disable %@ dashboard cookie usage." = "%@ ダッシュボードの Cookie 使用を無効にします。"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "詳細設定でキーチェーンへのアクセスが無効になっているため、ブラウザ Cookie の読み込みは利用できません。"; +"Manually paste an %@ from a browser session." = "ブラウザセッションから %@ を手動で貼り付けてください。"; +"Paste a Cookie header captured from %@." = "%@ から取得した Cookie ヘッダーを貼り付けてください。"; +"Paste a Cookie header from %@." = "%@ の Cookie ヘッダーを貼り付けてください。"; +"Paste a Cookie header or cURL capture from %@." = "%@ の Cookie ヘッダーまたは cURL キャプチャを貼り付けてください。"; +"Paste a Cookie header or full cURL capture from %@." = "%@ の Cookie ヘッダーまたは完全な cURL キャプチャを貼り付けてください。"; +"Paste a Cookie or Authorization header from %@." = "%@ の Cookie または Authorization ヘッダーを貼り付けてください。"; +"Paste a full cookie header or the %@ value." = "完全な Cookie ヘッダーまたは %@ の値を貼り付けてください。"; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "T3 Chat 設定から取得した Cookie ヘッダーまたは完全な cURL キャプチャを貼り付けてください。"; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "admin.mistral.ai へのリクエストの Cookie ヘッダーを貼り付けてください。ory_session_* Cookie が含まれている必要があります。"; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "platform.stepfun.com にログイン中のブラウザセッションから Oasis-Token を貼り付けてください。"; +"Paste the %@ JSON bundle from %@." = "%@ の JSON バンドルを %@ から貼り付けてください。"; +"Paste the %@ value or a full Cookie header." = "%@ の値または完全な Cookie ヘッダーを貼り付けてください。"; +"Personal account" = "個人アカウント"; +"Project ID" = "プロジェクト ID"; +"Re-auth" = "再認証"; +"Re-login at claude.ai" = "claude.ai で再ログイン"; +"Re-authenticating…" = "再認証中…"; +"Refresh Session" = "セッションを更新"; +"Refresh organizations" = "組織を更新"; +"Region" = "リージョン"; +"Reload" = "再読み込み"; +"Reorder" = "並べ替え"; +"Secret access key" = "シークレットアクセスキー"; +"Series" = "シリーズ"; +"Service" = "サービス"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "メニューバーアイコンの横に Kiro のクレジット、パーセント、またはその両方を表示/非表示にします。"; +"Show usage for organizations you belong to. Personal account is always shown." = "所属している組織の使用量を表示します。個人アカウントは常に表示されます。"; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "ブラウザで cursor.com にサインインしてから、CodexBar で Cursor を更新してください。"; +"Simulated error text" = "シミュレートされたエラーテキスト"; +"StepFun platform account (phone number or email)." = "StepFun プラットフォームのアカウント(電話番号またはメールアドレス)。"; +"Stored in ~/.codexbar/config.json." = "~/.codexbar/config.json に保存されます。"; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "~/.codexbar/config.json に保存されます。AZURE_OPENAI_API_KEY もサポートされています。"; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "~/.codexbar/config.json に保存されます。公式の Kimi API には Moonshot / Kimi API を使用してください。"; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "~/.codexbar/config.json に保存されます。APIキーは Volcengine Ark コンソールから取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "~/.codexbar/config.json に保存されます。キーは Ollama の設定から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "~/.codexbar/config.json に保存されます。キーは console.deepgram.com から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "~/.codexbar/config.json に保存されます。キーは elevenlabs.io/app/settings/api-keys から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "~/.codexbar/config.json に保存されます。キーは openrouter.ai/settings/keys から取得し、そこでキーの支出上限を設定すると APIキーのクォータ追跡が有効になります。"; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "~/.codexbar/config.json に保存されます。Warp で「Settings」>「Platform」>「API Keys」を開いて作成してください。"; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "~/.codexbar/config.json に保存されます。メトリクスには Groq Enterprise の Prometheus アクセスが必要です。"; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "~/.codexbar/config.json に保存されます。OPENAI_ADMIN_KEY が推奨されますが、OPENAI_API_KEY も引き続き使用できます。"; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "~/.codexbar/config.json に保存されます。Anthropic の Admin APIキーが必要です。"; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "~/.codexbar/config.json に保存されます。/v1/quota-stats に使用されます。"; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "~/.codexbar/config.json に保存されます。CODEBUFF_API_KEY を指定するか、CodexBar に ~/.config/manicode/credentials.json(`codebuff login` で作成)を読み込ませることもできます。"; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "~/.codexbar/config.json に保存されます。CROF_API_KEY を指定することもできます。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "~/.codexbar/config.json に保存されます。KILO_API_KEY または ~/.local/share/kilo/auth.json(kilo.access)を指定することもできます。"; +"T3 Chat cookie" = "T3 Chat Cookie"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "そのアカウントは CodexBar で利用できなくなっています。アカウントリストを更新してからやり直してください。"; +"The browser login did not complete in time. Try Antigravity login again." = "ブラウザでのログインが時間内に完了しませんでした。Antigravity のログインをもう一度お試しください。"; +"Timed out waiting for Cursor login. %@" = "Cursor のログイン待機がタイムアウトしました。%@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Cursor のログイン待機がタイムアウトしました。%@ 最後のエラー: %@"; +"Today requests" = "本日のリクエスト"; +"Total (30d): %@ credits" = "合計(30日間): %@ クレジット"; +"Username" = "ユーザ名"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "ユーザ名とパスワードでログインし、Oasis-Token を自動的に取得します。"; +"Uses username + password to login and obtain an %@ automatically." = "ユーザ名とパスワードでログインし、%@ を自動的に取得します。"; +"Utilization End" = "使用率終了"; +"Utilization Start" = "使用率開始"; +"Verbosity" = "詳細度"; +"Windsurf session JSON bundle" = "Windsurf セッション JSON バンドル"; +"Workspace ID" = "ワークスペース ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "StepFun プラットフォームのパスワード。ログインしてセッショントークンを取得するために使用されます。"; +"claude /login exited with status %d." = "claude /login がステータス %d で終了しました。"; +"codex login exited with status %d." = "codex login がステータス %d で終了しました。"; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nまたは Abacus AI ダッシュボードからの cURL キャプチャを貼り付けてください"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nまたは __Secure-next-auth.session-token の値を貼り付けてください"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nまたは kimi-auth トークンの値を貼り付けてください"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nまたは session_id の値のみを貼り付けてください"; +"Clear" = "消去"; +"No matching providers" = "一致するプロバイダがありません"; +"Search providers" = "プロバイダを検索"; + +"language_vietnamese" = "ベトナム語"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index ae229905..67cc5e47 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -402,6 +402,7 @@ "language_dutch" = "Nederlands"; "language_french" = "Frans"; "language_ukrainian" = "Oekraïens"; +"language_japanese" = "Japans"; "language_swedish" = "Zweeds"; "language_vietnamese" = "Vietnamees"; "start_at_login_title" = "Begin bij Inloggen"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 300ee8ea..98a98f57 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -403,6 +403,7 @@ "language_dutch" = "Nederlands"; "language_french" = "Francês"; "language_ukrainian" = "Ucraniano"; +"language_japanese" = "Japonês"; "start_at_login_title" = "Iniciar ao fazer login"; "start_at_login_subtitle" = "Abre o CodexBar automaticamente ao iniciar o Mac."; "show_cost_summary" = "Mostrar resumo de custos"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 737a3f80..ad74f19f 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -404,6 +404,7 @@ "language_swedish" = "Svenska"; "language_french" = "Franska"; "language_ukrainian" = "Ukrainska"; +"language_japanese" = "Japanska"; "start_at_login_title" = "Starta vid inloggning"; "start_at_login_subtitle" = "Öppnar CodexBar automatiskt när du startar din Mac."; "show_cost_summary" = "Visa kostnadssammanfattning"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index bcdf9bc6..37212d56 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -403,6 +403,7 @@ "language_french" = "Français"; "language_dutch" = "Нідерландська"; "language_ukrainian" = "Українська"; +"language_japanese" = "Японська"; "language_vietnamese" = "В'єтнамська"; "start_at_login_title" = "Почніть із входу"; "start_at_login_subtitle" = "Автоматично відкриває CodexBar під час запуску Mac."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index b62fdbd1..a737b0ed 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -403,6 +403,7 @@ "language_french" = "Tiếng Pháp"; "language_dutch" = "Tiếng Hà Lan"; "language_ukrainian" = "Tiếng Ukraina"; +"language_japanese" = "Tiếng Nhật"; "start_at_login_title" = "Bắt đầu khi đăng nhập"; "start_at_login_subtitle" = "Tự động mở CodexBar khi bạn khởi động máy Mac."; "show_cost_summary" = "Hiển thị tóm tắt chi phí"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 76c5225f..c42fe010 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -410,6 +410,7 @@ "language_dutch" = "Nederlands"; "language_french" = "法语"; "language_ukrainian" = "乌克兰语"; +"language_japanese" = "日语"; "start_at_login_title" = "开机启动"; "start_at_login_subtitle" = "启动 Mac 时自动打开 CodexBar。"; "show_cost_summary" = "显示费用摘要"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 583994b0..025f0916 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -410,6 +410,7 @@ "language_dutch" = "Nederlands"; "language_french" = "法語"; "language_ukrainian" = "烏克蘭語"; +"language_japanese" = "日語"; "start_at_login_title" = "登入時啟動"; "start_at_login_subtitle" = "登入 Mac 時自動開啟 CodexBar。"; "show_cost_summary" = "顯示費用摘要"; diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 32eeff2b..a2ffe874 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -16,6 +16,7 @@ struct LocalizationLanguageCatalogTests { "language_dutch", "language_ukrainian", "language_vietnamese", + "language_japanese", ] @Test @@ -68,4 +69,23 @@ struct LocalizationLanguageCatalogTests { #expect(contents.contains(key), "Missing localization key: \(key)") } } + + @Test + func `japanese usage chart accessibility text preserves argument meanings`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let jaURL = root.appendingPathComponent("Sources/CodexBar/Resources/ja.lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: jaURL) as? [String: String]) + let format = try #require(catalog["%d days of usage data across %d services"]) + + let rendered = String( + format: format, + locale: Locale(identifier: "ja_JP"), + arguments: [7, 3]) + + #expect(rendered.contains("7日間")) + #expect(rendered.contains("3サービス")) + } } diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index a7b19b7b..a7e4837e 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -77,6 +77,13 @@ struct PreferencesPaneSmokeTests { #expect(L("tab_general") == "通用") #expect(L("quota_warning_notifications_title") == "配额预警通知") #expect(L("show_provider_storage_usage_title") == "显示提供商存储用量") + + settings.appLanguage = "ja" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "ja") + #expect(L("language_title") == "言語") + #expect(L("start_at_login_title") == "ログイン時に起動") + #expect(L("quit_app") == "CodexBar を終了") } private static func makeSettingsStore(suite: String) -> SettingsStore { From 2ed15bf7f91901fcf5c20141a3d5721bd7970263 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 09:30:14 -0700 Subject: [PATCH 19/51] perf: defer overview provider rebuild (#1391) --- CHANGELOG.md | 1 + ...tatusItemController+OverviewSubmenus.swift | 5 +- .../StatusMenuOverviewSubmenuTests.swift | 143 ++++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 46 ------ 4 files changed, 147 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2f9508..4df2401b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! +- Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index 4d3444ed..ef9a6667 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -53,7 +53,8 @@ extension StatusItemController { self.lastMenuProvider = provider self.refreshProviderSelectionDependentUI(deferRendering: true) } - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) + // Custom-view clicks stay open and rebuild next turn. Standard menu-item activation can close; + // menuWillOpen then renders the saved provider without doing structural work inside the action. + self.requestProviderSwitcherMenuRebuild(menu, provider: provider) } } diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index df65ad9e..4311deaa 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -125,4 +125,147 @@ extension StatusMenuTests { ($0.representedObject as? String) == "overviewRow-zai" }) } + + @Test + func `selecting overview row defers provider detail rebuild`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" + }) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) + + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .cursor) + #expect(rebuildCount == 0) + #expect(menu.items.contains { + ($0.representedObject as? String)?.hasPrefix("overviewRow-") == true + }) + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + let representedIDs = menu.items.compactMap { $0.representedObject as? String } + let switcherButtons = (menu.items.first?.view as? ProviderSwitcherView)?.subviews + .compactMap { $0 as? NSButton } ?? [] + #expect(rebuildCount == 1) + #expect(representedIDs.contains("menuCard")) + #expect(representedIDs.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + #expect(switcherButtons.first(where: { $0.state == .on })?.tag == 2) + } + + @Test + func `overview row action close renders selected provider on next open`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" + }) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) + controller.menuDidClose(menu) + + await Task.yield() + await Task.yield() + #expect(rebuildCount == 0) + #expect(settings.selectedMenuProvider == .cursor) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let representedIDs = menu.items.compactMap { $0.representedObject as? String } + let switcherButtons = (menu.items.first?.view as? ProviderSwitcherView)?.subviews + .compactMap { $0 as? NSButton } ?? [] + #expect(representedIDs.contains("menuCard")) + #expect(representedIDs.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + #expect(switcherButtons.first(where: { $0.state == .on })?.tag == 2) + } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 2abbd518..d59357da 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -1657,50 +1657,4 @@ extension StatusMenuTests { #expect(claudeRow.action != nil) #expect(claudeRow.target is StatusItemController) } - - @Test - func `selecting overview row switches to provider detail`() throws { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true - settings.selectedMenuProvider = .codex - settings.mergedMenuLastSelectedWasOverview = true - - let registry = ProviderRegistry.shared - for provider in UsageProvider.allCases { - guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || provider == .cursor - settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) - } - - let fetcher = UsageFetcher() - let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let controller = StatusItemController( - store: store, - settings: settings, - account: fetcher.loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - - let menu = controller.makeMenu() - controller.menuWillOpen(menu) - - let cursorRow = try #require(menu.items.first { - ($0.representedObject as? String) == "overviewRow-cursor" - }) - let action = try #require(cursorRow.action) - let target = try #require(cursorRow.target as? StatusItemController) - _ = target.perform(action, with: cursorRow) - - #expect(settings.mergedMenuLastSelectedWasOverview == false) - #expect(settings.selectedMenuProvider == .cursor) - - let ids = self.representedIDs(in: menu) - #expect(ids.contains("menuCard")) - #expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false) - #expect(self.switcherButtons(in: menu).first(where: { $0.state == .on })?.tag == 2) - } } From 08c171b6b487654a0eb188494fa24bd1c4272a2e Mon Sep 17 00:00:00 2001 From: Hinotobi Date: Thu, 11 Jun 2026 00:44:23 +0800 Subject: [PATCH 20/51] [security] fix(providers): guard credentialed redirects (#1237) * Harden provider redirects carrying credentials * Cover provider secret headers in redirect guard * fix: guard OpenAI cookie importer redirects * Strip provider redirect credential headers * fix: harden provider redirect boundary --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + ...OpenAIDashboardBrowserCookieImporter.swift | 2 +- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 4 +- Sources/CodexBarCore/ProviderHTTPClient.swift | 48 +++++++++++++- .../ProviderHTTPClientTests.swift | 65 +++++++++++++++++++ 5 files changed, 115 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df2401b..ed5e7004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! +- Security: block credentialed provider redirects that leave the original HTTPS origin while preserving same-origin redirects (#1237). Thanks @Hinotoi-agent! - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index bf36e53c..5629bf4e 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -538,7 +538,7 @@ public struct OpenAIDashboardBrowserCookieImporter { request.setValue("application/json", forHTTPHeaderField: "Accept") do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("API \(url.host ?? "chatgpt.com") \(url.path) status=\(status)") guard status >= 200, status < 300 else { continue } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index c07871cb..e3d69c0b 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -763,7 +763,7 @@ public struct OpenAIDashboardFetcher { guard !cookieHeader.isEmpty else { return nil } do { - let (data, response) = try await URLSession.shared.data( + let (data, response) = try await ProviderHTTPClient.shared.data( for: self.dashboardUsageAPIRequest(cookieHeader: cookieHeader)) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("usage api status=\(status)") @@ -793,7 +793,7 @@ public struct OpenAIDashboardFetcher { for url in endpoints { do { - let (data, response) = try await URLSession.shared.data( + let (data, response) = try await ProviderHTTPClient.shared.data( for: self.dashboardIdentityAPIRequest(url: url, cookieHeader: cookieHeader)) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("identity api \(url.path) status=\(status)") diff --git a/Sources/CodexBarCore/ProviderHTTPClient.swift b/Sources/CodexBarCore/ProviderHTTPClient.swift index f5ea38a4..68b8b383 100644 --- a/Sources/CodexBarCore/ProviderHTTPClient.swift +++ b/Sources/CodexBarCore/ProviderHTTPClient.swift @@ -171,7 +171,7 @@ public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendabl private let session: URLSession public init(session: URLSession? = nil) { - self.session = session ?? URLSession(configuration: Self.defaultConfiguration()) + self.session = session ?? Self.redirectGuardedSession() } static func defaultConfiguration() -> URLSessionConfiguration { @@ -189,7 +189,16 @@ public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendabl // XCTest URLProtocol.registerClass stubs only intercept URLSession.shared on macOS. return .shared } - return URLSession(configuration: self.defaultConfiguration()) + return self.redirectGuardedSession() + } + + static func redirectGuardedSession( + configuration: URLSessionConfiguration = ProviderHTTPClient.defaultConfiguration()) -> URLSession + { + URLSession( + configuration: configuration, + delegate: ProviderHTTPRedirectGuardDelegate(), + delegateQueue: nil) } private static var isRunningTests: Bool { @@ -207,3 +216,38 @@ public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendabl try await self.session.data(for: request) } } + +final class ProviderHTTPRedirectGuardDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession( + _: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void) + { + completionHandler(Self.guardedRedirectRequest(originalURL: task.originalRequest?.url, redirectRequest: request)) + } + + static func guardedRedirectRequest(originalURL: URL?, redirectRequest request: URLRequest) -> URLRequest? { + guard let originalURL, let redirectedURL = request.url else { return nil } + guard originalURL.scheme?.caseInsensitiveCompare("https") == .orderedSame else { return nil } + guard redirectedURL.scheme?.caseInsensitiveCompare("https") == .orderedSame else { return nil } + guard self.isSameOrigin(originalURL, redirectedURL) else { return nil } + return request + } + + private static func isSameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.scheme?.lowercased() == rhs.scheme?.lowercased() + && lhs.host?.lowercased() == rhs.host?.lowercased() + && self.normalizedPort(lhs) == self.normalizedPort(rhs) + } + + private static func normalizedPort(_ url: URL) -> Int? { + if let port = url.port { return port } + switch url.scheme?.lowercased() { + case "http": return 80 + case "https": return 443 + default: return nil + } + } +} diff --git a/Tests/CodexBarTests/ProviderHTTPClientTests.swift b/Tests/CodexBarTests/ProviderHTTPClientTests.swift index c07b43e0..c2fa5fc0 100644 --- a/Tests/CodexBarTests/ProviderHTTPClientTests.swift +++ b/Tests/CodexBarTests/ProviderHTTPClientTests.swift @@ -129,6 +129,71 @@ struct ProviderHTTPClientTests { #expect(response.statusCode == 403) #expect(await script.requestCount() == 1) } + + @Test + func `redirect guard blocks cross origin redirects`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "https://attacker.example/capture"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key") + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks non HTTPS redirects`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "http://provider.example/capture"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks redirects without an original URL`() throws { + let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next"))) + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: nil, + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks port changes`() throws { + let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example:8443/usage"))) + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard preserves same origin HTTPS requests`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Authorization") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key") + redirectRequest.setValue("application/json", forHTTPHeaderField: "Accept") + + let guarded = try #require(ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest)) + + #expect(guarded.value(forHTTPHeaderField: "Cookie") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "Authorization") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "x-api-key") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "Accept") == "application/json") + } } extension ProviderHTTPRetryPolicy { From 71a38c29436760bb69193b98acf8a815722b29bf Mon Sep 17 00:00:00 2001 From: Vaibhav Arora Date: Wed, 10 Jun 2026 23:06:10 +0530 Subject: [PATCH 21/51] fix: keep Codex cost visible without quotas (#1390) Co-authored-by: Cursor --- CHANGELOG.md | 1 + .../CodexBar/MenuCardView+ModelHelpers.swift | 15 +- Sources/CodexBar/MenuCardView.swift | 10 +- ...MenuCardModelCodexDegradedQuotaTests.swift | 245 ++++++++++++++++++ 4 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5e7004..7b33d6eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! - Security: block credentialed provider redirects that leave the original HTTPS origin while preserving same-origin redirects (#1237). Thanks @Hinotoi-agent! +- Codex: keep local token and cost history visible when remote quota data is unavailable (#1390). Thanks @vaibhavarora14! - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 54d6ce01..8e45c3bb 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -29,6 +29,13 @@ extension UsageMenuCardView.Model { self.placeholder != nil } + var usesStackedDetailLayout: Bool { + !self.metrics.isEmpty || + self.creditsText != nil || + self.providerCost != nil || + self.tokenUsage != nil + } + static func progressColor(for provider: UsageProvider) -> Color { if provider == .elevenlabs { return Color(nsColor: .labelColor) @@ -52,7 +59,7 @@ extension UsageMenuCardView.Model { } if input.snapshot == nil, !input.isRefreshing, input.lastError == nil { - return L("No usage yet") + return self.hasLocalCodexTokenUsage(input) ? nil : L("No usage yet") } return nil @@ -70,6 +77,12 @@ extension UsageMenuCardView.Model { return lastError } + private static func hasLocalCodexTokenUsage(_ input: Input) -> Bool { + input.provider == .codex && + input.tokenCostUsageEnabled && + self.tokenUsageSnapshot(input: input) != nil + } + private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool { let currentError = lastError ?? input.lastError if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index ae567bf7..89c3d220 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -143,7 +143,7 @@ struct UsageMenuCardView: View { Divider() } - if self.model.metrics.isEmpty { + if !self.model.usesStackedDetailLayout { if let dashboard = self.model.inlineUsageDashboard { InlineUsageDashboardContent(model: dashboard) } else if !self.model.usageNotes.isEmpty { @@ -172,6 +172,10 @@ struct UsageMenuCardView: View { InlineUsageDashboardContent(model: dashboard) } else if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) } } } @@ -244,9 +248,7 @@ struct UsageMenuCardView: View { } private var hasDetails: Bool { - self.model.hasUsageContent || - self.model.tokenUsage != nil || - self.model.providerCost != nil + self.model.hasUsageContent || self.model.usesStackedDetailLayout } } diff --git a/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift new file mode 100644 index 00000000..a1c0817e --- /dev/null +++ b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift @@ -0,0 +1,245 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardModelCodexDegradedQuotaTests { + @Test + func `codex local token usage keeps remote quota unavailable error visible`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [ + .init( + date: "2026-06-05", + inputTokens: 710_217, + outputTokens: 11749, + totalTokens: 721_966, + costUSD: 1.081155, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "Codex usage is temporarily unavailable. Try refreshing.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.usesStackedDetailLayout) + #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) + #expect(model.tokenUsage?.sessionLine.contains("tokens") == true) + #expect(model.tokenUsage?.monthLine.contains("$583.13") == true) + #expect(model.tokenUsage?.monthLine.contains("tokens") == true) + } + + @Test + func `codex remote quota unavailable error stays visible when token usage is hidden`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + let error = "Codex usage is temporarily unavailable. Try refreshing." + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == error) + #expect(model.tokenUsage == nil) + } + + @Test + func `codex local token usage preserves limits unavailable placeholder`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(model.tokenUsage != nil) + #expect(model.usesStackedDetailLayout) + } + + @Test + func `codex local token usage preserves sign-in guidance`() throws { + let model = try self.makeModel( + tokenCostUsageEnabled: true, + lastError: "Codex CLI is not signed in. Run `codex login --device-auth`, then refresh.") + + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText.contains("codex login")) + #expect(model.tokenUsage != nil) + #expect(model.usesStackedDetailLayout) + } + + @Test + func `codex local token usage preserves mapped transport error`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + let error = try #require(CodexUIErrorMapper.userFacingMessage("Codex connection failed: timed out.")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) + } + + @Test + func `credits select stacked detail layout without quota metrics`() { + let model = UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "user@example.com", + subtitleText: "Not fetched yet", + subtitleStyle: .info, + planText: nil, + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: "$12.34 remaining", + creditsRemaining: 12.34, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: "No usage yet", + progressColor: .blue) + + #expect(model.usesStackedDetailLayout) + } + + private func makeModel( + tokenCostUsageEnabled: Bool, + lastError: String?) throws -> UsageMenuCardView.Model + { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + + return UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: lastError, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: tokenCostUsageEnabled, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } +} From cde92cfb91f3d7b0cc7f126285ced831d9cd983b Mon Sep 17 00:00:00 2001 From: scheinms <96508811+scheinms@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:24:03 -0400 Subject: [PATCH 22/51] perf: add menu chart render timing logs (#1366) --- .../StatusItemController+HostedSubmenus.swift | 5 +++++ Sources/CodexBar/StatusItemController+Menu.swift | 3 +++ ...StatusItemController+MenuInteractionRefresh.swift | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 71b1d417..3e5fdf44 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -1,5 +1,6 @@ import AppKit import CodexBarCore +import QuartzCore import SwiftUI extension StatusItemController { @@ -60,6 +61,7 @@ extension StatusItemController { providerRawValue: placeholder.toolTip) menu.removeAllItems() + let t0 = CACurrentMediaTime() let didHydrate: Bool = switch chartID { case Self.usageBreakdownChartID: self.appendUsageBreakdownChartItem(to: menu, width: width) @@ -100,6 +102,7 @@ extension StatusItemController { default: false } + self.logChartRenderDurationIfSlow("hydrateHostedSubview:\(chartID)", startedAt: t0) if !didHydrate { self.appendHostedSubviewUnavailableItem( @@ -126,6 +129,7 @@ extension StatusItemController { } menu.removeAllItems() + let t0 = CACurrentMediaTime() let didHydrate: Bool = switch identity.chartID { case Self.usageBreakdownChartID: self.appendUsageBreakdownChartItem(to: menu, width: width) @@ -158,6 +162,7 @@ extension StatusItemController { default: false } + self.logChartRenderDurationIfSlow("refreshHostedSubview:\(identity.chartID)", startedAt: t0) if !didHydrate { self.appendHostedSubviewUnavailableItem( diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index c0e254bb..4a5158a1 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -597,6 +597,9 @@ extension StatusItemController { } guard !rows.isEmpty else { return false } + let t0 = CACurrentMediaTime() + defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } + for (index, row) in rows.enumerated() { let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let storageText = self.store.storageFootprintText(for: row.provider) diff --git a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift index d5d0ff1c..6c27e636 100644 --- a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift +++ b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift @@ -5,6 +5,7 @@ import QuartzCore extension StatusItemController { private static let defaultDeferredMenuInteractionRefreshDelay: Duration = .milliseconds(250) private static let slowMenuOperationThreshold: TimeInterval = 0.15 + private static let slowChartRenderThreshold: TimeInterval = 0.050 #if DEBUG private static var deferredMenuInteractionRefreshDelayForTesting: Duration = .milliseconds(250) @@ -46,6 +47,17 @@ extension StatusItemController { ]) } + func logChartRenderDurationIfSlow(_ label: String, startedAt: CFTimeInterval) { + let elapsed = CACurrentMediaTime() - startedAt + guard elapsed >= Self.slowChartRenderThreshold else { return } + self.menuLogger.warning( + "slow chart render", + metadata: [ + "section": label, + "durationMs": String(format: "%.1f", elapsed * 1000), + ]) + } + func deferMenuInteractionRefreshIfNeeded() { guard !self.store.isRefreshing else { return } self.deferredMenuInteractionRefreshPending = true From ee063f9f18a697f6c51e2ff87c21bf0f0da62f86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 12:06:56 -0700 Subject: [PATCH 23/51] test: stabilize dashboard idle prune scheduling (#1395) --- .../OpenAIDashboardWebViewCache.swift | 12 ++++++++++ .../OpenAIDashboardWebViewCacheTests.swift | 23 +++++++------------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 42170965..8de1cf5b 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -113,6 +113,9 @@ final class OpenAIDashboardWebViewCache { private let idleTimeout: TimeInterval private var idlePruneWorkItem: DispatchWorkItem? private var idlePruneGeneration = 0 + #if DEBUG + private(set) var idlePruneDeadlineForTesting: Date? + #endif /// Reuse the validated analytics page only for the immediate next handoff. private let preservedPageHandoffTimeout: TimeInterval = 5 private let blankURL = URL(string: "about:blank")! @@ -492,12 +495,18 @@ final class OpenAIDashboardWebViewCache { MainActor.assumeIsolated { guard let self, self.idlePruneGeneration == generation else { return } self.idlePruneWorkItem = nil + #if DEBUG + self.idlePruneDeadlineForTesting = nil + #endif let pruneTime = Date() self.prune(now: pruneTime) self.scheduleNextIdlePrune(now: pruneTime) } } self.idlePruneWorkItem = workItem + #if DEBUG + self.idlePruneDeadlineForTesting = nextExpiry + #endif let delay = max(0, nextExpiry.timeIntervalSince(now)) + 0.01 DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } @@ -506,6 +515,9 @@ final class OpenAIDashboardWebViewCache { self.idlePruneGeneration &+= 1 self.idlePruneWorkItem?.cancel() self.idlePruneWorkItem = nil + #if DEBUG + self.idlePruneDeadlineForTesting = nil + #endif } private func prune(now: Date) { diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index 5dfe093b..23b66292 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -237,7 +237,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Later release does not postpone an older idle entry`() async throws { if self.shouldSkipOnCI() { return } - let cache = OpenAIDashboardWebViewCache(idleTimeout: 0.5) + let cache = OpenAIDashboardWebViewCache(idleTimeout: 5) let firstStore = WKWebsiteDataStore.nonPersistent() let secondStore = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -247,29 +247,22 @@ struct OpenAIDashboardWebViewCacheTests { usageURL: url, logger: nil) firstLease.release() + let firstDeadline = try #require(cache.idlePruneDeadlineForTesting) - try await Task.sleep(for: .milliseconds(250)) + try await Task.sleep(for: .milliseconds(50)) let secondLease = try await cache.acquire( websiteDataStore: secondStore, usageURL: url, logger: nil) secondLease.release() + let rescheduledDeadline = try #require(cache.idlePruneDeadlineForTesting) - let firstDeadline = Date().addingTimeInterval(1.5) - while cache.hasCachedEntry(for: firstStore), Date() < firstDeadline { - try await Task.sleep(for: .milliseconds(20)) - } - - #expect(!cache.hasCachedEntry(for: firstStore), "Expected the oldest idle entry to be pruned first") + #expect( + abs(rescheduledDeadline.timeIntervalSince(firstDeadline)) < 0.001, + "A later release should keep the prune scheduled for the oldest idle entry") + #expect(cache.hasCachedEntry(for: firstStore)) #expect(cache.hasCachedEntry(for: secondStore), "A later release should keep its own idle window") - - let secondDeadline = Date().addingTimeInterval(1) - while cache.hasCachedEntry(for: secondStore), Date() < secondDeadline { - try await Task.sleep(for: .milliseconds(20)) - } - - #expect(!cache.hasCachedEntry(for: secondStore), "Expected the next idle deadline to be scheduled") cache.clearAllForTesting() } From 91305aee3492b3c7da5b5ebaa43370057fbeaffd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 12:42:18 -0700 Subject: [PATCH 24/51] fix: show all daily cost models (#1396) --- CHANGELOG.md | 1 + .../CodexBar/CostHistoryChartMenuView.swift | 135 +++++++++++------- .../CostHistoryChartMenuViewTests.swift | 33 +++++ 3 files changed, 120 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b33d6eb..59cdbea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localization: add Japanese as a selectable app language (#1385). Thanks @naoterumaker! ### Fixed +- Cost history: keep all per-day model breakdown rows available in a bounded scrolling detail area instead of hiding models after the first four (#1370). Thanks @MoollaMore! - Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! - Security: block credentialed provider redirects that leave the original HTTPS origin while preserving same-origin redirects (#1237). Thanks @Hinotoi-agent! - Codex: keep local token and cost history visible when remote quota data is unavailable (#1390). Thanks @vaibhavarora14! diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index e1a35e28..0f54e169 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -131,47 +131,68 @@ struct CostHistoryChartMenuView: View { .lineLimit(1) .truncationMode(.tail) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) - ForEach(detail.rows) { row in - HStack(alignment: .top, spacing: 8) { - Rectangle() - .fill(row.accentColor) - .frame( - width: 2, - height: Self.accentHeight(for: row)) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 1) { - Text(row.title) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailTitleLineHeight, alignment: .leading) - if let subtitle = row.subtitle { - Text(subtitle) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailSubtitleLineHeight, alignment: .leading) + if model.maxRenderedBreakdownRows > 0 { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: Self.detailSpacing) { + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame( + width: 2, + height: Self.accentHeight(for: row)) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.detailTitleLineHeight, alignment: .leading) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + if let modeSubtitle = row.modeSubtitle { + Text(modeSubtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + } + } + .frame(height: Self.detailRowHeight(for: row), alignment: .leading) } - if let modeSubtitle = row.modeSubtitle { - Text(modeSubtitle) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailSubtitleLineHeight, alignment: .leading) + ForEach( + 0.. (count: Int, height: CGFloat) { guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return (0, 0) } let renderedRows = Array( - self.sortedBreakdown(breakdown) - .prefix(self.maxVisibleDetailLines)) + self.orderedBreakdownItems(breakdown) + .prefix(self.detailViewportRowCount(itemCount: breakdown.count))) let height = renderedRows.reduce(CGFloat(0)) { total, item in total + self.detailRowHeight(hasModeSubtitle: Self.hasModeSubtitle(item)) } @@ -353,8 +374,18 @@ struct CostHistoryChartMenuView: View { private static func detailBlockHeight(maxBreakdownRows: Int, maxRowsHeight: CGFloat) -> CGFloat { guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } return self.detailPrimaryLineHeight + - maxRowsHeight + - (CGFloat(maxBreakdownRows) * self.detailSpacing) + self.detailRowsViewportHeight( + maxBreakdownRows: maxBreakdownRows, + maxRowsHeight: maxRowsHeight) + + self.detailSpacing + } + + private static func detailRowsViewportHeight( + maxBreakdownRows: Int, + maxRowsHeight: CGFloat) -> CGFloat + { + guard maxBreakdownRows > 0 else { return 0 } + return maxRowsHeight + (CGFloat(maxBreakdownRows - 1) * self.detailSpacing) } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { @@ -400,10 +431,9 @@ struct CostHistoryChartMenuView: View { proxy: ChartProxy, geo: GeometryProxy) { - guard let location else { - if self.selectedDateKey != nil { self.selectedDateKey = nil } - return - } + // Keep the last hovered day selected when the pointer leaves the chart so the adjacent + // model-breakdown scroller remains interactive. The selection resets with the menu view. + guard let location else { return } guard let plotAnchor = proxy.plotFrame else { return } let plotFrame = geo[plotAnchor] @@ -457,8 +487,7 @@ struct CostHistoryChartMenuView: View { guard let entry = model.entriesByDateKey[key] else { return [] } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } - return Self.sortedBreakdown(breakdown) - .prefix(Self.maxVisibleDetailLines) + return Self.orderedBreakdownItems(breakdown) .enumerated() .map { index, item in DetailRow( @@ -470,7 +499,7 @@ struct CostHistoryChartMenuView: View { } } - private static func sortedBreakdown( + static func orderedBreakdownItems( _ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] { breakdown.sorted { lhs, rhs in @@ -486,6 +515,14 @@ struct CostHistoryChartMenuView: View { } } + static func detailViewportRowCount(itemCount: Int) -> Int { + min(max(itemCount, 0), self.maxVisibleDetailLines) + } + + static func detailRowsNeedScrolling(itemCount: Int) -> Bool { + itemCount > self.maxVisibleDetailLines + } + private func modelBreakdownTotalSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { UsageFormatter.modelCostDetail( item.modelName, diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index f1c98f03..b157faea 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -1,5 +1,6 @@ import Testing @testable import CodexBar +@testable import CodexBarCore struct CostHistoryChartMenuViewTests { @Test @@ -9,4 +10,36 @@ struct CostHistoryChartMenuViewTests { #expect(CostHistoryChartMenuView.windowLabel(days: 7) == "Last 7 days") #expect(CostHistoryChartMenuView.windowLabel(days: 30) == "Last 30 days") } + + @Test + @MainActor + func `model breakdown keeps every item behind a bounded scrolling viewport`() { + let breakdown = (1...6).map { index in + CostUsageDailyReport.ModelBreakdown( + modelName: "model-\(index)", + costUSD: Double(index), + totalTokens: index * 100) + } + + let ordered = CostHistoryChartMenuView.orderedBreakdownItems(breakdown) + + #expect(ordered.map(\.modelName) == [ + "model-6", + "model-5", + "model-4", + "model-3", + "model-2", + "model-1", + ]) + #expect(ordered.count == 6) + #expect(CostHistoryChartMenuView.detailViewportRowCount(itemCount: ordered.count) == 4) + #expect(CostHistoryChartMenuView.detailRowsNeedScrolling(itemCount: ordered.count)) + } + + @Test + @MainActor + func `short model breakdown does not scroll or reserve extra rows`() { + #expect(CostHistoryChartMenuView.detailViewportRowCount(itemCount: 3) == 3) + #expect(CostHistoryChartMenuView.detailRowsNeedScrolling(itemCount: 3) == false) + } } From f927e8ad90ae7fd07c0f5708c062915309ecc0f0 Mon Sep 17 00:00:00 2001 From: bcssewl Date: Wed, 10 Jun 2026 22:05:23 +0200 Subject: [PATCH 25/51] Recycle menu card hosting views and reconcile menu content in place (#1394) * perf: recycle menu content in place Co-authored-by: bcssewl * docs: note menu content recycling --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../CodexBar/StatusItemController+Menu.swift | 132 ++--- .../StatusItemController+MenuCardItems.swift | 37 +- ...atusItemController+MenuCardRecycling.swift | 69 +++ ...tatusItemController+MenuPresentation.swift | 28 +- .../StatusItemController+MenuReconcile.swift | 132 +++++ ...StatusItemController+MenuSmartUpdate.swift | 137 ++++++ ...ontroller+MergedSwitcherContentCache.swift | 26 + Sources/CodexBar/StatusItemController.swift | 4 + .../MenuCardViewRecyclingTests.swift | 449 ++++++++++++++++++ .../StatusMenuSwitcherRefreshTests.swift | 26 +- 11 files changed, 929 insertions(+), 112 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+MenuCardRecycling.swift create mode 100644 Sources/CodexBar/StatusItemController+MenuReconcile.swift create mode 100644 Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift create mode 100644 Tests/CodexBarTests/MenuCardViewRecyclingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cdbea8..80e3f436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). +- Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! - Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 4a5158a1..4740f033 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -387,88 +387,15 @@ extension StatusItemController { return reusableRows } - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. - private struct MenuUpdateContext { - let provider: UsageProvider? - let currentProvider: UsageProvider - let switcherSelection: ProviderSwitcherSelection - let menuWidth: CGFloat - let codexAccountDisplay: CodexAccountMenuDisplay? - let tokenAccountDisplay: TokenAccountMenuDisplay? - let openAIContext: OpenAIWebContext - let descriptor: MenuDescriptor - } - - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. - private func updateMenuContentPreservingSwitcher( - _ menu: NSMenu, - context: MenuUpdateContext) - { - self.performMenuMutationWithoutAnimation { - let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) - if let switcherView = menu.items.first?.view as? ProviderSwitcherView { - switcherView.updateSelection(context.switcherSelection) - switcherView.updateQuotaIndicators() - } - if let outgoingSelection = self.lastMergedMenuContentSelection, - outgoingSelection != context.switcherSelection - { - self.cacheVisibleMergedSwitcherContent( - in: menu, - selection: outgoingSelection, - contentStartIndex: contentStartIndex, - menuWidth: context.menuWidth) - } - while menu.items.count > contentStartIndex { - menu.removeItem(at: contentStartIndex) - } - - let enabledProviders = self.store.enabledProvidersForDisplay() - self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) - if self.addCachedMergedSwitcherContent( - for: context.switcherSelection, - to: menu, - menuWidth: context.menuWidth, - codexAccountDisplay: context.codexAccountDisplay, - tokenAccountDisplay: context.tokenAccountDisplay) - { - return - } - self.addCodexAccountSwitcherIfNeeded( - to: menu, - display: context.codexAccountDisplay, - width: context.menuWidth) - self.lastCodexAccountMenuDisplay = context.codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded( - to: menu, - display: context.tokenAccountDisplay, - width: context.menuWidth) - self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay - - let menuContext = MenuCardContext( - currentProvider: context.currentProvider, - selectedProvider: context.provider, - menuWidth: context.menuWidth, - codexAccountDisplay: context.codexAccountDisplay, - tokenAccountDisplay: context.tokenAccountDisplay, - openAIContext: context.openAIContext) - self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) - self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) - self.cacheVisibleMergedSwitcherContent( - in: menu, - selection: context.switcherSelection, - contentStartIndex: contentStartIndex, - menuWidth: context.menuWidth, - contentVersion: self.menuContentVersion) - } - } - private func rebuildMenuContent( _ menu: NSMenu, context: MenuRebuildContext) { self.performMenuMutationWithoutAnimation { + let displacedSelection = self.lastMergedMenuContentSelection self.lastMergedMenuContentSelection = nil + self.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: displacedSelection) + defer { self.clearMenuCardViewRecyclePool() } menu.removeAllItems() let contentSelection = context.switcherSelection ?? .provider(context.currentProvider) self.addProviderSwitcherIfNeeded( @@ -567,16 +494,32 @@ extension StatusItemController { menu.addItem(.separator()) } - private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?, width: CGFloat) { + func addTokenAccountSwitcherIfNeeded( + to menu: NSMenu, + display: TokenAccountMenuDisplay?, + width: CGFloat, + captureMenu: NSMenu? = nil) + { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu, width: width) + let switcherItem = self.makeTokenAccountSwitcherItem( + display: display, + menu: captureMenu ?? menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } - private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) { + func addCodexAccountSwitcherIfNeeded( + to menu: NSMenu, + display: CodexAccountMenuDisplay?, + width: CGFloat, + captureMenu: NSMenu? = nil) + { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width) + let switcherItem = self.makeCodexAccountSwitcherItem( + display: display, + menu: captureMenu ?? menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } @@ -585,8 +528,12 @@ extension StatusItemController { private func addOverviewRows( to menu: NSMenu, enabledProviders: [UsageProvider], - menuWidth: CGFloat) -> Bool + menuWidth: CGFloat, + captureMenu: NSMenu? = nil) -> Bool { + // Rows may be built into a detached scratch menu for in-place reconciliation; + // interaction closures must always reference the live menu they end up serving. + let interactionMenu = captureMenu ?? menu let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( activeProviders: enabledProviders) let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders @@ -616,9 +563,9 @@ extension StatusItemController { section: "overview", additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), submenu: submenu, - onClick: { [weak self, weak menu] in - guard let self, let menu else { return } - self.selectOverviewProvider(row.provider, menu: menu) + onClick: { [weak self, weak interactionMenu] in + guard let self, let interactionMenu else { return } + self.selectOverviewProvider(row.provider, menu: interactionMenu) }) if submenu == nil { // Keep plain rows wired for keyboard activation and accessibility action paths. @@ -768,17 +715,19 @@ extension StatusItemController { menu.addItem(.separator()) } - private func addPrimaryMenuContent( + func addPrimaryMenuContent( to menu: NSMenu, context: MenuCardContext, - switcherSelection: ProviderSwitcherSelection) + switcherSelection: ProviderSwitcherSelection, + captureMenu: NSMenu? = nil) { if switcherSelection == .overview { let enabledProviders = self.store.enabledProvidersForDisplay() if self.addOverviewRows( to: menu, enabledProviders: enabledProviders, - menuWidth: context.menuWidth) + menuWidth: context.menuWidth, + captureMenu: captureMenu) { menu.addItem(.separator()) } else { @@ -809,7 +758,12 @@ extension StatusItemController { } } - func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { + func addActionableSections( + _ sections: [MenuDescriptor.Section], + to menu: NSMenu, + width: CGFloat, + captureMenu: NSMenu? = nil) + { let actionableSections = sections.filter { section in section.entries.contains { entry in if case .action = entry { return true } @@ -843,7 +797,7 @@ extension StatusItemController { menu.addItem(self.makePersistentMenuActionItem( title: localizedTitle, action: action, - menu: menu, + menu: captureMenu ?? menu, width: width)) continue } diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index 768b0fcf..ebb86759 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -20,8 +20,8 @@ extension StatusItemController { } } - func makeMenuCardItem( - _ view: some View, + func makeMenuCardItem( + _ view: CardContent, id: String, width: CGFloat, heightCacheScope: String? = nil, @@ -43,16 +43,33 @@ extension StatusItemController { return item } - let highlightState = MenuCardHighlightState() - let wrapped = MenuCardSectionContainerView( - highlightState: highlightState, - showsSubmenuIndicator: submenu != nil, - submenuIndicatorAlignment: submenuIndicatorAlignment, - submenuIndicatorTopPadding: submenuIndicatorTopPadding) + let hosting: MenuCardItemHostingView> + if let recycled = self.takeRecyclableMenuCardView( + for: id, + as: MenuCardItemHostingView>.self) { - view + let wrapped = MenuCardSectionContainerView( + highlightState: recycled.highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + recycled.prepareForReuse(rootView: wrapped, onClick: onClick) + hosting = recycled + } else { + let highlightState = MenuCardHighlightState() + let wrapped = MenuCardSectionContainerView( + highlightState: highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) } - let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) let height = self.cachedMenuCardHeight( for: id, scope: heightCacheScope ?? id, diff --git a/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift b/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift new file mode 100644 index 00000000..718e2df1 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift @@ -0,0 +1,69 @@ +import AppKit + +extension StatusItemController { + /// Collects the card hosting views of items the current populate pass is about to discard + /// so `makeMenuCardItem` can reuse them for cards with the same identifier (or, failing + /// that, the same content type) instead of building fresh hosting views. + /// + /// Safety: live menu items can alias one merged-switcher cache entry — the one for the + /// selection currently displayed, re-cached at the end of every populate. Consuming that + /// entry up front (`displacedSelection`) guarantees no cache entry can still reference a + /// harvested view; entries for other selections only hold items already detached from the + /// menu. Harvested views are detached from their outgoing items; whatever the pass does + /// not consume is released by `clearMenuCardViewRecyclePool`. + func harvestRecyclableMenuCardViews( + in menu: NSMenu, + fromIndex: Int, + displacedSelection: ProviderSwitcherSelection?, + preserveHighlightedItem: Bool = false) + { + self.menuCardViewRecyclePool.removeAll(keepingCapacity: true) + let menuKey = ObjectIdentifier(menu) + if let displacedSelection { + self.mergedSwitcherContentCaches[menuKey]?.removeValue(forKey: displacedSelection) + } + guard Self.menuCardRenderingEnabled else { return } + guard fromIndex >= 0, fromIndex < menu.items.count else { return } + for item in menu.items[fromIndex...] { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + guard self.menuCardViewRecyclePool[id] == nil else { continue } + // Unhighlight before detaching: the highlight tracker unwinds through the + // outgoing item's `view`, which is about to become nil, so a recycled view + // would otherwise re-attach visibly highlighted with no path to clear it. + if self.highlightedMenuItems[menuKey] === item { + if !preserveHighlightedItem { + self.highlightedMenuItems.removeValue(forKey: menuKey) + } + } + (view as? MenuCardHighlighting)?.setHighlighted(false) + item.view = nil + self.menuCardViewRecyclePool[id] = view + } + } + + /// Pops a pool entry adoptable as `ViewType`: the same card identifier when its view + /// matches, otherwise the first type-compatible leftover. The fallback is what makes + /// provider switches cheap — a different provider's card with a different identifier but + /// the same SwiftUI content type (for example two providers' usage cards) is repainted + /// in place instead of being rebuilt. + func takeRecyclableMenuCardView(for id: String, as type: ViewType.Type) -> ViewType? { + if let candidate = self.menuCardViewRecyclePool.removeValue(forKey: id) { + if let adopted = candidate as? ViewType { + return adopted + } + // A same-id view of an incompatible shape can never be adopted later in this + // pass; dropping it restores the build-fresh behavior. + return nil + } + guard let match = self.menuCardViewRecyclePool.first(where: { $0.value is ViewType }) else { + return nil + } + self.menuCardViewRecyclePool.removeValue(forKey: match.key) + return match.value as? ViewType + } + + func clearMenuCardViewRecyclePool() { + self.menuCardViewRecyclePool.removeAll(keepingCapacity: true) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 527e3543..0b3de367 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -82,8 +82,9 @@ final class MenuHostingView: NSHostingView { @MainActor final class MenuCardItemHostingView: NSHostingView, MenuCardHighlighting, MenuCardMeasuring { - private let highlightState: MenuCardHighlightState - private let onClick: (() -> Void)? + let highlightState: MenuCardHighlightState + private var onClick: (() -> Void)? + private var hasClickRecognizer = false override var allowsVibrancy: Bool { true @@ -100,12 +101,29 @@ final class MenuCardItemHostingView: NSHostingView, Menu self.onClick = onClick super.init(rootView: rootView) if onClick != nil { - let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) - recognizer.buttonMask = 0x1 - self.addGestureRecognizer(recognizer) + self.installClickRecognizer() } } + /// Reuses this hosting view for a rebuilt card with the same identity: the replaced + /// `rootView` is diffed in place by SwiftUI instead of tearing down and recreating the + /// hosting view and its graph. Callers must construct `rootView` around this view's own + /// `highlightState` so menu hover highlighting keeps driving the rendered content. + func prepareForReuse(rootView: Content, onClick: (() -> Void)?) { + self.rootView = rootView + self.onClick = onClick + if onClick != nil, !self.hasClickRecognizer { + self.installClickRecognizer() + } + } + + private func installClickRecognizer() { + let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) + recognizer.buttonMask = 0x1 + self.addGestureRecognizer(recognizer) + self.hasClickRecognizer = true + } + required init(rootView: Content) { self.highlightState = MenuCardHighlightState() self.onClick = nil diff --git a/Sources/CodexBar/StatusItemController+MenuReconcile.swift b/Sources/CodexBar/StatusItemController+MenuReconcile.swift new file mode 100644 index 00000000..c8f0e1f1 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuReconcile.swift @@ -0,0 +1,132 @@ +import AppKit + +/// Pre-harvest snapshot of one live content row, captured before card views are detached +/// into the recycle pool so reconciliation can still compare row shapes afterwards. +struct MenuRowShape { + let isSeparator: Bool + let id: String? + let viewClassName: String? +} + +extension StatusItemController { + func menuContentShapes(in menu: NSMenu, fromIndex: Int) -> [MenuRowShape] { + guard fromIndex >= 0, fromIndex <= menu.items.count else { return [] } + return menu.items[fromIndex...].map { item in + MenuRowShape( + isSeparator: item.isSeparatorItem, + id: item.representedObject as? String, + viewClassName: item.view.map { String(describing: type(of: $0)) }) + } + } + + /// Position-wise in-place reconciliation: live rows whose shape matches the freshly + /// built content (separator placement, card identifier, view class) are updated in + /// place — views transplanted, plain rows recopied — and only the mismatched middle + /// span is removed and reinserted. Matching runs from both ends, so the expensive card + /// rows at the top and the shared action rows at the bottom survive even a provider + /// switch whose middle sections differ; AppKit then relayouts the open tracked menu for + /// the few changed rows instead of once per row. + func reconcileMenuContent( + _ menu: NSMenu, + fromIndex: Int, + shapes: [MenuRowShape], + with scratch: NSMenu) + { + defer { self.finishReconciledHighlightTracking(in: menu) } + let newItems = scratch.items + scratch.removeAllItems() + guard menu.items.count - fromIndex == shapes.count else { + // The live region changed underneath the snapshot; replace it wholesale. + self.replaceMenuContent(menu, fromIndex: fromIndex, with: newItems) + return + } + + func updatable(_ shape: MenuRowShape, _ newItem: NSMenuItem) -> Bool { + guard shape.isSeparator == newItem.isSeparatorItem else { return false } + if shape.isSeparator { return true } + guard shape.id == newItem.representedObject as? String else { return false } + return shape.viewClassName == newItem.view.map { String(describing: type(of: $0)) } + } + + var prefix = 0 + while prefix < min(shapes.count, newItems.count), updatable(shapes[prefix], newItems[prefix]) { + prefix += 1 + } + var suffix = 0 + while suffix < min(shapes.count, newItems.count) - prefix, + updatable(shapes[shapes.count - 1 - suffix], newItems[newItems.count - 1 - suffix]) + { + suffix += 1 + } + + for offset in 0.. fromIndex { + menu.removeItem(at: fromIndex) + } + for item in newItems { + menu.addItem(item) + } + } + + private func updateMenuItemInPlace(_ liveItem: NSMenuItem, from newItem: NSMenuItem) { + if liveItem.isSeparatorItem { return } + let remainsHighlighted = liveItem.menu.map { + self.highlightedMenuItems[ObjectIdentifier($0)] === liveItem + } ?? false + // Detach from the scratch item first so a view or submenu is never referenced by + // two menu items at once. + let view = newItem.view + newItem.view = nil + let submenu = newItem.submenu + newItem.submenu = nil + liveItem.view = view + (view as? MenuCardHighlighting)?.setHighlighted(remainsHighlighted) + liveItem.submenu = submenu + liveItem.title = newItem.title + liveItem.attributedTitle = newItem.attributedTitle + liveItem.action = newItem.action + liveItem.target = newItem.target + liveItem.representedObject = newItem.representedObject + liveItem.state = newItem.state + liveItem.isEnabled = newItem.isEnabled + liveItem.image = newItem.image + liveItem.toolTip = newItem.toolTip + liveItem.keyEquivalent = newItem.keyEquivalent + liveItem.keyEquivalentModifierMask = newItem.keyEquivalentModifierMask + liveItem.indentationLevel = newItem.indentationLevel + if #available(macOS 14.4, *) { + liveItem.subtitle = newItem.subtitle + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift new file mode 100644 index 00000000..c55794f4 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -0,0 +1,137 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + struct MenuUpdateContext { + let provider: UsageProvider? + let currentProvider: UsageProvider + let switcherSelection: ProviderSwitcherSelection + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor + } + + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + func updateMenuContentPreservingSwitcher( + _ menu: NSMenu, + context: MenuUpdateContext) + { + self.performMenuMutationWithoutAnimation { + let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) + if let switcherView = menu.items.first?.view as? ProviderSwitcherView { + switcherView.updateSelection(context.switcherSelection) + switcherView.updateQuotaIndicators() + } + let outgoingSelection = self.lastMergedMenuContentSelection + let isSelectionSwitch = outgoingSelection != nil && outgoingSelection != context.switcherSelection + let enabledProviders = self.store.enabledProvidersForDisplay() + + if isSelectionSwitch, + let outgoingSelection, + self.hasReusableMergedSwitcherContent( + for: context.switcherSelection, + in: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + // Instant path: the incoming tab reattaches wholesale, so park the outgoing + // items for an equally instant switch-back. + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: outgoingSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth) + while menu.items.count > contentStartIndex { + menu.removeItem(at: contentStartIndex) + } + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + if self.addCachedMergedSwitcherContent( + for: context.switcherSelection, + to: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + return + } + self.addSwitcherScopedMenuContent(into: menu, captureMenu: menu, context: context) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) + return + } + + // Rebuild path (data tick, or switch whose incoming tab must be built): recycle + // the outgoing hosting views and reconcile in place when the row skeleton is + // unchanged, so an open tracked menu sees content mutations instead of item + // churn. The fresh content is built into a detached scratch menu while its + // interaction closures capture the live menu they will serve. + let shapes = self.menuContentShapes(in: menu, fromIndex: contentStartIndex) + self.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: contentStartIndex, + displacedSelection: outgoingSelection, + preserveHighlightedItem: true) + defer { self.clearMenuCardViewRecyclePool() } + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let scratch = NSMenu() + scratch.autoenablesItems = false + self.addSwitcherScopedMenuContent(into: scratch, captureMenu: menu, context: context) + self.reconcileMenuContent(menu, fromIndex: contentStartIndex, shapes: shapes, with: scratch) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) + } + } + + /// Adds everything below the provider switcher (account switchers, card content, and + /// actionable sections) to `target`, which may be a detached scratch menu; interaction + /// closures always capture `captureMenu`, the live menu the rows will serve. + private func addSwitcherScopedMenuContent( + into target: NSMenu, + captureMenu: NSMenu, + context: MenuUpdateContext) + { + self.addCodexAccountSwitcherIfNeeded( + to: target, + display: context.codexAccountDisplay, + width: context.menuWidth, + captureMenu: captureMenu) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.addTokenAccountSwitcherIfNeeded( + to: target, + display: context.tokenAccountDisplay, + width: context.menuWidth, + captureMenu: captureMenu) + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + + let menuContext = MenuCardContext( + currentProvider: context.currentProvider, + selectedProvider: context.provider, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + openAIContext: context.openAIContext) + self.addPrimaryMenuContent( + to: target, + context: menuContext, + switcherSelection: context.switcherSelection, + captureMenu: captureMenu) + self.addActionableSections( + context.descriptor.sections, + to: target, + width: context.menuWidth, + captureMenu: captureMenu) + } +} diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift index b31c447c..5f323f10 100644 --- a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -65,6 +65,32 @@ extension StatusItemController { self.mergedSwitcherContentCaches[ObjectIdentifier(menu), default: [:]][selection] = entry } + /// Non-consuming variant of `addCachedMergedSwitcherContent`'s lookup: reports whether a + /// reusable entry exists (evicting it when stale) without attaching anything, so callers + /// can choose between reattaching cached content and recycling the outgoing views. + func hasReusableMergedSwitcherContent( + for selection: ProviderSwitcherSelection, + in menu: NSMenu, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?) + -> Bool + { + let key = ObjectIdentifier(menu) + guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return false } + guard entry.matches( + requiredMenuContentVersion: self.latestRequiredMenuRebuildVersion, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + localizationSignature: self.menuLocalizationSignature()) + else { + self.mergedSwitcherContentCaches[key]?.removeValue(forKey: selection) + return false + } + return true + } + func addCachedMergedSwitcherContent( for selection: ProviderSwitcherSelection, to menu: NSMenu, diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 7d28152d..23208fcf 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -235,6 +235,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var mergedSwitcherContentCaches: [ObjectIdentifier: [ProviderSwitcherSelection: CachedMergedSwitcherMenuContent]] = [:] var preservesMergedSwitcherContentCachesDuringInvalidation = false + /// Card hosting views harvested from items about to be discarded by the current populate + /// pass, keyed by card identifier; consumed by `makeMenuCardItem` and cleared when the + /// pass finishes. Never outlives a single synchronous menu population. + var menuCardViewRecyclePool: [String: NSView] = [:] /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 var providerSelectionUIRefreshTask: Task? diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift new file mode 100644 index 00000000..7c623bb6 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -0,0 +1,449 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +private final class RecordingMenuHighlightView: NSView, MenuCardHighlighting { + private(set) var isHighlighted = false + + func setHighlighted(_ highlighted: Bool) { + self.isHighlighted = highlighted + } +} + +extension StatusMenuTests { + private func makeRecyclingController(settings: SettingsStore) -> StatusItemController { + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } + + private func cardViewIdentities(in menu: NSMenu) -> [String: ObjectIdentifier] { + var identities: [String: ObjectIdentifier] = [:] + for item in menu.items { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + identities[id] = ObjectIdentifier(view) + } + return identities + } + + @Test + func `data only repopulate reuses menu card hosting views`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstPass = self.cardViewIdentities(in: menu) + #expect(!firstPass.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + let secondPass = self.cardViewIdentities(in: menu) + + #expect(secondPass.keys.sorted() == firstPass.keys.sorted()) + for (id, identity) in firstPass { + #expect(secondPass[id] == identity, "card \(id) should reuse its hosting view") + } + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `merged data tick reconciles items in place without churn`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = false + let registry = ProviderRegistry.shared + let enabled: Set = [.codex, .claude] + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabled.contains(provider)) + } + } + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.selectedMenuProvider = .codex + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let itemsBefore = menu.items.map(ObjectIdentifier.init) + let cardViewsBefore = self.cardViewIdentities(in: menu) + #expect(!cardViewsBefore.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + + let itemsAfter = menu.items.map(ObjectIdentifier.init) + #expect(itemsAfter == itemsBefore, "data-only repopulate should not remove or insert menu items") + let cardViewsAfter = self.cardViewIdentities(in: menu) + for (id, identity) in cardViewsBefore { + #expect(cardViewsAfter[id] == identity, "card \(id) should reuse its hosting view") + } + } + + @Test + func `reconcile keeps matching edge rows when the middle differs`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + func plainItem(_ title: String) -> NSMenuItem { + NSMenuItem(title: title, action: nil, keyEquivalent: "") + } + + let menu = NSMenu() + menu.addItem(controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300)) + menu.addItem(.separator()) + menu.addItem(plainItem("Old Provider Action")) + menu.addItem(plainItem("Old Provider Detail")) + menu.addItem(.separator()) + menu.addItem(plainItem("Settings")) + let cardItem = menu.items[0] + let cardView = cardItem.view + let settingsItem = menu.items[5] + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("other provider card"), id: "menuCard", width: 300)) + scratch.addItem(.separator()) + scratch.addItem(plainItem("New Provider Action")) + scratch.addItem(.separator()) + scratch.addItem(plainItem("Settings")) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 5) + #expect(menu.items[0] === cardItem, "card row should be updated in place") + #expect(menu.items[0].view === cardView, "card hosting view should be recycled in place") + #expect(menu.items[4] === settingsItem, "shared trailing row should be updated in place") + #expect(menu.items[2].title == "New Provider Action") + } + + @Test + func `reconcile preserves highlight on a retained custom action row`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = NSMenuItem() + liveItem.isEnabled = true + liveItem.representedObject = "action" + liveItem.view = RecordingMenuHighlightView() + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + + let replacementView = RecordingMenuHighlightView() + let replacementItem = NSMenuItem() + replacementItem.isEnabled = true + replacementItem.representedObject = "action" + replacementItem.view = replacementView + let scratch = NSMenu() + scratch.addItem(replacementItem) + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === replacementView) + #expect(replacementView.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `reconcile restores highlight on a retained recycled card`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300) + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + guard let hosting = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: nil, + preserveHighlightedItem: true) + defer { controller.clearMenuCardViewRecyclePool() } + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300)) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === hosting) + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `harvesting consumes only the displaced selection cache entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300) + menu.addItem(item) + + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(controller.menuCardViewRecyclePool.count == 1) + #expect(item.view == nil) + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `harvesting consumes displaced cache when card rendering is disabled`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `type compatible leftover is adopted across card identifiers`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("codex usage"), id: "menuCard-0", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let switched = controller.makeMenuCardItem(Text("claude usage"), id: "menuCard", width: 300) + + #expect(switched.view === originalView) + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `recycled card keeps its hosting view and highlight state`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300) + menu.addItem(original) + guard let originalView = original.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300) + + #expect(rebuilt.view === originalView) + guard let rebuiltView = rebuilt.view as? MenuCardItemHostingView> + else { + Issue.record("expected the recycled hosting view") + return + } + #expect(rebuiltView.highlightState === originalView.highlightState) + rebuiltView.setHighlighted(true) + #expect(rebuiltView.highlightState.isHighlighted) + rebuiltView.setHighlighted(false) + } + + @Test + func `harvesting a highlighted card clears its highlight and tracking entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300) + menu.addItem(item) + controller.menu(menu, willHighlight: item) + guard let hosting = item.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === item) + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + + let rebuilt = controller.makeMenuCardItem(Text("rebuilt"), id: "menuCard", width: 300) + #expect(rebuilt.view === hosting) + #expect(!hosting.highlightState.isHighlighted) + } + + @Test + func `same id with different content type builds a fresh view`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("text card"), id: "menuCard", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Image(systemName: "clock"), id: "menuCard", width: 300) + + #expect(rebuilt.view != nil) + #expect(rebuilt.view !== originalView) + // The incompatible pool entry is consumed rather than left behind. + #expect(controller.menuCardViewRecyclePool.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index a49fc7c1..c10999cb 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -156,7 +156,7 @@ struct StatusMenuSwitcherRefreshTests { } @Test - func `merged provider switch restores cached tab content`() async throws { + func `merged provider switch updates live tab rows in place`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(true) @@ -198,12 +198,14 @@ struct StatusMenuSwitcherRefreshTests { } defer { controller._test_openMenuRebuildObserver = nil } + // Provider switches now reconcile matching rows in place instead of parking and + // restoring distinct item sets per tab: the same NSMenuItem objects carry each + // tab's freshly built content, so AppKit never relayouts the open menu per insert. let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) #expect(menu.items.indices.contains(contentStartIndex)) - let alternateContentID = ObjectIdentifier(menu.items[contentStartIndex]) - #expect(alternateContentID != originalContentID) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) @@ -215,7 +217,7 @@ struct StatusMenuSwitcherRefreshTests { #expect(restoredSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(3, rebuildCount: { rebuildCount }) #expect(menu.items.indices.contains(contentStartIndex)) - #expect(ObjectIdentifier(menu.items[contentStartIndex]) == alternateContentID) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) controller.invalidateMenus() #expect(controller.mergedSwitcherContentCaches.isEmpty) @@ -253,9 +255,7 @@ struct StatusMenuSwitcherRefreshTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) - let originalContent = try #require( - menu.items.indices.contains(contentStartIndex) ? menu.items[contentStartIndex] : nil) - let originalContentID = ObjectIdentifier(originalContent) + #expect(menu.items.indices.contains(contentStartIndex)) let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) @@ -266,6 +266,7 @@ struct StatusMenuSwitcherRefreshTests { defer { controller._test_openMenuRebuildObserver = nil } controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) @@ -274,8 +275,17 @@ struct StatusMenuSwitcherRefreshTests { #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + // Rows are reconciled in place, so freshness is guaranteed by rebuilding content + // from current data rather than by minting new items: the live menu must be marked + // fresh and no cached entry may predate the required invalidation. (In-place item + // identity itself is covered deterministically in MenuCardViewRecyclingTests; here + // async gate state may legitimately route a populate through the full rebuild.) #expect(menu.items.indices.contains(contentStartIndex)) - #expect(ObjectIdentifier(menu.items[contentStartIndex]) != originalContentID) + let menuKey = ObjectIdentifier(menu) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + for entry in controller.mergedSwitcherContentCaches[menuKey]?.values ?? [:].values { + #expect(entry.requiredMenuContentVersion >= controller.latestRequiredMenuRebuildVersion) + } } @Test From 3c2d53de57c118149b8511abb333aebb01cdf327 Mon Sep 17 00:00:00 2001 From: Joshua Vial Date: Thu, 11 Jun 2026 06:49:04 +0800 Subject: [PATCH 26/51] Fix menu open refresh delay (#1398) * Fix menu open refresh delay * docs: note instant cached menu opening --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Sources/CodexBar/MenuCardView.swift | 2 +- .../Kilo/UsageStore+KiloOrgRefresh.swift | 3 +- .../StatusItemController+Actions.swift | 3 +- .../CodexBar/StatusItemController+Menu.swift | 55 +- .../StatusItemController+MenuCardModel.swift | 2 +- ...temController+MenuInteractionRefresh.swift | 14 +- ...ItemController+MenuRefreshScheduling.swift | 5 + .../StatusItemController+MenuTracking.swift | 125 +- Sources/CodexBar/StatusItemController.swift | 13 +- .../CodexBar/UsageStore+PlanUtilization.swift | 5 + Sources/CodexBar/UsageStore+Refresh.swift | 213 ++- .../CodexBar/UsageStore+TokenAccounts.swift | 53 +- Sources/CodexBar/UsageStore.swift | 18 +- Sources/CodexBar/UsageStoreSupport.swift | 72 + .../CodexBarTests/MenuCardSubtitleTests.swift | 45 + .../StatusMenuClosedPreparationTests.swift | 8 + .../StatusMenuInstantOpenTests.swift | 1540 +++++++++++++++++ .../StatusMenuOpenRefreshTests.swift | 125 +- Tests/CodexBarTests/StatusMenuTests.swift | 13 +- .../StatusMenuTokenAccountSwitcherTests.swift | 7 +- 21 files changed, 2145 insertions(+), 177 deletions(-) create mode 100644 Tests/CodexBarTests/StatusMenuInstantOpenTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e3f436..c7636ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). +- Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! - Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! - Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 89c3d220..1c3532da 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1047,7 +1047,7 @@ extension UsageMenuCardView.Model { return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error) } - if isRefreshing, snapshot == nil { + if isRefreshing { return ("\(L("Refreshing"))…", .loading) } diff --git a/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift b/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift index ed1f7129..59e76dc7 100644 --- a/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift +++ b/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift @@ -35,7 +35,7 @@ extension UsageStore { self.kiloEnabledScopes.count > 1 } - func refreshKiloScopes() async { + func refreshKiloScopes(generation: UInt64? = nil) async { let scopes = self.kiloEnabledScopes guard scopes.count > 1 else { await MainActor.run { self.kiloScopeSnapshots = [] } @@ -102,6 +102,7 @@ extension UsageStore { let ordered = scopes.compactMap { resultByID[$0.scopeIdentifier] } await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(.kilo, generation: generation) else { return } self.kiloScopeSnapshots = ordered } } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 9ce0d39d..d0607c57 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -44,8 +44,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { func refreshOpenMenusAfterExplicitStoreAction() { self.invalidateMenus( refreshOpenMenus: true, - deferOpenParentMenuRebuild: true, - allowStaleContentDuringDataRefresh: true) + deferOpenParentMenuRebuild: true) } @objc func refreshNow() { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 4740f033..9dfebcf3 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1135,8 +1135,11 @@ extension StatusItemController { // provider fetch failed and needs a retry; periodic freshness is handled by the refresh timer. // AppKit menu tracking is modal, so starting provider refreshes while it is active can make the menu // feel frozen and can block keyboard focus from returning. - if self.menuNeedsDelayedRefreshRetry(for: menu) { - self.deferMenuInteractionRefreshIfNeeded() + let providersNeedingRetry = self.delayedRefreshRetryProviders(for: menu).filter { + self.store.isStale(provider: $0) || self.store.snapshot(for: $0) == nil + } + if !providersNeedingRetry.isEmpty { + self.deferMenuInteractionRefreshIfNeeded(providers: providersNeedingRetry) } let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() @@ -1149,13 +1152,43 @@ extension StatusItemController { self.onDelayedMenuRefreshAttemptForTesting?() #endif guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - guard !self.store.isRefreshing else { return } - let retryProviders = self.delayedRefreshRetryProviders(for: menu) - let retryStaleProviderCount = retryProviders.count { self.store.isStale(provider: $0) } - let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } - let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 - guard willRetryRefresh else { return } - self.deferMenuInteractionRefreshIfNeeded() + let availableProviders = Set(self.store.enabledProvidersForBackgroundWork()) + let retryProviders = self.delayedRefreshRetryProviders(for: menu).filter { + availableProviders.contains($0) && + (self.store.refreshingProviders.contains($0) || + self.store.isStale(provider: $0) || + self.store.snapshot(for: $0) == nil) + } + guard !retryProviders.isEmpty else { + self.clearSatisfiedDeferredMenuInteractionRefreshes( + for: self.delayedRefreshRetryProviders(for: menu)) + if self.menuNeedsRefresh(menu) { + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: self.menuProvider(for: menu), + resyncReadinessBaselineAfterRebuild: self.openMenus.count == 1) + } + return + } + self.deferMenuInteractionRefreshIfNeeded(providers: retryProviders) + await ProviderInteractionContext.$current.withValue(.background) { + for provider in retryProviders { + guard !Task.isCancelled else { return } + await self.store.refreshProvider(provider, coalesceIfRefreshing: true) + } + } + let stillNeedsRetry = retryProviders.contains { + self.store.isStale(provider: $0) || self.store.snapshot(for: $0) == nil + } + if !stillNeedsRetry { + self.clearSatisfiedDeferredMenuInteractionRefreshes(for: retryProviders) + } + guard !Task.isCancelled else { return } + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: false, + allowStaleContentDuringDataRefresh: true) } } @@ -1168,6 +1201,10 @@ extension StatusItemController { } private func delayedRefreshRetryProviders(for menu: NSMenu) -> [UsageProvider] { + self.renderedProviders(for: menu) + } + + func renderedProviders(for menu: NSMenu) -> [UsageProvider] { let enabledProviders = self.store.enabledProvidersForDisplay() guard !enabledProviders.isEmpty else { return [] } let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 7c17a655..eebc4b83 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -101,7 +101,7 @@ extension StatusItemController { tokenSnapshot: tokenSnapshot, tokenError: tokenError, account: fallbackAccount, - isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), + isRefreshing: self.store.shouldShowRefreshingMenuCardIndicator(for: target), lastError: errorOverride ?? codexProjection?.userFacingErrors.usage ?? self.store.userFacingError(for: target), diff --git a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift index 6c27e636..5cfcf765 100644 --- a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift +++ b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift @@ -58,9 +58,17 @@ extension StatusItemController { ]) } - func deferMenuInteractionRefreshIfNeeded() { + func deferMenuInteractionRefreshIfNeeded(providers: [UsageProvider]) { guard !self.store.isRefreshing else { return } - self.deferredMenuInteractionRefreshPending = true + self.deferredMenuInteractionRefreshProviders.formUnion(providers) + } + + func clearSatisfiedDeferredMenuInteractionRefreshes(for providers: [UsageProvider]) { + for provider in providers + where !self.store.isStale(provider: provider) && self.store.snapshot(for: provider) != nil + { + self.deferredMenuInteractionRefreshProviders.remove(provider) + } } func deferOpenAIDashboardRefreshUntilMenuCloses(reason: String) { @@ -110,7 +118,7 @@ extension StatusItemController { return } self.deferredMenuInteractionRefreshTask = nil - self.deferredMenuInteractionRefreshPending = false + self.deferredMenuInteractionRefreshProviders.removeAll() self.deferredOpenAIDashboardRefreshReason = nil #if DEBUG self.onDeferredMenuInteractionRefreshForTesting?() diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 4980a019..a6b21ed3 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -120,6 +120,7 @@ extension StatusItemController { [ provider.rawValue, "token=\(tokenSignature)", + "refreshing=\(self.store.shouldShowRefreshingMenuCardIndicator(for: provider) ? "1" : "0")", "usageHistory=\(usageHistoryVisible ? "1" : "0")", ].joined(separator: ":")) } @@ -214,6 +215,7 @@ extension StatusItemController { _ menu: NSMenu, provider: UsageProvider?, closeHostedSubviewMenusBeforeRebuild: Bool = false, + resyncReadinessBaselineAfterRebuild: Bool = false, debounceNanoseconds: UInt64 = 0, beforeRebuild: (@MainActor () -> Bool)? = nil) { @@ -255,6 +257,9 @@ extension StatusItemController { self.closeHostedSubviewMenusForParentSwitch() } self.rebuildOpenMenuIfStillVisible(menu, provider: provider) + if resyncReadinessBaselineAfterRebuild, !self.menuNeedsRefresh(menu) { + self.resyncMenuAdjunctReadinessBaseline() + } } } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 9d437f01..037edcdd 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -37,6 +37,11 @@ extension StatusItemController { self.clearMergedSwitcherContentCaches() } self.pruneVersionScopedMenuCardHeightCache() + if allowStaleContentDuringDataRefresh { + self.latestDataOnlyMenuContentVersion = self.menuContentVersion + } else { + self.latestStructuralMenuContentVersion = self.menuContentVersion + } if !allowStaleContentDuringDataRefresh, !preservesMergedSwitcherContentCaches { self.latestRequiredMenuRebuildVersion = self.menuContentVersion } @@ -49,31 +54,56 @@ extension StatusItemController { deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) return } + if allowStaleContentDuringDataRefresh { + if !self.cancelNonRequiredClosedMenuPreparation() { + self.prepareAttachedClosedMenusIfNeeded() + } + return + } self.prepareAttachedClosedMenusIfNeeded() } + @discardableResult + private func cancelNonRequiredClosedMenuPreparation() -> Bool { + let menus = self.attachedMenusForClosedPreparation() + let hasRequiredClosedMenu = self.latestRequiredMenuRebuildVersion > 0 && menus.contains { menu in + let key = ObjectIdentifier(menu) + return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion + } + guard !hasRequiredClosedMenu else { return false } + self.cancelAllClosedMenuRebuilds() + for menu in menus { + self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu)) + } + return true + } + func prepareAttachedClosedMenusIfNeeded() { guard self.isMenuRefreshEnabled else { return } guard self.openMenus.isEmpty else { return } guard !self.isMenuDataRefreshInFlight else { return } let menus = self.attachedMenusForClosedPreparation() let requiredClosedPreparationVersion: Int? - if self.menuContentVersion > self.latestRequiredMenuRebuildVersion { - guard self.latestRequiredMenuRebuildVersion > 0 else { return } - let hasRequiredClosedMenu = menus.contains { menu in - let key = ObjectIdentifier(menu) - return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion - } - guard hasRequiredClosedMenu else { return } + if self.latestRequiredMenuRebuildVersion > 0, + menus.contains(where: { menu in + let key = ObjectIdentifier(menu) + return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion + }) + { requiredClosedPreparationVersion = self.latestRequiredMenuRebuildVersion + } else if self.menuContentVersion > self.latestRequiredMenuRebuildVersion { + guard self.latestRequiredMenuRebuildVersion > 0 else { return } + return } else { requiredClosedPreparationVersion = nil } for menu in menus { let key = ObjectIdentifier(menu) - guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue } if let requiredClosedPreparationVersion { + self.closedMenusDeferredUntilNextOpen.remove(key) guard (self.menuVersions[key] ?? -1) < requiredClosedPreparationVersion else { continue } + } else { + guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue } } // Pre-warming the merged menu while it is closed runs a full main-thread populateMenu // (incl. SwiftUI hosting-view layout) that menuWillOpen redoes synchronously on display @@ -96,6 +126,7 @@ extension StatusItemController { self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) self.menuReadinessSignatures.removeValue(forKey: key) + self.menuIdentitySignatures.removeValue(forKey: key) self.closedMenusDeferredUntilNextOpen.remove(key) } @@ -112,28 +143,36 @@ extension StatusItemController { func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu)) guard self.menuNeedsRefresh(menu) else { return } - if self.canPreserveStaleMenuContentDuringRefresh(menu) { + if self.canPreserveStaleMenuContentForInstantOpen(menu) { #if DEBUG self.menuLogger.debug( - "menu open kept existing content during refresh", + "menu open kept existing content for instant render", metadata: [ "items": "\(menu.items.count)", "provider": provider?.rawValue ?? "nil", "storeRefreshing": self.store.isRefreshing ? "1" : "0", ]) #endif - self.deferMenuInteractionRefreshIfNeeded() + if self.isMenuRefreshEnabled, !self.isMenuDataRefreshInFlight { + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: provider, + resyncReadinessBaselineAfterRebuild: self.openMenus.isEmpty) + } return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) } - private func canPreserveStaleMenuContentDuringRefresh(_ menu: NSMenu) -> Bool { - guard self.isMenuDataRefreshInFlight, !menu.items.isEmpty else { return false } + private func canPreserveStaleMenuContentForInstantOpen(_ menu: NSMenu) -> Bool { + guard !menu.items.isEmpty else { return false } let key = ObjectIdentifier(menu) guard let menuVersion = self.menuVersions[key] else { return false } - return menuVersion >= self.latestRequiredMenuRebuildVersion + return self.menuContentVersion == self.latestDataOnlyMenuContentVersion && + menuVersion >= self.latestStructuralMenuContentVersion && + self.menuIdentitySignatures[key] == self.menuIdentitySignature( + for: self.renderedProviders(for: menu)) } private func attachedMenusForClosedPreparation() -> [NSMenu] { @@ -233,6 +272,64 @@ extension StatusItemController { let key = ObjectIdentifier(menu) self.menuVersions[key] = self.menuContentVersion self.menuReadinessSignatures[key] = self.menuAdjunctReadinessSignature() + self.menuIdentitySignatures[key] = self.menuIdentitySignature( + for: self.renderedProviders(for: menu)) + } + + private func menuIdentitySignature(for providers: [UsageProvider]) -> String { + var parts: [String] = [] + for target in providers { + parts.append(target.rawValue) + parts.append(self.providerIdentitySignature(self.store.snapshot(for: target)?.identity(for: target))) + + if self.store.metadata(for: target).usesAccountFallback { + let account = self.store.accountInfo(for: target) + parts.append(Self.menuIdentityField(account.email)) + parts.append(Self.menuIdentityField(account.plan)) + } + + for accountSnapshot in self.store.accountSnapshots[target] ?? [] { + parts.append(accountSnapshot.account.id.uuidString) + parts.append(Self.menuIdentityField(accountSnapshot.account.label)) + parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target))) + } + + if target == .codex { + for account in self.settings.codexVisibleAccountProjection.visibleAccounts { + parts.append(Self.menuIdentityField(account.id)) + parts.append(Self.menuIdentityField(account.email)) + parts.append(Self.menuIdentityField(account.workspaceLabel)) + parts.append(account.isActive ? "active" : "inactive") + parts.append(account.isLive ? "live" : "stored") + } + for accountSnapshot in self.store.codexAccountSnapshots { + parts.append(Self.menuIdentityField(accountSnapshot.id)) + parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target))) + } + } + + if target == .kilo { + for scopeSnapshot in self.store.kiloScopeSnapshots { + parts.append(Self.menuIdentityField(scopeSnapshot.id)) + parts.append(self.providerIdentitySignature(scopeSnapshot.snapshot?.identity(for: target))) + } + } + } + return parts.joined(separator: "|") + } + + private func providerIdentitySignature(_ identity: ProviderIdentitySnapshot?) -> String { + [ + identity?.providerID?.rawValue ?? "", + Self.menuIdentityField(identity?.accountEmail), + Self.menuIdentityField(identity?.accountOrganization), + Self.menuIdentityField(identity?.loginMethod), + ].joined(separator: ":") + } + + private static func menuIdentityField(_ value: String?) -> String { + let value = value ?? "" + return "\(value.utf8.count):\(value)" } func hasOpenHostedSubviewMenu() -> Bool { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 23208fcf..b1e57e6e 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -116,6 +116,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuContentVersion: Int = 0 var latestRequiredMenuRebuildVersion: Int = 0 + var latestDataOnlyMenuContentVersion: Int = 0 + var latestStructuralMenuContentVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] var menuReadinessSignatures: [ObjectIdentifier: String] = [:] let hostedSubviewRenderSignatures = NSMapTable.weakToStrongObjects() @@ -136,9 +138,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 + var menuIdentitySignatures: [ObjectIdentifier: String] = [:] var openMenuRebuildsClosingHostedSubviewMenus: Set = [] var parentMenuRebuildsDeferredDuringTracking: Set = [] - var deferredMenuInteractionRefreshPending = false + var deferredMenuInteractionRefreshProviders: Set = [] + var deferredMenuInteractionRefreshPending: Bool { + !self.deferredMenuInteractionRefreshProviders.isEmpty + } + var deferredOpenAIDashboardRefreshReason: String? var deferredMenuInteractionRefreshTask: Task? var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] @@ -649,6 +656,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #endif let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder + let localizationChanged = self.menuLocalizationSignature() != self.lastMenuLocalizationSignature let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() self.invalidateMenus() if orderChanged || configChanged { @@ -657,7 +665,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.updateVisibility() self.updateIcons() if shouldRefreshOpenMenus { - self.refreshOpenMenusForStructureChange() + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: !localizationChanged) } } diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 29406373..988e2525 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -94,6 +94,11 @@ extension UsageStore { && self.error(for: provider) == nil } + func shouldShowRefreshingMenuCardIndicator(for provider: UsageProvider) -> Bool { + let isRefreshing = self.isRefreshing || self.refreshingProviders.contains(provider) + return isRefreshing && self.error(for: provider) == nil + } + func shouldHidePlanUtilizationMenuItem(for provider: UsageProvider) -> Bool { guard self.supportsPlanUtilizationHistory(for: provider) else { return true } return self.shouldShowRefreshingMenuCard(for: provider) diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index eabe08c7..ed33360c 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -2,6 +2,19 @@ import CodexBarCore import Foundation extension UsageStore { + private struct ProviderRefreshOutcomeContext { + let generation: UInt64 + let codexExpectedGuard: CodexAccountScopedRefreshGuard? + let claudeCredentialsChanged: Bool + let shouldConsumeClaudeKeychainFingerprint: Bool + } + + func refreshForSettingsChange() async { + await self.runRefresh( + startupConnectivityRetryAttempt: nil, + coalesceProviderRefreshesOverride: false) + } + func prepareRefreshState(for provider: UsageProvider? = nil) { guard provider == nil || provider == .codex else { return } _ = self.settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() @@ -20,9 +33,140 @@ extension UsageStore { return self.providerSpecs[provider] } - func refreshProvider(_ provider: UsageProvider, allowDisabled: Bool = false) async { + func refreshProvider( + _ provider: UsageProvider, + allowDisabled: Bool = false, + coalesceIfRefreshing: Bool = false) async + { + while coalesceIfRefreshing, + let states = self.providerRefreshTasks[provider], + let latestGeneration = self.latestProviderRefreshGenerations[provider], + let existingState = states.last(where: { $0.generation == latestGeneration }) + { + await self.waitForProviderRefresh(provider, state: existingState) + if Task.isCancelled { return } + if existingState.shouldRetry { + self.removeProviderRefreshTask(provider, state: existingState) + continue + } + return + } + + self.providerRefreshTaskGeneration &+= 1 + let generation = self.providerRefreshTaskGeneration + let predecessorStates = self.providerRefreshTasks[provider] ?? [] + for predecessorState in predecessorStates { + predecessorState.cancelTask() + } + self.latestProviderRefreshGenerations[provider] = generation + let state = ProviderRefreshTaskState(generation: generation) + let task = Task { @MainActor [weak self] in + guard let self else { return } + var snapshotUpdatedAtBeforeRefresh: Date? + var didStartRefresh = false + for predecessorState in predecessorStates { + await predecessorState.waitForTaskCompletion() + } + if !Task.isCancelled, self.isCurrentProviderRefreshGeneration(provider, generation: generation) { + snapshotUpdatedAtBeforeRefresh = self.snapshot(for: provider)?.updatedAt + didStartRefresh = true + await self.refreshProviderTracked( + provider, + allowDisabled: allowDisabled, + generation: generation) + } + let publishedNewSnapshot = didStartRefresh && + self.snapshot(for: provider)?.updatedAt != snapshotUpdatedAtBeforeRefresh + let retryRequired = Task.isCancelled && !publishedNewSnapshot + self.providerRefreshDidComplete(provider, state: state, retryRequired: retryRequired) + } + state.install(task: task) + self.providerRefreshTasks[provider, default: []].append(state) + await self.waitForProviderRefresh(provider, state: state) + } + + private func waitForProviderRefresh(_ provider: UsageProvider, state: ProviderRefreshTaskState) async { + self.providerRefreshWaiterGeneration &+= 1 + let waiterID = self.providerRefreshWaiterGeneration + guard let task = state.addWaiter(waiterID) else { return } + await withTaskCancellationHandler { + await task.value + } onCancel: { + state.cancelWaiter(waiterID) + } + state.finishWaiter(waiterID) + if state.canRemove { + self.scheduleProviderRefreshTaskRemoval(provider, state: state) + } + } + + private func providerRefreshDidComplete( + _ provider: UsageProvider, + state: ProviderRefreshTaskState, + retryRequired: Bool) + { + state.markCompleted(retryRequired: retryRequired) + self.scheduleProviderRefreshTaskRemoval(provider, state: state) + } + + private func removeProviderRefreshTask(_ provider: UsageProvider, state: ProviderRefreshTaskState) { + guard var states = self.providerRefreshTasks[provider] else { return } + states.removeAll { $0 === state } + if states.isEmpty { + self.providerRefreshTasks.removeValue(forKey: provider) + } else { + self.providerRefreshTasks[provider] = states + } + } + + private func scheduleProviderRefreshTaskRemoval(_ provider: UsageProvider, state: ProviderRefreshTaskState) { + Task { @MainActor [weak self] in + await Task.yield() + guard let self, + self.providerRefreshTasks[provider]?.contains(where: { $0 === state }) == true, + state.canRemove + else { + return + } + self.removeProviderRefreshTask(provider, state: state) + } + } + + func isCurrentProviderRefreshGeneration(_ provider: UsageProvider, generation: UInt64?) -> Bool { + guard let generation else { return true } + return self.latestProviderRefreshGenerations[provider] == generation + } + + private func refreshProviderTracked( + _ provider: UsageProvider, + allowDisabled: Bool, + generation: UInt64) async + { + self.providerRefreshCounts[provider, default: 0] += 1 + self.refreshingProviders.insert(provider) + defer { + let remaining = max(0, self.providerRefreshCounts[provider, default: 1] - 1) + if remaining == 0 { + self.providerRefreshCounts.removeValue(forKey: provider) + self.refreshingProviders.remove(provider) + } else { + self.providerRefreshCounts[provider] = remaining + } + } + await self.refreshProviderNow( + provider, + allowDisabled: allowDisabled, + generation: generation) + } + + private func refreshProviderNow( + _ provider: UsageProvider, + allowDisabled: Bool, + generation: UInt64) async + { self.prepareRefreshState(for: provider) guard let spec = await self.providerRefreshSpec(provider) else { return } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } let codexExpectedGuard = provider == .codex ? self.freshCodexAccountScopedRefreshGuard() : nil if !spec.isEnabled(), !allowDisabled { @@ -30,18 +174,16 @@ extension UsageStore { return } - self.refreshingProviders.insert(provider) - defer { self.refreshingProviders.remove(provider) } - if provider == .codex, self.shouldFetchAllCodexVisibleAccounts() { - await self.refreshCodexVisibleAccountsForMenu() + await self.refreshCodexVisibleAccountsForMenu(generation: generation) return } else if provider == .codex { self.codexAccountSnapshots = [] } if provider == .kilo, self.shouldFanOutKiloScopes() { - await self.refreshKiloScopes() + await self.refreshKiloScopes(generation: generation) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } // Continue to also fetch the personal snapshot through the regular path // so the existing single-card render keeps working when only personal is shown. // The presence of multi-element kiloScopeSnapshots triggers stacked rendering. @@ -51,7 +193,10 @@ extension UsageStore { let tokenAccounts = self.tokenAccounts(for: provider) if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { - await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) + await self.refreshTokenAccounts( + provider: provider, + accounts: tokenAccounts, + generation: generation) return } else { _ = await MainActor.run { @@ -62,7 +207,7 @@ extension UsageStore { let claudeAuthStateBeforeFetch = provider == .claude ? await Self.captureClaudeRefreshAuthState(invalidateCredentialsFile: true) : nil - let fetchContext = spec.makeFetchContext() + let fetchContext = self.makeFetchContext(provider: provider, override: nil) let descriptor = spec.descriptor // Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness. let outcome = await withTaskGroup( @@ -74,6 +219,7 @@ extension UsageStore { } return await group.next()! } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } let claudeAuthFingerprintAfterFetch = provider == .claude ? await Self.captureClaudeAuthFingerprintToken() : nil @@ -88,6 +234,22 @@ extension UsageStore { let shouldConsumeClaudeKeychainFingerprint = Self.shouldConsumeClaudeKeychainFingerprintChange( beforeFetch: claudeAuthStateBeforeFetch, changedDuringFetch: claudeAuthChangedDuringFetch) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } + await self.applyProviderRefreshOutcome( + provider: provider, + outcome: outcome, + context: ProviderRefreshOutcomeContext( + generation: generation, + codexExpectedGuard: codexExpectedGuard, + claudeCredentialsChanged: claudeCredentialsChanged, + shouldConsumeClaudeKeychainFingerprint: shouldConsumeClaudeKeychainFingerprint)) + } + + private func applyProviderRefreshOutcome( + provider: UsageProvider, + outcome: ProviderFetchOutcome, + context: ProviderRefreshOutcomeContext) async + { await MainActor.run { self.lastFetchAttempts[provider] = outcome.attempts } @@ -96,17 +258,20 @@ extension UsageStore { case let .success(result): let scoped = result.usage.scoped(to: provider) if provider == .codex, - let codexExpectedGuard, + let codexExpectedGuard = context.codexExpectedGuard, !self.shouldApplyCodexUsageResult(expectedGuard: codexExpectedGuard, usage: scoped) { return } - let backfilled = await MainActor.run { - if claudeCredentialsChanged { + let backfilled = await MainActor.run { () -> UsageSnapshot? in + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { + return nil + } + if context.claudeCredentialsChanged { self.clearClaudeCredentialDerivedStateForCredentialSwapNow() } let resetBackfillSource = provider == .codex - ? self.codexLastKnownResetSnapshot(matching: codexExpectedGuard) + ? self.codexLastKnownResetSnapshot(matching: context.codexExpectedGuard) : self.lastKnownResetSnapshots[provider] let backfilled = scoped.backfillingResetTimes(from: resetBackfillSource) self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) @@ -130,12 +295,14 @@ extension UsageStore { } return backfilled } - if shouldConsumeClaudeKeychainFingerprint { + guard let backfilled else { return } + if context.shouldConsumeClaudeKeychainFingerprint { _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() } await self.recordPlanUtilizationHistorySample( provider: provider, snapshot: backfilled) + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) @@ -146,19 +313,23 @@ extension UsageStore { } case let .failure(error): if provider == .codex, - let codexExpectedGuard, + let codexExpectedGuard = context.codexExpectedGuard, !self.shouldApplyCodexScopedFailure(expectedGuard: codexExpectedGuard) { return } + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } self.recordStartupConnectivityRetryableFailure(error) - if claudeCredentialsChanged { + if context.claudeCredentialsChanged { await self.clearClaudeCredentialDerivedStateForCredentialSwap() } - if shouldConsumeClaudeKeychainFingerprint { + if context.shouldConsumeClaudeKeychainFingerprint { _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() } - await self.handleProviderFetchFailure(provider: provider, error: error) + await self.handleProviderFetchFailure( + provider: provider, + error: error, + generation: context.generation) } } @@ -293,9 +464,14 @@ extension UsageStore { self.lastTokenFetchAt.removeValue(forKey: .claude) } - private func handleProviderFetchFailure(provider: UsageProvider, error: Error) async { + private func handleProviderFetchFailure( + provider: UsageProvider, + error: Error, + generation: UInt64) async + { let shouldNotifyPermissionPrompt = Self.isPermissionPromptWaiting(error) await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } let hadPriorData = self.snapshots[provider] != nil let preservesPriorData = Self.shouldPreservePriorSnapshot( after: error, @@ -336,6 +512,7 @@ extension UsageStore { self.postPermissionPromptNotificationIfNeeded(provider: provider, error: error) } } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 5f0476c7..aa7d4cad 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -75,7 +75,7 @@ extension UsageStore { projection.visibleAccounts.count > 1 } - func refreshCodexVisibleAccountsForMenu() async { + func refreshCodexVisibleAccountsForMenu(generation: UInt64? = nil) async { let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() let accounts = self.limitedCodexVisibleAccounts( projection.visibleAccounts, @@ -131,6 +131,7 @@ extension UsageStore { let currentProjection = self.freshCodexVisibleAccountProjectionForAccountRefresh( requireLiveManagedAuthFor: managedAccountIDsWithReadableAuthAtStart) + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in guard let currentAccount = Self.currentCodexVisibleAccount( matching: snapshot.account, @@ -188,7 +189,8 @@ extension UsageStore { selectedOutcome, account: currentSelectedAccount, snapshot: currentSelectedSnapshot, - sourceLabel: selectedSourceLabel) + sourceLabel: selectedSourceLabel, + generation: generation) } } else { _ = self.prepareCodexAccountScopedRefreshIfNeeded() @@ -386,7 +388,11 @@ extension UsageStore { } } - func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { + func refreshTokenAccounts( + provider: UsageProvider, + accounts: [ProviderTokenAccount], + generation: UInt64? = nil) async + { let selectedAccount = self.settings.selectedTokenAccount(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) let effectiveSelected = selectedAccount ?? limitedAccounts.first @@ -405,6 +411,7 @@ extension UsageStore { var sawAnyNonCancellationOutcome = false let results = await self.fetchTokenAccountOutcomes(provider: provider, accounts: limitedAccounts) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } for result in results { let account = result.account let outcome = result.outcome @@ -445,9 +452,11 @@ extension UsageStore { selectedOutcome, provider: provider, account: effectiveSelected, - fallbackSnapshot: selectedSnapshot) + fallbackSnapshot: selectedSnapshot, + generation: generation) } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } await self.recordFetchedTokenAccountPlanUtilizationHistory( provider: provider, samples: historySamples, @@ -632,6 +641,9 @@ extension UsageStore { codexActiveSourceOverride: codexActiveSourceOverride) let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: provider, env: env) let verbose = self.settings.isVerboseLoggingEnabled + let contextProvider = provider + let originalAccountToken = account?.token + let originalManualToken = provider == .stepfun ? self.settings.stepfunToken : nil return ProviderFetchContext( runtime: .app, sourceMode: sourceMode, @@ -646,19 +658,26 @@ extension UsageStore { claudeFetcher: self.claudeFetcher, browserDetection: self.browserDetection, selectedTokenAccountID: account?.id, - tokenAccountTokenUpdater: { [weak settings = self.settings] provider, accountID, token in + tokenAccountTokenUpdater: { [weak self] provider, accountID, token in await MainActor.run { - settings?.updateTokenAccount( + guard let self, provider == contextProvider, + self.settings.tokenAccounts(for: provider) + .first(where: { $0.id == accountID })?.token == originalAccountToken + else { + return + } + self.settings.updateTokenAccount( provider: provider, accountID: accountID, token: token) } }, - providerManualTokenUpdater: { [weak settings = self.settings] provider, token in + providerManualTokenUpdater: { [weak self] provider, token in await MainActor.run { - if provider == .stepfun { - settings?.stepfunToken = token - } + guard let self, provider == .stepfun, + self.settings.stepfunToken == originalManualToken + else { return } + self.settings.stepfunToken = token } }, costUsageHistoryDays: self.settings.costUsageHistoryDays) @@ -1141,8 +1160,10 @@ extension UsageStore { _ outcome: ProviderFetchOutcome, account: CodexVisibleAccount, snapshot: UsageSnapshot?, - sourceLabel: String?) async + sourceLabel: String?, + generation: UInt64? = nil) async { + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } self.lastFetchAttempts[.codex] = outcome.attempts switch outcome.result { case .success: @@ -1159,6 +1180,7 @@ extension UsageStore { self.rememberLiveSystemCodexEmailIfNeeded(snapshot.accountEmail(for: .codex)) self.seedCodexAccountScopedRefreshGuard(accountEmail: account.email) await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: snapshot) + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } self.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) case let .failure(error): guard let message = self.tokenAccountErrorMessage(error) else { @@ -1182,11 +1204,14 @@ extension UsageStore { _ outcome: ProviderFetchOutcome, provider: UsageProvider, account: ProviderTokenAccount?, - fallbackSnapshot: UsageSnapshot?) async + fallbackSnapshot: UsageSnapshot?, + generation: UInt64? = nil) async { await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } self.lastFetchAttempts[provider] = outcome.attempts } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: provider) @@ -1196,6 +1221,9 @@ extension UsageStore { scoped } let backfilled = await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { + return nil as UsageSnapshot? + } let backfilled = labeled.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) @@ -1206,6 +1234,7 @@ extension UsageStore { self.failureGates[provider]?.recordSuccess() return backfilled } + guard let backfilled else { return } await self.recordPlanUtilizationHistorySample( provider: provider, snapshot: backfilled, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index bae9cef7..e3ea41a7 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -64,7 +64,7 @@ extension UsageStore { self.startTimer() self.updateProviderRuntimes() await self.refreshHistoricalDatasetIfNeeded() - await self.refresh() + await self.refreshForSettingsChange() } } } @@ -227,6 +227,11 @@ final class UsageStore { @ObservationIgnored var providerSpecs: [UsageProvider: ProviderSpec] = [:] @ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata] @ObservationIgnored var providerRuntimes: [UsageProvider: any ProviderRuntime] = [:] + @ObservationIgnored var providerRefreshTasks: [UsageProvider: [ProviderRefreshTaskState]] = [:] + @ObservationIgnored var providerRefreshTaskGeneration: UInt64 = 0 + @ObservationIgnored var providerRefreshWaiterGeneration: UInt64 = 0 + @ObservationIgnored var latestProviderRefreshGenerations: [UsageProvider: UInt64] = [:] + @ObservationIgnored var providerRefreshCounts: [UsageProvider: Int] = [:] @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] @ObservationIgnored var accountInfoCache: [UsageProvider: AccountInfoCacheEntry] = [:] @ObservationIgnored private var timerTask: Task? @@ -549,8 +554,8 @@ final class UsageStore { func runRefresh( forceTokenUsage: Bool = false, - startupConnectivityRetryAttempt: Int?) - async + startupConnectivityRetryAttempt: Int?, + coalesceProviderRefreshesOverride: Bool? = nil) async { guard !self.isRefreshing else { return } self.prepareRefreshState() @@ -583,7 +588,12 @@ final class UsageStore { await withTaskGroup(of: Void.self) { group in for provider in refreshProviders { - group.addTask { await self.refreshProvider(provider) } + group.addTask { + await self.refreshProvider( + provider, + coalesceIfRefreshing: coalesceProviderRefreshesOverride ?? + (ProviderInteractionContext.current == .background)) + } if availableRefreshProviders.contains(provider) { group.addTask { await self.refreshStatus(provider) } } diff --git a/Sources/CodexBar/UsageStoreSupport.swift b/Sources/CodexBar/UsageStoreSupport.swift index 3ac92fc5..6f9aa532 100644 --- a/Sources/CodexBar/UsageStoreSupport.swift +++ b/Sources/CodexBar/UsageStoreSupport.swift @@ -1,6 +1,78 @@ import CodexBarCore import Foundation +final class ProviderRefreshTaskState: @unchecked Sendable { + let generation: UInt64 + + private let lock = NSLock() + private var task: Task? + private var waiterIDs: Set = [] + private var completed = false + private var retryRequired = false + + init(generation: UInt64) { + self.generation = generation + } + + func install(task: Task) { + self.lock.withLock { + self.task = task + } + } + + func addWaiter(_ waiterID: UInt64) -> Task? { + self.lock.withLock { + self.waiterIDs.insert(waiterID) + return self.task + } + } + + func cancelWaiter(_ waiterID: UInt64) { + let taskToCancel = self.lock.withLock { + guard self.waiterIDs.remove(waiterID) != nil else { return nil as Task? } + return self.waiterIDs.isEmpty && !self.completed ? self.task : nil + } + taskToCancel?.cancel() + } + + func finishWaiter(_ waiterID: UInt64) { + _ = self.lock.withLock { + self.waiterIDs.remove(waiterID) + } + } + + func markCompleted(retryRequired: Bool) { + self.lock.withLock { + self.completed = true + self.retryRequired = retryRequired + } + } + + func cancelTask() { + let task = self.lock.withLock { + self.completed ? nil : self.task + } + task?.cancel() + } + + func waitForTaskCompletion() async { + let task = self.lock.withLock { self.task } + await task?.value + } + + var isCompleted: Bool { + self.lock.withLock { self.completed } + } + + var shouldRetry: Bool { + self.lock.withLock { self.retryRequired } + } + + var canRemove: Bool { + self.lock.withLock { self.completed && self.waiterIDs.isEmpty } + } +} + enum ProviderStatusIndicator: String { case none case minor diff --git a/Tests/CodexBarTests/MenuCardSubtitleTests.swift b/Tests/CodexBarTests/MenuCardSubtitleTests.swift index 8407c1c1..6cda8e0d 100644 --- a/Tests/CodexBarTests/MenuCardSubtitleTests.swift +++ b/Tests/CodexBarTests/MenuCardSubtitleTests.swift @@ -46,4 +46,49 @@ struct MenuCardSubtitleTests { #expect(model.subtitleText == UsageFormatter.updatedString(from: updatedAt, now: now)) } + + @Test + func `subtitle shows refreshing while cached snapshot remains visible`() throws { + let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let now = updatedAt.addingTimeInterval(5 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), + isRefreshing: true, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.subtitleText == "Refreshing…") + #expect(model.subtitleStyle == .loading) + #expect(!model.metrics.isEmpty) + } } diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift index e2e1fbaf..92ebb005 100644 --- a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift +++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift @@ -32,6 +32,9 @@ extension StatusMenuTests { defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } controller.menuRefreshEnabledOverrideForTesting = true + for _ in 0..<20 { + await Task.yield() + } let menu = controller.makeMenu() // Simulate a closed menu that was attached by an icon update but has never been opened. controller.fallbackMenu = menu @@ -80,6 +83,9 @@ extension StatusMenuTests { defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } controller.menuRefreshEnabledOverrideForTesting = true + for _ in 0..<20 { + await Task.yield() + } let menu = controller.makeMenu() controller.fallbackMenu = menu controller.statusItem.menu = menu @@ -101,6 +107,8 @@ extension StatusMenuTests { #expect(controller.menuVersions[key] == openedVersion) store.isRefreshing = false + controller.fallbackMenu = menu + controller.statusItem.menu = menu controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) for _ in 0..<40 where controller.menuVersions[key] == openedVersion { await Task.yield() diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift new file mode 100644 index 00000000..00dd8dbb --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -0,0 +1,1540 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `opening fresh menu does not schedule deferred refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + + controller.menuDidClose(menu) + for _ in 0..<40 { + await Task.yield() + } + + #expect(providerRefreshCount == 0) + #expect(refreshInteractions.isEmpty) + } + + @Test + func `menu open with missing data refreshes asynchronously while tracking`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.deferredMenuInteractionRefreshPending) + + for _ in 0..<40 where providerRefreshCount == 0 { + await Task.yield() + } + + #expect(providerRefreshCount == 1) + #expect(refreshInteractions == [.background]) + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + #expect(!controller.deferredMenuInteractionRefreshPending) + controller.menuDidClose(menu) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `menu open renders cached data immediately after data only invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedItemCount = menu.items.count + let cachedVersion = controller.menuVersions[key] + controller.lastMenuAdjunctReadinessSignature = "stale-baseline" + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + let dataOnlyVersion = controller.menuContentVersion + var asyncRebuilds = 0 + controller._test_openMenuRebuildObserver = { _ in + asyncRebuilds += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.menuWillOpen(menu) + + #expect(cachedVersion != dataOnlyVersion) + #expect(menu.items.count == cachedItemCount) + #expect(controller.menuVersions[key] == cachedVersion) + #expect(asyncRebuilds == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + + for _ in 0..<40 where asyncRebuilds == 0 { + await Task.yield() + } + + #expect(asyncRebuilds == 1) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(!controller.didMenuAdjunctReadinessChange()) + controller.menuDidClose(menu) + for _ in 0..<40 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + } + + @Test + func `closing before cached menu rebuild keeps next open stale`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + var rebuildGateEntries = 0 + var rebuildGate: CheckedContinuation? + controller._test_openMenuRefreshYieldOverride = { + rebuildGateEntries += 1 + await withCheckedContinuation { continuation in + rebuildGate = continuation + } + } + defer { + rebuildGate?.resume() + controller._test_openMenuRefreshYieldOverride = nil + } + + controller.menuWillOpen(menu) + for _ in 0..<40 where rebuildGateEntries == 0 { + await Task.yield() + } + + #expect(rebuildGateEntries == 1) + #expect(controller.menuVersions[key] == cachedVersion) + controller.menuDidClose(menu) + #expect(controller.menuNeedsRefresh(menu)) + + rebuildGate?.resume() + rebuildGate = nil + controller._test_openMenuRefreshYieldOverride = nil + for _ in 0..<20 { + await Task.yield() + } + + controller.menuWillOpen(menu) + for _ in 0..<40 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(!controller.menuNeedsRefresh(menu)) + controller.menuDidClose(menu) + } + + @Test + func `menu open rebuilds synchronously after provider identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com"), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com"), + provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `overview menu rebuilds synchronously after secondary provider identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.codex, .claude], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "codex@example.com"), + provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + controller.selectedMenuProvider = .codex + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com", provider: .claude), + provider: .claude) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `stacked Codex menu rebuilds synchronously after secondary account identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "selected@example.com"), + provider: .codex) + let selectedAccount = CodexVisibleAccount( + id: "selected", + email: "selected@example.com", + workspaceLabel: nil, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let secondaryAccount = CodexVisibleAccount( + id: "secondary", + email: "secondary@example.com", + workspaceLabel: nil, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + store.codexAccountSnapshots = [ + CodexAccountUsageSnapshot( + account: selectedAccount, + snapshot: self.instantOpenSnapshot(email: "selected@example.com"), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: secondaryAccount, + snapshot: self.instantOpenSnapshot(email: "old@example.com"), + error: nil, + sourceLabel: "test"), + ] + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store.codexAccountSnapshots[1] = CodexAccountUsageSnapshot( + account: secondaryAccount, + snapshot: self.instantOpenSnapshot(email: "new@example.com"), + error: nil, + sourceLabel: "test") + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `cache preserving structural invalidation rebuilds synchronously on open`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + + controller.preservingMergedSwitcherContentCachesDuringInvalidation { + controller.invalidateMenus() + } + #expect(controller.menuVersions[key] == cachedVersion) + #expect(controller.menuContentVersion != controller.latestDataOnlyMenuContentVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `data invalidation after cache preserving structural invalidation still rebuilds synchronously`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + + controller.preservingMergedSwitcherContentCachesDuringInvalidation { + controller.invalidateMenus() + } + let structuralVersion = controller.menuContentVersion + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + #expect(controller.menuVersions[key] == cachedVersion) + #expect(controller.latestStructuralMenuContentVersion == structuralVersion) + #expect(controller.menuContentVersion == controller.latestDataOnlyMenuContentVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `menu open does not overlap provider specific refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + let existingRefreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<40 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(store.refreshingProviders.contains(.codex)) + await refreshGate.releaseFirst() + await existingRefreshTask.value + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + #expect(!store.isRefreshing) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `cached menu rebuilds after active provider refresh completes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + let existingRefreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + #expect(controller.menuNeedsRefresh(menu)) + + await refreshGate.releaseFirst() + await existingRefreshTask.value + for _ in 0..<80 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `menu rebuilds after displayed provider completes while another provider refreshes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com"), + provider: .codex) + store.refreshingProviders = [.claude] + defer { store.refreshingProviders = [] } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com"), + provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<80 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `user refresh supersedes background provider refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let backgroundRefreshTask = Task { + await ProviderInteractionContext.$current.withValue(.background) { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + } + await refreshGate.waitUntilStarted(count: 1) + + let userRefreshTask = Task { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh() + } + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + await refreshGate.releaseFirst() + await refreshGate.waitUntilStarted(count: 2) + await userRefreshTask.value + await backgroundRefreshTask.value + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .userInitiated]) + } + + @Test + func `settings refresh supersedes background provider refresh without becoming user initiated`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let backgroundRefreshTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + await refreshGate.waitUntilStarted(count: 1) + + let settingsRefreshTask = Task { + await store.refreshForSettingsChange() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + await refreshGate.releaseFirst() + await refreshGate.waitUntilStarted(count: 2) + await settingsRefreshTask.value + await backgroundRefreshTask.value + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .background]) + } + + @Test + func `superseded provider refresh drains before newer result`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.claude], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderRefresh() + let baseSpec = try #require(store.providerSpecs[.claude]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderFetchStrategy { + await refreshes.awaitSnapshot() + } + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .claude, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.claude) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.claude) + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshes.startCount == 1) + + await refreshes.resume( + call: 1, + snapshot: self.instantOpenSnapshot( + email: "old@example.com", + provider: .claude, + percent: 10)) + await refreshes.waitUntilStarted(count: 2) + await refreshes.resume( + call: 2, + snapshot: self.instantOpenSnapshot( + email: "new@example.com", + provider: .claude, + percent: 80)) + await newerTask.value + await olderTask.value + + #expect(store.snapshot(for: .claude)?.primary?.usedPercent == 80) + #expect(store.snapshot(for: .claude)?.accountEmail(for: .claude) == "new@example.com") + } + + @Test + func `superseded provider refresh cannot overwrite manually changed token`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings) + settings.stepfunToken = "initial-token" + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderMutation() + let baseSpec = try #require(store.providerSpecs[.stepfun]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes) + store.providerSpecs[.stepfun] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .stepfun, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.stepfun) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.stepfun) + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshes.startCount == 1) + + settings.stepfunToken = "user-token" + await refreshes.resume(call: 1, token: "old-token") + await refreshes.waitUntilStarted(count: 2) + #expect(settings.stepfunToken == "user-token") + await refreshes.resume(call: 2, token: "new-token") + await newerTask.value + await olderTask.value + + #expect(settings.stepfunToken == "new-token") + } + + @Test + func `superseded provider refresh preserves rotated token when credential is unchanged`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings) + settings.stepfunToken = "initial-token" + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderMutation() + let baseSpec = try #require(store.providerSpecs[.stepfun]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes) + store.providerSpecs[.stepfun] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .stepfun, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.stepfun) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.stepfun) + } + + await refreshes.resume(call: 1, token: "rotated-token") + await refreshes.waitUntilStarted(count: 2) + #expect(settings.stepfunToken == "rotated-token") + await refreshes.resume(call: 2, token: "newer-token") + await newerTask.value + await olderTask.value + + #expect(settings.stepfunToken == "newer-token") + } + + @Test + func `canceling provider refresh cancels its owned probe task`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshWasCancelled = false + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + refreshWasCancelled = Task.isCancelled + } + defer { store._test_providerRefreshOverride = nil } + + let refreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + refreshTask.cancel() + await refreshGate.releaseFirst() + await refreshTask.value + + #expect(refreshWasCancelled) + } + + @Test + func `canceling refresh owner keeps shared provider probe alive`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshWasCancelled = false + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + refreshWasCancelled = Task.isCancelled + } + defer { store._test_providerRefreshOverride = nil } + + let ownerTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + let sharedWaiterTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + for _ in 0..<40 { + await Task.yield() + } + + ownerTask.cancel() + await refreshGate.releaseFirst() + await ownerTask.value + await sharedWaiterTask.value + + #expect(!refreshWasCancelled) + #expect(await refreshGate.startCount == 1) + } + + @Test + func `background refresh retries canceled provider probe with cached data`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "cached@example.com"), + provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let ownerTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + ownerTask.cancel() + let backgroundTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + for _ in 0..<40 { + await Task.yield() + } + await refreshGate.releaseFirst() + await ownerTask.value + await backgroundTask.value + + #expect(await refreshGate.startCount == 2) + } + + @Test + func `menu open refresh only retries the displayed provider`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store.refreshingProviders.insert(.claude) + defer { store.refreshingProviders.remove(.claude) } + var refreshedProviders: [UsageProvider] = [] + store._test_providerRefreshOverride = { provider in + refreshedProviders.append(provider) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuProviders[ObjectIdentifier(menu)] = .codex + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<40 where refreshedProviders.isEmpty { + await Task.yield() + } + + #expect(refreshedProviders == [.codex]) + } + + @Test + func `opening fresh split menu preserves another provider deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "claude@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60)) + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() + } + + let codexMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(codexMenu) + controller.menuDidClose(codexMenu) + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + let claudeMenu = controller.makeMenu(for: .claude) + controller.menuWillOpen(claudeMenu) + defer { controller.menuDidClose(claudeMenu) } + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + #expect(controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `overview defers only providers that need retry`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "claude@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60)) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + } + + @Test + func `closing overview menu stops before refreshing another provider`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.codex, .openai], settings: settings) + settings.updateProviderConfig(provider: .openai) { config in + config.apiKey = "test-openai-key" + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting(nil, provider: .openai) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { _ in + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60)) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + controller.menuDidClose(menu) + await refreshGate.releaseFirst() + for _ in 0..<80 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `closing menu during missing data refresh preserves deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + #expect(controller.deferredMenuInteractionRefreshPending) + #expect(store.refreshingProviders.contains(.codex)) + + let periodicRefreshTask = Task { + await store.refresh() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + controller.menuDidClose(menu) + #expect(controller.deferredMenuInteractionRefreshPending) + await refreshGate.releaseFirst() + await periodicRefreshTask.value + for _ in 0..<40 where store.isRefreshing { + await Task.yield() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.deferredMenuInteractionRefreshPending) + + controller.scheduleDeferredMenuInteractionRefreshIfNeeded(delay: .zero) + await refreshGate.waitUntilStarted(count: 2) + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .background]) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `closing menu during successful missing data refresh clears deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + controller.menuDidClose(menu) + await refreshGate.releaseFirst() + + for _ in 0..<80 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + for _ in 0..<40 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + private func enableOnlyCodexForInstantOpenTesting(_ settings: SettingsStore) { + self.enableProvidersForInstantOpenTesting([.codex], settings: settings) + } + + private func instantOpenSnapshot( + email: String, + provider: UsageProvider = .codex, + percent: Double = 25) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "ChatGPT")) + } + + private func enableProvidersForInstantOpenTesting( + _ enabledProviders: Set, + settings: SettingsStore) + { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) + } + } +} + +private struct InstantOpenProviderFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async -> UsageSnapshot + + var id: String { + "instant-open-provider-refresh-test" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let usage = await self.loader() + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct InstantOpenProviderMutationFetchStrategy: ProviderFetchStrategy { + let mutations: OrderedInstantOpenProviderMutation + + let id = "instant-open-provider-mutation-test" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let token = await self.mutations.awaitToken() + await context.providerManualTokenUpdater?(.stepfun, token) + let usage = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor OrderedInstantOpenProviderRefresh { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var continuations: [Int: CheckedContinuation] = [:] + + var startCount: Int { + self.started + } + + func awaitSnapshot() async -> UsageSnapshot { + self.started += 1 + let call = self.started + self.resumeReadyStartWaiters() + return await withCheckedContinuation { continuation in + self.continuations[call] = continuation + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resume(call: Int, snapshot: UsageSnapshot) { + self.continuations.removeValue(forKey: call)?.resume(returning: snapshot) + } + + 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 OrderedInstantOpenProviderMutation { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var continuations: [Int: CheckedContinuation] = [:] + + var startCount: Int { + self.started + } + + func awaitToken() async -> String { + self.started += 1 + let call = self.started + self.resumeReadyStartWaiters() + return await withCheckedContinuation { continuation in + self.continuations[call] = continuation + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resume(call: Int, token: String) { + self.continuations.removeValue(forKey: call)?.resume(returning: token) + } + + 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 BlockingInstantOpenProviderRefresh { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var firstReleaseWaiters: [CheckedContinuation] = [] + private var firstReleased = false + + var startCount: Int { + self.started + } + + func run() async { + self.started += 1 + self.resumeReadyStartWaiters() + guard self.started == 1, !self.firstReleased else { return } + await withCheckedContinuation { continuation in + self.firstReleaseWaiters.append(continuation) + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func releaseFirst() { + self.firstReleased = true + let waiters = self.firstReleaseWaiters + self.firstReleaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + 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/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index e25496bb..4a523d59 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -5,112 +5,6 @@ import Testing @testable import CodexBar extension StatusMenuTests { - @Test - func `opening fresh menu does not schedule deferred refresh`() async { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = false - self.enableOnlyCodex(settings) - - let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) - var providerRefreshCount = 0 - var refreshInteractions: [ProviderInteraction] = [] - store._test_providerRefreshOverride = { provider in - guard provider == .codex else { return } - refreshInteractions.append(ProviderInteractionContext.current) - providerRefreshCount += 1 - } - defer { store._test_providerRefreshOverride = nil } - - let controller = StatusItemController( - store: store, - settings: settings, - account: UsageFetcher().loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - defer { controller.releaseStatusItemsForTesting() } - StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) - defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } - - controller.menuRefreshEnabledOverrideForTesting = true - StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) - defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } - - let menu = controller.makeMenu() - controller.menuWillOpen(menu) - - for _ in 0..<20 { - await Task.yield() - } - #expect(providerRefreshCount == 0) - #expect(!controller.deferredMenuInteractionRefreshPending) - - controller.menuDidClose(menu) - for _ in 0..<40 { - await Task.yield() - } - - #expect(providerRefreshCount == 0) - #expect(refreshInteractions.isEmpty) - } - - @Test - func `menu open with missing data defers automatic refresh until tracking ends`() async { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = false - self.enableOnlyCodex(settings) - - let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) - store._setSnapshotForTesting(nil, provider: .codex) - var providerRefreshCount = 0 - var refreshInteractions: [ProviderInteraction] = [] - store._test_providerRefreshOverride = { provider in - guard provider == .codex else { return } - refreshInteractions.append(ProviderInteractionContext.current) - providerRefreshCount += 1 - } - defer { store._test_providerRefreshOverride = nil } - - let controller = StatusItemController( - store: store, - settings: settings, - account: UsageFetcher().loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - defer { controller.releaseStatusItemsForTesting() } - StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) - defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } - - controller.menuRefreshEnabledOverrideForTesting = true - StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) - defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } - - let menu = controller.makeMenu() - controller.menuWillOpen(menu) - - for _ in 0..<20 { - await Task.yield() - } - #expect(providerRefreshCount == 0) - #expect(controller.deferredMenuInteractionRefreshPending) - - controller.menuDidClose(menu) - for _ in 0..<40 where providerRefreshCount == 0 { - await Task.yield() - } - - #expect(providerRefreshCount == 1) - #expect(refreshInteractions == [.background]) - #expect(!controller.deferredMenuInteractionRefreshPending) - } - @Test func `store observation marks open menu stale without rebuilding during tracking`() async { self.disableMenuCardsForTesting() @@ -193,10 +87,20 @@ extension StatusMenuTests { let menu = controller.makeMenu() controller.mergedMenu = menu controller.statusItem.menu = menu + for _ in 0..<20 { + await Task.yield() + } controller.populateMenu(menu, provider: nil) controller.markMenuFresh(menu) let key = ObjectIdentifier(menu) + for _ in 0..<40 { + await Task.yield() + } + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.cancelAllClosedMenuRebuilds() + controller.closedMenusDeferredUntilNextOpen.removeAll(keepingCapacity: false) let openedVersion = controller.menuVersions[key] // Background data-refresh tick (stale allowed): closed prep is skipped entirely, so @@ -256,6 +160,12 @@ extension StatusMenuTests { controller.populateMenu(menu, provider: nil) controller.markMenuFresh(menu) let key = ObjectIdentifier(menu) + for _ in 0..<40 { + await Task.yield() + } + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.cancelAllClosedMenuRebuilds() let openedVersion = controller.menuVersions[key] controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) @@ -270,6 +180,9 @@ extension StatusMenuTests { controller.menuWillOpen(menu) defer { controller.menuDidClose(menu) } + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } #expect(controller.menuVersions[key] == controller.menuContentVersion) } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index d59357da..73160c41 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -563,12 +563,19 @@ struct StatusMenuTests { settings.usageBarsShowUsed = true controller.handleProviderConfigChange(reason: "usageBarsShowUsed") - for _ in 0..<20 - where initialSwitcherID == (menu.items.first?.view as? ProviderSwitcherView).map(ObjectIdentifier.init) - { + for _ in 0..<20 { await Task.yield() } + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(ObjectIdentifier(menu))) + if let initialSwitcherID, let currentSwitcher = menu.items.first?.view as? ProviderSwitcherView { + #expect(initialSwitcherID == ObjectIdentifier(currentSwitcher)) + } + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(updatedSwitcher != nil) if let initialSwitcherID, let updatedSwitcher { diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 49053cc9..1e505ce0 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -131,10 +131,15 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) - await blocker.waitUntilStarted(count: 2) XCTAssertEqual(settings.tokenAccountsData(for: .claude)?.clampedActiveIndex(), 1) + for _ in 0..<40 { + await Task.yield() + } + let startedBeforeDrain = await blocker.startedCallCount() + XCTAssertEqual(startedBeforeDrain, 1) await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await blocker.waitUntilStarted(count: 2) await selectionTask.value await refreshTask.value let startedCallCount = await blocker.startedCallCount() From eda747ba6d4293a831398b8dadd2e2189793a7f6 Mon Sep 17 00:00:00 2001 From: bcssewl Date: Thu, 11 Jun 2026 01:33:58 +0200 Subject: [PATCH 27/51] Gate switcher shortcut event peek behind session HID counters (#1397) * perf: gate switcher event queue peeks Co-authored-by: bcssewl * docs: note switcher event gating --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + ...tatusItemController+ProviderSwitcher.swift | 89 ++++++++++- .../ProviderSwitcherEventPeekGateTests.swift | 149 ++++++++++++++++++ 3 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c7636ecd..7ec2c03e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! - Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! +- Menu bar: gate the provider-switcher shortcut monitor's event-queue peek behind session event counters so hover-driven menu tracking no longer calls `NSApp.nextEvent` on every run-loop pass (#1397). Thanks @bcssewl! - Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index b8eb310c..b9c6df97 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -6,13 +6,83 @@ struct PendingProviderSwitcherRebuild { let provider: UsageProvider? } +/// Skips the event-queue peek on run-loop passes where no event of the monitored kinds +/// can possibly be pending. The menu-tracking run loop spins on every mouse move, and the +/// session-wide event counters for keys and clicks are far cheaper to read than +/// `NSApp.nextEvent` is to call, so gating on them removes the per-pass peek cost from +/// hover-heavy menu interaction (mouse moves never advance these counters). +@MainActor +final class ProviderSwitcherEventPeekGate { + private let eventTypes: [CGEventType] + private let counterProvider: (CGEventType) -> UInt32 + private var lastCounters: [UInt32]? + private var heldKeyCodes: Set = [] + private var emptyPeekBudget = 0 + + init( + eventTypes: [CGEventType], + counterProvider: @escaping (CGEventType) -> UInt32 = { type in + CGEventSource.counterForEventType(.combinedSessionState, eventType: type) + }) + { + self.eventTypes = eventTypes + self.counterProvider = counterProvider + } + + /// True when an event of a monitored kind may have been posted since the last check. + func shouldPeek() -> Bool { + let counters = self.eventTypes.map(self.counterProvider) + let countersChanged = self.lastCounters.map { counters != $0 } ?? true + self.lastCounters = counters + if countersChanged { + // The observer runs before run-loop sources. WindowServer can advance a counter + // one pass before AppKit queues the NSEvent, so require two empty peeks before + // considering the queue caught up. + self.emptyPeekBudget = max(self.emptyPeekBudget, 2) + } + // CoreGraphics does not count key autorepeat events. Keep peeking while a key is + // held so repeated provider-navigation events are still handled. + if !self.heldKeyCodes.isEmpty { return true } + return self.emptyPeekBudget > 0 + } + + func observe(_ event: NSEvent) { + // An unhandled event stays queued until AppKit processes it after this observer. + // Keep peeking until a later pass proves the matching queue is empty. + self.emptyPeekBudget = max(self.emptyPeekBudget, 1) + switch event.type { + case .keyDown: + self.heldKeyCodes.insert(event.keyCode) + case .keyUp: + self.heldKeyCodes.remove(event.keyCode) + default: + break + } + } + + func observeQueueEmpty(afterFindingEvent: Bool) { + if afterFindingEvent { + // A counter snapshot can represent multiple events that AppKit delivers across + // run-loop passes. Keep one empty proof pending after draining available events. + self.emptyPeekBudget = max(self.emptyPeekBudget - 1, 1) + } else if self.emptyPeekBudget > 0 { + self.emptyPeekBudget -= 1 + } + } +} + @MainActor final class ProviderSwitcherShortcutEventMonitor { private let callback: @MainActor (NSEvent) -> Bool private let observer: CFRunLoopObserver private var isActive = false - init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { + init( + events: NSEvent.EventTypeMask, + peekGate: ProviderSwitcherEventPeekGate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp]), + callback: @escaping @MainActor (NSEvent) -> Bool) + { self.callback = callback self.observer = CFRunLoopObserverCreateWithHandler( @@ -20,21 +90,32 @@ final class ProviderSwitcherShortcutEventMonitor { CFRunLoopActivity.beforeSources.rawValue, true, 0) - { [events, callback] _, _ in + { [events, peekGate, callback] _, _ in MainActor.assumeIsolated { + guard peekGate.shouldPeek() else { return } + var foundEvent = false + var blockedByUnhandledEvent = false while let event = NSApp.nextEvent( matching: events, until: .distantPast, inMode: .eventTracking, dequeue: false) { - guard callback(event) else { break } + foundEvent = true + peekGate.observe(event) + guard callback(event) else { + blockedByUnhandledEvent = true + break + } _ = NSApp.nextEvent( matching: events, until: .distantPast, inMode: .eventTracking, dequeue: true) } + if !blockedByUnhandledEvent { + peekGate.observeQueueEmpty(afterFindingEvent: foundEvent) + } } } } @@ -75,7 +156,7 @@ extension StatusItemController { self.removeProviderSwitcherShortcutMonitor() let monitor = ProviderSwitcherShortcutEventMonitor( - events: [.keyDown, .leftMouseDown, .leftMouseUp]) + events: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp]) { [weak self, weak menu] event in guard let self, let menu, diff --git a/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift new file mode 100644 index 00000000..27cc0111 --- /dev/null +++ b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift @@ -0,0 +1,149 @@ +import AppKit +import CoreGraphics +import Testing +@testable import CodexBar + +@MainActor +struct ProviderSwitcherEventPeekGateTests { + @Test + func `first check always peeks`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + } + + @Test + func `unchanged counters skip the peek`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .leftMouseDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + #expect(!gate.shouldPeek()) + } + + @Test + func `any advanced counter re-enables the peek`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .leftMouseDown], + counterProvider: { type in type == .keyDown ? keyDownCount : 3 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `counter change keeps one follow up peek for AppKit queue delivery`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown], + counterProvider: { _ in keyDownCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `queued unhandled event burst keeps peeking until the queue is empty`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 3 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `handled event keeps peeking for delayed sibling from same counter snapshot`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 2 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `held key keeps peeking for uncounted autorepeat events`() throws { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .keyUp], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyDown, keyCode: 124)) + #expect(gate.shouldPeek()) + #expect(gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + private static func keyEvent(type: NSEvent.EventType, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: type, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode)) + } +} From b7772b240a4e5a4ceb53bf7a2d578753d6c22e68 Mon Sep 17 00:00:00 2001 From: kiranmagic7 Date: Thu, 11 Jun 2026 06:31:45 +0530 Subject: [PATCH 28/51] Defer merged icon redraws during menu tracking (#1409) * perf: defer merged icon redraws during menu tracking * docs: credit merged icon redraw fix --------- Co-authored-by: kiranmagic7 <209323973+kiranmagic7@users.noreply.github.com> Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../StatusItemController+Animation.swift | 62 ++++++++- .../CodexBar/StatusItemController+Menu.swift | 4 + .../StatusItemController+Shutdown.swift | 1 + Sources/CodexBar/StatusItemController.swift | 24 +--- .../StatusItemAnimationSignatureTests.swift | 125 ++++++++++++++++++ 6 files changed, 195 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec2c03e..342d7b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Codex: keep local token and cost history visible when remote quota data is unavailable (#1390). Thanks @vaibhavarora14! - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! +- Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! - Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index bbb42c44..3b545e6d 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -232,8 +232,13 @@ extension StatusItemController { } @discardableResult - func applyIcon(phase: Double?) -> Bool { + func applyIcon( + phase: Double?, + bypassMergedMenuTrackingDeferral: Bool = false) -> Bool + { guard let button = self.statusItem.button else { return false } + if !bypassMergedMenuTrackingDeferral, + self.deferMergedIconRenderDuringMenuTrackingIfNeeded() { return true } let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed @@ -395,6 +400,25 @@ extension StatusItemController { return false } + private func deferMergedIconRenderDuringMenuTrackingIfNeeded() -> Bool { + guard self.shouldMergeIcons, self.isMergedMenuOpen else { return false } + self.deferredMergedIconRenderAfterTracking = true + self.noteIconPerfRender(skipped: true) + return true + } + + func applyDeferredMergedIconRenderAfterTrackingIfNeeded() { + guard self.deferredMergedIconRenderAfterTracking else { return } + guard self.shouldMergeIcons else { + self.deferredMergedIconRenderAfterTracking = false + return + } + guard !self.isMergedMenuOpen else { return } + self.deferredMergedIconRenderAfterTracking = false + let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase + self.applyIcon(phase: phase) + } + private func shouldSkipMergedIconRender(_ signature: String) -> Bool { guard self.shouldMergeIcons else { self.lastAppliedMergedIconRenderSignature = signature @@ -595,6 +619,42 @@ extension StatusItemController { return false } + func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { + let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) + self.quotaWarningFlashUntil[provider] = until + self.quotaWarningFlashTasks[provider]?.cancel() + self.updateIcons() + self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() + self.quotaWarningFlashTasks[provider] = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) + await MainActor.run { [weak self] in + self?.clearExpiredQuotaWarningFlash(provider: provider) + } + } + } + + func clearExpiredQuotaWarningFlash(provider: UsageProvider, now: Date = Date()) { + guard let currentUntil = self.quotaWarningFlashUntil[provider], + currentUntil <= now + else { + return + } + self.quotaWarningFlashUntil.removeValue(forKey: provider) + self.quotaWarningFlashTasks.removeValue(forKey: provider) + self.updateIcons() + self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() + } + + private func applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() { + guard self.shouldMergeIcons, + self.isMergedMenuOpen + else { + return + } + let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase + self.applyIcon(phase: phase, bypassMergedMenuTrackingDeferral: true) + } + static func quotaWarningFlashImage(base: NSImage) -> NSImage { let image = NSImage(size: base.size) image.lockFocus() diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 9dfebcf3..17fd31bf 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -159,6 +159,7 @@ extension StatusItemController { func forgetClosedMenu(_ menu: NSMenu) { let key = ObjectIdentifier(menu) + let wasMergedMenu = menu === self.mergedMenu if key == self.providerSwitcherShortcutMenuID { self.removeProviderSwitcherShortcutMonitor() @@ -185,6 +186,9 @@ extension StatusItemController { } self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.scheduleDeferredMenuInteractionRefreshIfNeeded() + if wasMergedMenu { + self.applyDeferredMergedIconRenderAfterTrackingIfNeeded() + } } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 6e44db4e..5ca3f397 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -54,6 +54,7 @@ extension StatusItemController { self.openMenuInvalidationRetryTask = nil self.providerSelectionUIRefreshTask?.cancel() self.providerSelectionUIRefreshTask = nil + self.deferredMergedIconRenderAfterTracking = false self.providerSwitcherPointerInteractionMenuID = nil self.pendingProviderSwitcherPointerRebuild = nil } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index b1e57e6e..ca719199 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -249,6 +249,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 var providerSelectionUIRefreshTask: Task? + var deferredMergedIconRenderAfterTracking = false var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] var lastObservedStoreIconWorkSignature: String? @@ -568,26 +569,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.startQuotaWarningFlash(provider: event.provider, postedAt: event.postedAt) } - func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { - let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) - self.quotaWarningFlashUntil[provider] = until - self.quotaWarningFlashTasks[provider]?.cancel() - self.updateIcons() - self.quotaWarningFlashTasks[provider] = Task { [weak self] in - try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) - await MainActor.run { [weak self] in - guard let self else { return } - if let currentUntil = self.quotaWarningFlashUntil[provider], - currentUntil <= Date() - { - self.quotaWarningFlashUntil.removeValue(forKey: provider) - self.quotaWarningFlashTasks.removeValue(forKey: provider) - self.updateIcons() - } - } - } - } - private func observeUpdaterChanges() { withObservationTracking { _ = self.updater.updateStatus.isUpdateReady @@ -670,7 +651,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - private func updateIcons() { + func updateIcons() { #if DEBUG guard !self.isReleasedForTesting else { return } #endif @@ -683,6 +664,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if self.shouldMergeIcons { let skippedMergedRender = self.applyIcon(phase: phase) if skippedMergedRender, + !self.deferredMergedIconRenderAfterTracking, let mergedMenu = self.mergedMenu, self.statusItem.menu === mergedMenu { diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index dfef3960..94e38001 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -138,6 +138,131 @@ struct StatusItemAnimationSignatureTests { #expect(button.imagePosition == .imageLeft) } + @Test + func `merged icon render defers while merged menu is tracking`() async throws { + let suite = "StatusItemAnimationSignatureTests-merged-icon-defers-during-tracking" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .synthetic) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } + + store._setSnapshotForTesting(snapshot(usedPercent: 20), provider: .codex) + for _ in 0..<10 where controller.animationDriver != nil { + await Task.yield() + } + #expect(controller.animationDriver == nil) + controller.applyIcon(phase: nil) + let initialSignature = try #require(controller.lastAppliedMergedIconRenderSignature) + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.menuWillOpen(menu) + #expect(controller.isMergedMenuOpen) + + store._setSnapshotForTesting(nil, provider: .codex) + for _ in 0..<10 where controller.animationDriver == nil { + await Task.yield() + } + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + store._setSnapshotForTesting(snapshot(usedPercent: 80), provider: .codex) + for _ in 0..<10 where controller.animationDriver != nil { + await Task.yield() + } + #expect(controller.animationDriver == nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature == initialSignature) + + controller.startQuotaWarningFlash(provider: .codex) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + + let quotaWarningTask = controller.quotaWarningFlashTasks[.codex] + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + quotaWarningTask?.cancel() + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuDidClose(menu) + + #expect(!controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuWillOpen(menu) + settings.selectedMenuProvider = .synthetic + #expect(controller.primaryProviderForUnifiedIcon() == .synthetic) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + controller.startQuotaWarningFlash(provider: .codex) + let switchedProviderWarningTask = controller.quotaWarningFlashTasks[.codex] + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + switchedProviderWarningTask?.cancel() + controller.menuDidClose(menu) + + settings.selectedMenuProvider = .codex + for _ in 0..<10 where controller.primaryProviderForUnifiedIcon() != .codex { + await Task.yield() + } + + controller.menuWillOpen(menu) + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateAnimationState() + controller.applyIcon(phase: controller.animationPhase) + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + controller.animationDriver?.stop() + controller.animationDriver = nil + controller.animationPhase = 0 + controller.menuDidClose(menu) + + #expect(controller.animationDriver == nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("primary=nil") == true) + } + @Test func `merged fallback provider follows enabled provider order`() throws { let suite = "StatusItemAnimationSignatureTests-merged-provider-order" From 0e0102c30fe6897948f9a940cdf9c918ddb7d21e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 19:03:22 -0700 Subject: [PATCH 29/51] Fix merged menu width after provider switches (#1411) Keep merged-provider tabs at one stable width, account for AppKit's retained tracked menu window width, and normalize every hosted card row including Subscription Utilization. Fixes #1410 --- CHANGELOG.md | 1 + .../CodexBar/StatusItemController+Menu.swift | 23 ++----- .../StatusItemController+MenuCardItems.swift | 10 ++- .../StatusItemController+MenuTracking.swift | 25 ++++++- .../StatusItemController+MenuWidthCache.swift | 43 ++++++++++++ .../MenuCardViewRecyclingTests.swift | 68 +++++++++++++++++++ 6 files changed, 145 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 342d7b82..f62670b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7! +- Menu bar: keep one stable width across merged provider tabs and resize every hosted card row to AppKit's final menu width so provider switching no longer leaves a widened menu with inset submenu arrows (#1410). - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! - Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 17fd31bf..2e4f9c52 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -49,15 +49,6 @@ extension StatusItemController { } } - private func menuCardWidth( - for providers: [UsageProvider], - sections: [MenuDescriptor.Section]) -> CGFloat - { - _ = providers - let baselineWidth = Self.menuCardBaseWidth - return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) - } - func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) @@ -217,6 +208,7 @@ extension StatusItemController { menu: menu, provider: provider) } + defer { self.refreshMenuCardHeights(in: menu) } let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) @@ -244,16 +236,13 @@ extension StatusItemController { let openAIContext = self.openAIWebContext( currentProvider: currentProvider, showAllAccounts: showAllAccounts) - let descriptor = MenuDescriptor.build( + let descriptor = self.makeMenuDescriptor( provider: selectedProvider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, includeContextualActions: !isOverviewSelected) - let menuWidth = self.menuCardWidth(for: enabledProviders, sections: descriptor.sections) + let menuWidth = self.menuCardWidth( + for: enabledProviders, + selectedProvider: selectedProvider, + descriptor: descriptor) let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index ebb86759..0f7a0750 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -3,12 +3,10 @@ import SwiftUI extension StatusItemController { func refreshMenuCardHeights(in menu: NSMenu) { - let cardItems = menu.items.filter { item in - (item.representedObject as? String)?.hasPrefix("menuCard") == true - } - for item in cardItems { - guard let view = item.view else { continue } - let width = self.renderedMenuWidth(for: menu) + let width = self.renderedMenuWidth(for: menu) + for item in menu.items { + guard let view = item.view, view is any MenuCardMeasuring else { continue } + guard abs(view.frame.width - width) > 0.5 else { continue } let id = item.representedObject as? String ?? "menuCard" let scope = self.menuProvider(for: menu)?.rawValue ?? id let height = self.cachedMenuCardHeight(for: id, scope: scope, width: width) { diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 037edcdd..f7acd4a4 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -199,8 +199,29 @@ extension StatusItemController { } func renderedMenuWidth(for menu: NSMenu) -> CGFloat { - let measuredWidth = ceil(menu.size.width) - return max(measuredWidth, Self.menuCardBaseWidth) + let menuKey = ObjectIdentifier(menu) + let trackedWindowWidth: CGFloat? = if self.openMenus[menuKey] != nil { + menu.items.lazy.compactMap { item -> CGFloat? in + guard let window = item.view?.window else { return nil } + let contentWidth = window.contentLayoutRect.width + return contentWidth > 0 ? contentWidth : window.frame.width + }.first + } else { + nil + } + return Self.resolvedRenderedMenuWidth( + menuWidth: menu.size.width, + trackedWindowWidth: trackedWindowWidth) + } + + static func resolvedRenderedMenuWidth( + menuWidth: CGFloat, + trackedWindowWidth: CGFloat?) -> CGFloat + { + max( + ceil(menuWidth), + ceil(trackedWindowWidth ?? 0), + menuCardBaseWidth) } func rebuildClosedMenuIfNeeded(_ menu: NSMenu) { diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index a376958e..8f18e526 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -1,8 +1,51 @@ import AppKit +import CodexBarCore extension StatusItemController { private static let measuredStandardMenuWidthCacheLimit = 96 + func menuCardWidth( + for providers: [UsageProvider], + selectedProvider: UsageProvider?, + descriptor: MenuDescriptor) -> CGFloat + { + let sectionSets: [[MenuDescriptor.Section]] = if self.shouldMergeIcons, providers.count > 1 { + providers.map { provider in + if provider == selectedProvider { + return descriptor.sections + } + return self.makeMenuDescriptor( + provider: provider, + includeContextualActions: true).sections + } + } else { + [descriptor.sections] + } + return self.measuredMenuCardWidth(for: sectionSets) + } + + func measuredMenuCardWidth(for sectionSets: [[MenuDescriptor.Section]]) -> CGFloat { + let baselineWidth = Self.menuCardBaseWidth + return sectionSets.reduce(baselineWidth) { width, sections in + max(width, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) + } + } + + func makeMenuDescriptor( + provider: UsageProvider?, + includeContextualActions: Bool) -> MenuDescriptor + { + MenuDescriptor.build( + provider: provider, + store: self.store, + settings: self.settings, + account: self.account, + managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + updateReady: self.updater.updateStatus.isUpdateReady, + includeContextualActions: includeContextualActions) + } + func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { let cacheKey = self.measuredStandardMenuWidthCacheKey(for: sections, baseWidth: baseWidth) if let cached = self.measuredStandardMenuWidthCache[cacheKey] { diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift index 7c623bb6..5d48efd3 100644 --- a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -35,6 +35,74 @@ extension StatusMenuTests { return identities } + @Test + func `merged menu width uses widest provider action set`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let narrow = [ + MenuDescriptor.Section(entries: [ + .action("Usage Dashboard", .dashboard), + ]), + ] + let wide = [ + MenuDescriptor.Section(entries: [ + .action(String(repeating: "W", count: 60), .dashboard), + ]), + ] + + let narrowWidth = controller.measuredMenuCardWidth(for: [narrow]) + let stableWidth = controller.measuredMenuCardWidth(for: [narrow, wide]) + + #expect(narrowWidth == StatusItemController.menuCardBaseWidth) + #expect(stableWidth > narrowWidth) + #expect(controller.measuredMenuCardWidth(for: [wide, narrow]) == stableWidth) + } + + @Test + func `menu width normalization includes usage history submenu row`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let usageHistoryItem = controller.makeMenuCardItem( + Text("Subscription Utilization"), + id: "usageHistorySubmenu", + width: StatusItemController.menuCardBaseWidth) + menu.addItem(usageHistoryItem) + menu.addItem(NSMenuItem( + title: String(repeating: "W", count: 60), + action: nil, + keyEquivalent: "")) + + let expectedWidth = controller.renderedMenuWidth(for: menu) + #expect(expectedWidth > StatusItemController.menuCardBaseWidth) + + controller.refreshMenuCardHeights(in: menu) + + #expect(abs((usageHistoryItem.view?.frame.width ?? 0) - expectedWidth) <= 0.5) + } + + @Test + func `rendered menu width keeps tracked window width after AppKit shrink`() { + let width = StatusItemController.resolvedRenderedMenuWidth( + menuWidth: 310, + trackedWindowWidth: 356) + + #expect(width == 356) + #expect(StatusItemController.resolvedRenderedMenuWidth( + menuWidth: 310, + trackedWindowWidth: nil) == 310) + } + @Test func `data only repopulate reuses menu card hosting views`() { StatusItemController.setMenuRefreshEnabledForTesting(false) From 02c94032c79a23b95658b37c2f85ea59d8de713a Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:43:04 -0700 Subject: [PATCH 30/51] Decode provider status feeds off the main actor (#1406) * Decode provider status feeds off the main actor UsageStore's status fetch/parse helpers are statics on a MainActor type, so the Google Workspace incidents feed (hundreds of kilobytes live) decoded on the main thread, stalling the UI 150-340ms per Google-status provider per refresh - refreshes that also fire during menu interaction (#1399). The status helpers touch no store state, so they are now nonisolated and run on the concurrent executor, and the per-date-field ISO8601DateFormatter allocations are replaced with shared lock-guarded formatters (same pattern as CostUsageISO8601FormatterBox). * perf: guarantee status decoding stays off main --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Sources/CodexBar/UsageStore+Status.swift | 82 +++++++++++++------ .../GoogleWorkspaceStatusNetworkTests.swift | 26 ++++++ 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f62670b7..2ac0eae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7! +- Provider status: decode status feeds on the concurrent executor and reuse ISO8601 formatters, removing a measured main-thread stall during refreshes (#1406). Thanks @ProspectOre! - Menu bar: keep one stable width across merged provider tabs and resize every hosted card row to AppKit's final menu width so provider switching no longer leaves a widened menu with inset submenu arrows (#1410). - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! diff --git a/Sources/CodexBar/UsageStore+Status.swift b/Sources/CodexBar/UsageStore+Status.swift index 4d1b85e4..c1bd9b3f 100644 --- a/Sources/CodexBar/UsageStore+Status.swift +++ b/Sources/CodexBar/UsageStore+Status.swift @@ -1,8 +1,51 @@ import CodexBarCore import Foundation +/// Shared, lock-guarded ISO8601 formatters for status feeds. Allocating a fresh +/// `ISO8601DateFormatter` per decoded date field is a measurable share of decoding the +/// Google Workspace incidents feed, which can run to hundreds of kilobytes (#1399). +private final class StatusISO8601FormatterBox: @unchecked Sendable { + let lock = NSLock() + let withFractional: ISO8601DateFormatter = { + let fmt = ISO8601DateFormatter() + fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fmt + }() + + let plain: ISO8601DateFormatter = { + let fmt = ISO8601DateFormatter() + fmt.formatOptions = [.withInternetDateTime] + return fmt + }() +} + +private enum StatusFeedDateParser { + static let box = StatusISO8601FormatterBox() + + static func parse(_ text: String) -> Date? { + self.box.lock.lock() + defer { self.box.lock.unlock() } + return self.box.withFractional.date(from: text) ?? self.box.plain.date(from: text) + } + + static func decodingStrategy() -> JSONDecoder.DateDecodingStrategy { + .custom { decoder in + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + guard let date = StatusFeedDateParser.parse(raw) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") + } + return date + } + } +} + extension UsageStore { - static func fetchStatus( + /// Status feeds decode off the main actor: the Google Workspace incidents payload alone + /// can be hundreds of kilobytes and cost 150-340ms to decode (#1399), and these helpers + /// touch no store state. + @concurrent + nonisolated static func fetchStatus( from baseURL: URL, transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ProviderStatus @@ -32,16 +75,7 @@ extension UsageStore { } let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: raw) { return date } - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: raw) { return date } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") - } + decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy() let response = try decoder.decode(Response.self, from: data) let indicator = ProviderStatusIndicator(rawValue: response.status.indicator) ?? .unknown @@ -51,9 +85,11 @@ extension UsageStore { updatedAt: response.page?.updatedAt) } - static func fetchWorkspaceStatus( + @concurrent + nonisolated static func fetchWorkspaceStatus( productID: String, - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + beforeDecoding: (@Sendable () -> Void)? = nil) async throws -> ProviderStatus { guard let url = URL(string: "https://www.google.com/appsstatus/dashboard/incidents.json") else { @@ -62,22 +98,14 @@ extension UsageStore { var request = URLRequest(url: url) request.timeoutInterval = 10 let (data, _) = try await transport.data(for: request) + beforeDecoding?() return try Self.parseGoogleWorkspaceStatus(data: data, productID: productID) } - static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus { + nonisolated static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: raw) { return date } - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: raw) { return date } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") - } + decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy() let incidents = try decoder.decode([GoogleWorkspaceIncident].self, from: data) let active = incidents.filter { $0.isRelevant(productID: productID) && $0.isActive } @@ -105,7 +133,7 @@ extension UsageStore { return ProviderStatus(indicator: best.indicator, description: description, updatedAt: updatedAt) } - private static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int { + private nonisolated static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int { switch indicator { case .none: 0 case .maintenance: 1 @@ -116,7 +144,7 @@ extension UsageStore { } } - private static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator { + private nonisolated static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator { switch status?.uppercased() { case "AVAILABLE": return .none case "SERVICE_INFORMATION": return .minor @@ -134,7 +162,7 @@ extension UsageStore { } } - private static func workspaceSummary(from text: String?) -> String? { + private nonisolated static func workspaceSummary(from text: String?) -> String? { guard let text else { return nil } let normalized = text .replacingOccurrences(of: "\r\n", with: "\n") diff --git a/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift index 92661272..1d51d3df 100644 --- a/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift +++ b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift @@ -1,4 +1,5 @@ import Foundation +import os import Testing @testable import CodexBar @@ -41,4 +42,29 @@ struct GoogleWorkspaceStatusNetworkTests { #expect(requests.count == 1) #expect(requests.first?.url?.host == "www.google.com") } + + @Test + func `fetchWorkspaceStatus decodes off the main thread when called from the main actor`() async throws { + // The incidents feed can run to hundreds of kilobytes; decoding it on the main + // actor stalls the UI for 150-340ms per Google-status provider per refresh (#1399). + let decodedOffMainThread = OSAllocatedUnfairLock(initialState: false) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data("[]".utf8), response) + } + + let status = try await UsageStore.fetchWorkspaceStatus( + productID: "npdyhgECDJ6tB66MxXyo", + transport: transport, + beforeDecoding: { + decodedOffMainThread.withLock { $0 = !Thread.isMainThread } + }) + + #expect(status.indicator == .none) + #expect(decodedOffMainThread.withLock { $0 }) + } } From ad71da06985e65ab2664594602f09e2dcaa94a1d Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:30:49 -0700 Subject: [PATCH 31/51] Keep codex account file reads off the menu-build path populateMenu -> codexAccountMenuDisplay loaded the codex account reconciliation snapshot synchronously whenever the 2s freshness cache had lapsed, paying auth.json reads, JWT parsing, and SHA256 fingerprinting on the main thread inside menu open and tracking. Menu display now tolerates a stale cached snapshot and revalidates the cache off the menu-build path; account changes land on the next rebuild. --- CHANGELOG.md | 1 + .../Providers/Codex/CodexSettingsStore.swift | 39 ++++++ Sources/CodexBar/SettingsStore.swift | 1 + ...tusItemController+AccountMenuDisplay.swift | 2 +- ...CodexAccountMenuDisplaySnapshotTests.swift | 114 ++++++++++++++++++ 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac0eae4..a578f899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7! - Provider status: decode status feeds on the concurrent executor and reuse ISO8601 formatters, removing a measured main-thread stall during refreshes (#1406). Thanks @ProspectOre! - Menu bar: keep one stable width across merged provider tabs and resize every hosted card row to AppKit's final menu width so provider switching no longer leaves a widened menu with inset submenu arrows (#1410). +- Menu bar: keep Codex `auth.json` reads, JWT parsing, and fingerprint hashing off the menu-build path by rendering a cached account snapshot and revalidating it asynchronously (#1401). Thanks @ProspectOre! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! - Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 1f7987b1..b7d2650e 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -240,10 +240,49 @@ extension SettingsStore { return snapshot } + /// Menu rendering must not block on live `auth.json` reads, JWT parsing, and fingerprint + /// hashing. This returns the cached reconciliation snapshot even when it is older than the + /// freshness interval and refreshes the cache off the menu-build path instead, so only the + /// first menu build after launch (no cache yet) or an active-source change pays the + /// synchronous load. Account changes land on the next menu rebuild. + var codexAccountReconciliationSnapshotForMenuDisplay: CodexAccountReconciliationSnapshot { + let activeSource = self.codexPersistedActiveSource + guard Self.codexAccountReconciliationSnapshotCacheInterval > 0, + let cached = self.cachedCodexAccountReconciliationSnapshot, + cached.activeSource == activeSource + else { + return self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) + } + if Date().timeIntervalSince(cached.loadedAt) >= Self.codexAccountReconciliationSnapshotCacheInterval { + self.scheduleCodexAccountReconciliationSnapshotRevalidation() + } + return cached.snapshot + } + + private func scheduleCodexAccountReconciliationSnapshotRevalidation() { + guard self.codexAccountSnapshotRevalidationTask == nil else { return } + self.codexAccountSnapshotRevalidationTask = Task { @MainActor [weak self] in + // The main dispatch queue does not drain while AppKit runs the menu-tracking run loop + // mode, so this hop keeps the reload from landing inside an open tracking session. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + guard let self else { return } + defer { self.codexAccountSnapshotRevalidationTask = nil } + guard !Task.isCancelled else { return } + self.invalidateCodexAccountReconciliationSnapshotCache() + _ = self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) + } + } + var codexVisibleAccountProjection: CodexVisibleAccountProjection { CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) } + var codexVisibleAccountProjectionForMenuDisplay: CodexVisibleAccountProjection { + CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshotForMenuDisplay) + } + var codexVisibleAccounts: [CodexVisibleAccount] { self.codexVisibleAccountProjection.visibleAccounts } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index c6135391..6bf989c2 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -140,6 +140,7 @@ final class SettingsStore { @ObservationIgnored var tokenAccountsLoaded = false @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: CachedCodexAccountReconciliationSnapshot? + @ObservationIgnored var codexAccountSnapshotRevalidationTask: Task? @ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState diff --git a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift index ea0bb9d2..824d5e0c 100644 --- a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift +++ b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift @@ -35,7 +35,7 @@ extension StatusItemController { func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { guard provider == .codex else { return nil } - let projection = self.settings.codexVisibleAccountProjection + let projection = self.settings.codexVisibleAccountProjectionForMenuDisplay guard projection.visibleAccounts.count > 1 else { return nil } let showAll = self.settings.multiAccountMenuLayout == .stacked let accounts = showAll diff --git a/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift new file mode 100644 index 00000000..891b0d4c --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift @@ -0,0 +1,114 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct CodexAccountMenuDisplaySnapshotTests { + @MainActor + private static func makeSettings(suite: String) throws -> SettingsStore { + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings + } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth = ["tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan), + ]] + let data = try JSONSerialization.data(withJSONObject: auth) + 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, + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } + + @Test + @MainActor + func `menu display snapshot tolerates stale cache and revalidates off the menu path`() async throws { + let suite = "CodexAccountMenuDisplaySnapshotTests-stale-cache" + let settings = try Self.makeSettings(suite: suite) + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "before@example.com", plan: "pro") + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path] + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: ambientHome) + } + + let primed = settings.codexAccountReconciliationSnapshot + #expect(primed.liveSystemAccount?.email == "before@example.com") + + // Simulate a cache that has outlived the freshness interval while the auth file changed. + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "after@example.com", plan: "pro") + let cached = try #require(settings.cachedCodexAccountReconciliationSnapshot) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: cached.activeSource, + loadedAt: Date(timeIntervalSinceNow: -3600), + snapshot: cached.snapshot) + + // The menu path returns the stale cache without a synchronous reload. + let menuSnapshot = settings.codexAccountReconciliationSnapshotForMenuDisplay + #expect(menuSnapshot.liveSystemAccount?.email == "before@example.com") + + // The scheduled revalidation refreshes the cache off the menu path. + let revalidation = try #require(settings.codexAccountSnapshotRevalidationTask) + await revalidation.value + #expect(settings.codexAccountSnapshotRevalidationTask == nil) + #expect( + settings.codexAccountReconciliationSnapshotForMenuDisplay.liveSystemAccount?.email == + "after@example.com") + } + + @Test + @MainActor + func `menu display snapshot loads synchronously without a cache`() throws { + let suite = "CodexAccountMenuDisplaySnapshotTests-cold-cache" + let settings = try Self.makeSettings(suite: suite) + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "cold@example.com", plan: "pro") + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path] + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: ambientHome) + } + + #expect(settings.cachedCodexAccountReconciliationSnapshot == nil) + let menuSnapshot = settings.codexAccountReconciliationSnapshotForMenuDisplay + #expect(menuSnapshot.liveSystemAccount?.email == "cold@example.com") + #expect(settings.codexAccountSnapshotRevalidationTask == nil) + #expect(settings.cachedCodexAccountReconciliationSnapshot != nil) + } +} From f1c1b6cdf3eadaa8cfd0ba15aa99c78bfe3c08bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 04:08:32 +0100 Subject: [PATCH 32/51] perf: keep Codex account reconciliation off menu tracking --- .../Providers/Codex/CodexSettingsStore.swift | 130 ++++-- Sources/CodexBar/SettingsStore.swift | 20 +- ...tusItemController+AccountMenuDisplay.swift | 51 ++- .../CodexBar/StatusItemController+Menu.swift | 2 + .../StatusItemController+MenuTracking.swift | 6 +- .../StatusItemController+Shutdown.swift | 2 + Sources/CodexBar/StatusItemController.swift | 3 + .../Codex/CodexAccountReconciliation.swift | 2 +- ...CodexAccountMenuDisplaySnapshotTests.swift | 376 ++++++++++++++---- .../StatusMenuCodexSwitcherTests.swift | 3 + 10 files changed, 476 insertions(+), 119 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index b7d2650e..a0be2ddb 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -141,6 +141,7 @@ extension SettingsStore { } set { self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil self.updateProviderConfig(provider: .codex) { entry in entry.codexActiveSource = newValue } @@ -166,6 +167,7 @@ extension SettingsStore { @discardableResult func refreshCodexAccountReconciliationAfterManagedAccountsDidChange() -> Bool { self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil return self.persistResolvedCodexActiveSourceCorrectionIfNeeded() } @@ -210,6 +212,7 @@ extension SettingsStore { func invalidateCodexAccountReconciliationSnapshotCache() { self.cachedCodexAccountReconciliationSnapshot = nil + self.codexAccountReconciliationGeneration &+= 1 } var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot { @@ -230,57 +233,86 @@ extension SettingsStore { return cached.snapshot } - let snapshot = self.codexAccountReconciler(activeSource: activeSource).loadSnapshot() + let snapshot = self.codexAccountSnapshotLoader(activeSource: activeSource)() + let loadedAt = Date() if cacheInterval > 0 { self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( activeSource: activeSource, - loadedAt: now, + loadedAt: loadedAt, snapshot: snapshot) } + if activeSource == self.codexPersistedActiveSource { + self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection( + activeSource: activeSource, + loadedAt: loadedAt, + projection: CodexVisibleAccountProjection.make(from: snapshot)) + } return snapshot } - /// Menu rendering must not block on live `auth.json` reads, JWT parsing, and fingerprint - /// hashing. This returns the cached reconciliation snapshot even when it is older than the - /// freshness interval and refreshes the cache off the menu-build path instead, so only the - /// first menu build after launch (no cache yet) or an active-source change pays the - /// synchronous load. Account changes land on the next menu rebuild. - var codexAccountReconciliationSnapshotForMenuDisplay: CodexAccountReconciliationSnapshot { + /// Menu rendering must stay side-effect free: no `auth.json` reads, JWT parsing, or fingerprint hashing. + var codexVisibleAccountProjectionForMenuDisplay: CodexVisibleAccountProjection? { let activeSource = self.codexPersistedActiveSource - guard Self.codexAccountReconciliationSnapshotCacheInterval > 0, - let cached = self.cachedCodexAccountReconciliationSnapshot, + guard let cached = self.cachedCodexAccountMenuProjection, cached.activeSource == activeSource else { - return self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) + return nil } - if Date().timeIntervalSince(cached.loadedAt) >= Self.codexAccountReconciliationSnapshotCacheInterval { - self.scheduleCodexAccountReconciliationSnapshotRevalidation() + return cached.projection + } + + var codexAccountMenuProjectionNeedsRevalidation: Bool { + let activeSource = self.codexPersistedActiveSource + guard let cached = self.cachedCodexAccountMenuProjection, + cached.activeSource == activeSource + else { + return true } - return cached.snapshot + return Date().timeIntervalSince(cached.loadedAt) >= Self.codexAccountReconciliationSnapshotCacheInterval } - private func scheduleCodexAccountReconciliationSnapshotRevalidation() { - guard self.codexAccountSnapshotRevalidationTask == nil else { return } - self.codexAccountSnapshotRevalidationTask = Task { @MainActor [weak self] in - // The main dispatch queue does not drain while AppKit runs the menu-tracking run loop - // mode, so this hop keeps the reload from landing inside an open tracking session. - await withCheckedContinuation { continuation in - DispatchQueue.main.async { continuation.resume() } - } - guard let self else { return } - defer { self.codexAccountSnapshotRevalidationTask = nil } - guard !Task.isCancelled else { return } - self.invalidateCodexAccountReconciliationSnapshotCache() - _ = self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) + func revalidateCodexAccountMenuProjection() async -> CodexAccountMenuProjectionRevalidationResult { + guard self.codexAccountMenuProjectionNeedsRevalidation else { return .skipped } + + let activeSource = self.codexPersistedActiveSource + let generation = self.codexAccountReconciliationGeneration + let loader = self.codexAccountSnapshotLoader(activeSource: activeSource) + let snapshot = await Self.loadCodexAccountSnapshot(loader) + + guard generation == self.codexAccountReconciliationGeneration, + activeSource == self.codexPersistedActiveSource + else { + return .discarded + } + + let now = Date() + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let previousProjection = self.cachedCodexAccountMenuProjection.flatMap { cached in + cached.activeSource == activeSource ? cached.projection : nil + } + self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection( + activeSource: activeSource, + loadedAt: now, + projection: projection) + if Self.codexAccountReconciliationSnapshotCacheInterval > 0 { + self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: activeSource, + loadedAt: now, + snapshot: snapshot) } + return previousProjection == projection ? .unchanged : .updated } - var codexVisibleAccountProjection: CodexVisibleAccountProjection { - CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) + @concurrent + private nonisolated static func loadCodexAccountSnapshot( + _ loader: @escaping @Sendable () -> CodexAccountReconciliationSnapshot) + async -> CodexAccountReconciliationSnapshot + { + loader() } - var codexVisibleAccountProjectionForMenuDisplay: CodexVisibleAccountProjection { - CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshotForMenuDisplay) + var codexVisibleAccountProjection: CodexVisibleAccountProjection { + CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) } var codexVisibleAccounts: [CodexVisibleAccount] { @@ -296,11 +328,8 @@ extension SettingsStore { } func selectDisplayedCodexVisibleAccount(_ account: CodexVisibleAccount) { - if self.selectCodexVisibleAccount(id: account.id) { - return - } - // An open menu can preserve a previously rendered account row while the live projection is briefly incomplete. - self.invalidateCodexAccountReconciliationSnapshotCache() + // The row already carries the exact source it represented. Re-resolving its ID would synchronously + // reload auth state from the menu click callback and can also fail after a stale snapshot is rendered. self.codexActiveSource = account.selectionSource } @@ -322,6 +351,18 @@ extension SettingsStore { self.codexVisibleAccountProjection.source(forVisibleAccountID: id) } + private func codexAccountSnapshotLoader( + activeSource: CodexActiveSource) -> @Sendable () -> CodexAccountReconciliationSnapshot + { + #if DEBUG + if let loader = self._test_codexAccountSnapshotLoader { + return { loader(activeSource) } + } + #endif + let reconciler = self.codexAccountReconciler(activeSource: activeSource) + return { reconciler.loadSnapshot() } + } + private func codexAccountReconciler(activeSource: CodexActiveSource) -> DefaultCodexAccountReconciler { let baseEnvironment = self.codexReconciliationEnvironment() #if DEBUG @@ -557,10 +598,15 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb } extension SettingsStore { + private func invalidateCodexAccountReconciliationCachesForTesting() { + self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil + } + var _test_activeManagedCodexRemoteHomePath: String? { get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) } } @@ -568,7 +614,7 @@ extension SettingsStore { var _test_activeManagedCodexAccount: ManagedCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.account(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) } } @@ -576,7 +622,7 @@ extension SettingsStore { var _test_unreadableManagedCodexAccountStore: Bool { get { CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) } } @@ -584,7 +630,7 @@ extension SettingsStore { var _test_managedCodexAccountStoreURL: URL? { get { CodexManagedRemoteHomeTestingOverride.managedStoreURL(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) } } @@ -592,7 +638,7 @@ extension SettingsStore { var _test_liveSystemCodexAccount: ObservedSystemCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) } } @@ -600,7 +646,7 @@ extension SettingsStore { var _test_codexReconciliationEnvironment: [String: String]? { get { CodexManagedRemoteHomeTestingOverride.reconciliationEnvironment(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) } } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6bf989c2..7a05a305 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -114,6 +114,19 @@ struct CachedCodexAccountReconciliationSnapshot { let snapshot: CodexAccountReconciliationSnapshot } +struct CachedCodexAccountMenuProjection: Equatable { + let activeSource: CodexActiveSource + let loadedAt: Date + let projection: CodexVisibleAccountProjection +} + +enum CodexAccountMenuProjectionRevalidationResult: Equatable { + case skipped + case discarded + case unchanged + case updated +} + @MainActor @Observable final class SettingsStore { @@ -140,7 +153,12 @@ final class SettingsStore { @ObservationIgnored var tokenAccountsLoaded = false @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: CachedCodexAccountReconciliationSnapshot? - @ObservationIgnored var codexAccountSnapshotRevalidationTask: Task? + @ObservationIgnored var cachedCodexAccountMenuProjection: CachedCodexAccountMenuProjection? + @ObservationIgnored var codexAccountReconciliationGeneration: UInt = 0 + #if DEBUG + @ObservationIgnored var _test_codexAccountSnapshotLoader: + (@Sendable (CodexActiveSource) -> CodexAccountReconciliationSnapshot)? + #endif @ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState diff --git a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift index 824d5e0c..2c268511 100644 --- a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift +++ b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift @@ -2,6 +2,30 @@ import AppKit import CodexBarCore extension StatusItemController { + private static let defaultCodexAccountMenuProjectionRevalidationEnabled = !SettingsStore.isRunningTests + + #if DEBUG + private static var codexAccountMenuProjectionRevalidationEnabledForTesting = + defaultCodexAccountMenuProjectionRevalidationEnabled + + static func setCodexAccountMenuProjectionRevalidationEnabledForTesting(_ enabled: Bool) { + self.codexAccountMenuProjectionRevalidationEnabledForTesting = enabled + } + + static func resetCodexAccountMenuProjectionRevalidationEnabledForTesting() { + self.codexAccountMenuProjectionRevalidationEnabledForTesting = + self.defaultCodexAccountMenuProjectionRevalidationEnabled + } + #endif + + private static var codexAccountMenuProjectionRevalidationEnabled: Bool { + #if DEBUG + self.codexAccountMenuProjectionRevalidationEnabledForTesting + #else + self.defaultCodexAccountMenuProjectionRevalidationEnabled + #endif + } + func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) @@ -35,7 +59,7 @@ extension StatusItemController { func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { guard provider == .codex else { return nil } - let projection = self.settings.codexVisibleAccountProjectionForMenuDisplay + guard let projection = self.settings.codexVisibleAccountProjectionForMenuDisplay else { return nil } guard projection.visibleAccounts.count > 1 else { return nil } let showAll = self.settings.multiAccountMenuLayout == .stacked let accounts = showAll @@ -52,6 +76,31 @@ extension StatusItemController { layout: showAll ? .stacked : .segmented) } + func scheduleCodexAccountMenuProjectionRevalidationIfNeeded(for providers: [UsageProvider]) { + guard Self.codexAccountMenuProjectionRevalidationEnabled else { return } + guard providers.contains(.codex) else { return } + guard self.settings.codexAccountMenuProjectionNeedsRevalidation else { return } + guard self.codexAccountMenuProjectionRevalidationTask == nil else { return } + + self.codexAccountMenuProjectionRevalidationTask = Task { @MainActor [weak self] in + guard let settings = self?.settings else { return } + let result = await settings.revalidateCodexAccountMenuProjection() + guard let self else { return } + guard !Task.isCancelled else { + self.codexAccountMenuProjectionRevalidationTask = nil + return + } + self.codexAccountMenuProjectionRevalidationTask = nil + + switch result { + case .updated: + self.invalidateMenus(refreshOpenMenus: false) + case .discarded, .skipped, .unchanged: + break + } + } + } + private func codexAccountSnapshots(matching accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] { var snapshotsByID: [String: CodexAccountUsageSnapshot] = [:] for snapshot in self.store.codexAccountSnapshots { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2e4f9c52..d1a491b7 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -121,6 +121,8 @@ extension StatusItemController { let menuWasFreshBeforeOpen = !self.menuNeedsRefresh(menu) self.refreshMenuForOpenIfNeeded(menu, provider: provider) + self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( + for: self.renderedProviders(for: menu)) if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index f7acd4a4..7601eeb0 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -303,7 +303,7 @@ extension StatusItemController { parts.append(target.rawValue) parts.append(self.providerIdentitySignature(self.store.snapshot(for: target)?.identity(for: target))) - if self.store.metadata(for: target).usesAccountFallback { + if target != .codex, self.store.metadata(for: target).usesAccountFallback { let account = self.store.accountInfo(for: target) parts.append(Self.menuIdentityField(account.email)) parts.append(Self.menuIdentityField(account.plan)) @@ -316,7 +316,9 @@ extension StatusItemController { } if target == .codex { - for account in self.settings.codexVisibleAccountProjection.visibleAccounts { + parts.append(Self.menuIdentityField(self.account.email)) + parts.append(Self.menuIdentityField(self.account.plan)) + for account in self.settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts ?? [] { parts.append(Self.menuIdentityField(account.id)) parts.append(Self.menuIdentityField(account.email)) parts.append(Self.menuIdentityField(account.workspaceLabel)) diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 5ca3f397..6494086c 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -52,6 +52,8 @@ extension StatusItemController { } self.openMenuInvalidationRetryTask?.cancel() self.openMenuInvalidationRetryTask = nil + self.codexAccountMenuProjectionRevalidationTask?.cancel() + self.codexAccountMenuProjectionRevalidationTask = nil self.providerSelectionUIRefreshTask?.cancel() self.providerSelectionUIRefreshTask = nil self.deferredMergedIconRenderAfterTracking = false diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index ca719199..60ad9d55 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -139,6 +139,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 var menuIdentitySignatures: [ObjectIdentifier: String] = [:] + var codexAccountMenuProjectionRevalidationTask: Task? var openMenuRebuildsClosingHostedSubviewMenus: Set = [] var parentMenuRebuildsDeferredDuringTracking: Set = [] var deferredMenuInteractionRefreshProviders: Set = [] @@ -415,6 +416,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.wireBindings() self.updateVisibility() self.updateIcons() + self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( + for: self.store.enabledProvidersForDisplay()) self.scheduleStartupStatusItemVisibilityCheck() NotificationCenter.default.addObserver( self, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift index 25dcae7a..3b2f8a07 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift @@ -119,7 +119,7 @@ public struct CodexAccountReconciliationSnapshot: Equatable, Sendable { } } -public struct DefaultCodexAccountReconciler { +public struct DefaultCodexAccountReconciler: Sendable { public let storeLoader: @Sendable () throws -> ManagedCodexAccountSet public let systemObserver: any CodexSystemAccountObserving public let activeSource: CodexActiveSource diff --git a/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift index 891b0d4c..5941c8b0 100644 --- a/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift +++ b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift @@ -1,13 +1,15 @@ +import AppKit import CodexBarCore import Foundation import Testing @testable import CodexBar @Suite(.serialized) +@MainActor struct CodexAccountMenuDisplaySnapshotTests { - @MainActor - private static func makeSettings(suite: String) throws -> SettingsStore { - let defaults = try #require(UserDefaults(suiteName: suite)) + private func makeSettings() -> SettingsStore { + let suite = "CodexAccountMenuDisplaySnapshotTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) defaults.set(true, forKey: "providerDetectionCompleted") let settings = SettingsStore( @@ -19,96 +21,326 @@ struct CodexAccountMenuDisplaySnapshotTests { return settings } - private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { - try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) - let auth = ["tokens": [ - "accessToken": "access-token", - "refreshToken": "refresh-token", - "idToken": Self.fakeJWT(email: email, plan: plan), - ]] - let data = try JSONSerialization.data(withJSONObject: auth) - 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, - ])) ?? Data() - - func base64URL(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "=", with: "") - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") + private func enableOnlyCodex(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) } + } + + private func liveSnapshot(email: String) -> CodexAccountReconciliationSnapshot { + CodexAccountReconciliationSnapshot( + storedAccounts: [], + activeStoredAccount: nil, + liveSystemAccount: ObservedSystemCodexAccount( + email: email, + codexHomePath: "/tmp/\(email)", + observedAt: Date()), + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .liveSystem, + hasUnreadableAddedAccountStore: false) + } + + private func cachedProjection( + snapshot: CodexAccountReconciliationSnapshot, + loadedAt: Date = Date(timeIntervalSinceNow: -3600)) -> CachedCodexAccountMenuProjection + { + CachedCodexAccountMenuProjection( + activeSource: snapshot.activeSource, + loadedAt: loadedAt, + projection: CodexVisibleAccountProjection.make(from: snapshot)) + } + + @Test + func `cold menu projection read never loads auth state`() async { + let settings = self.makeSettings() + let probe = CodexAccountSnapshotLoaderProbe(snapshot: self.liveSnapshot(email: "loaded@example.com")) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + defer { settings._test_codexAccountSnapshotLoader = nil } + + #expect(settings.codexVisibleAccountProjectionForMenuDisplay == nil) + #expect(probe.callCount == 0) + + let result = await settings.revalidateCodexAccountMenuProjection() - return "\(base64URL(header)).\(base64URL(payload))." + #expect(result == .updated) + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "loaded@example.com") } @Test - @MainActor - func `menu display snapshot tolerates stale cache and revalidates off the menu path`() async throws { - let suite = "CodexAccountMenuDisplaySnapshotTests-stale-cache" - let settings = try Self.makeSettings(suite: suite) - let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( - UUID().uuidString, - isDirectory: true) - try Self.writeCodexAuthFile(homeURL: ambientHome, email: "before@example.com", plan: "pro") - settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path] + func `override snapshot load preserves persisted account menu projection`() { + let settings = self.makeSettings() + let activeSnapshot = self.liveSnapshot(email: "active@example.com") + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: activeSnapshot) + + let otherID = UUID() + let otherAccount = ManagedCodexAccount( + id: otherID, + email: "other@example.com", + managedHomePath: "/tmp/other", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let overrideSnapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [otherAccount], + activeStoredAccount: otherAccount, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: otherID), + hasUnreadableAddedAccountStore: false) + settings._test_codexAccountSnapshotLoader = { _ in overrideSnapshot } + defer { settings._test_codexAccountSnapshotLoader = nil } + + _ = settings.codexAccountReconciliationSnapshot(activeSourceOverride: .managedAccount(id: otherID)) + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "active@example.com") + } + + @Test + func `managed account change refreshes account menu projection`() { + let settings = self.makeSettings() + let activeSnapshot = self.liveSnapshot(email: "active@example.com") + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: activeSnapshot, loadedAt: Date()) + + let addedAccount = ManagedCodexAccount( + id: UUID(), + email: "added@example.com", + managedHomePath: "/tmp/added", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let refreshedSnapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [addedAccount], + activeStoredAccount: nil, + liveSystemAccount: activeSnapshot.liveSystemAccount, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .liveSystem, + hasUnreadableAddedAccountStore: false) + settings._test_codexAccountSnapshotLoader = { _ in refreshedSnapshot } + defer { settings._test_codexAccountSnapshotLoader = nil } + + settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.contains { + $0.email == "added@example.com" + } == true) + } + + @Test + func `stale menu projection returns immediately then refreshes concurrently`() async { + let settings = self.makeSettings() + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe(snapshot: self.liveSnapshot(email: "after@example.com")) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 defer { SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil - settings._test_codexReconciliationEnvironment = nil - try? FileManager.default.removeItem(at: ambientHome) + settings._test_codexAccountSnapshotLoader = nil } - let primed = settings.codexAccountReconciliationSnapshot - #expect(primed.liveSystemAccount?.email == "before@example.com") - - // Simulate a cache that has outlived the freshness interval while the auth file changed. - try Self.writeCodexAuthFile(homeURL: ambientHome, email: "after@example.com", plan: "pro") - let cached = try #require(settings.cachedCodexAccountReconciliationSnapshot) - settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( - activeSource: cached.activeSource, - loadedAt: Date(timeIntervalSinceNow: -3600), - snapshot: cached.snapshot) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "before@example.com") + #expect(probe.callCount == 0) + #expect(settings.codexAccountMenuProjectionNeedsRevalidation) - // The menu path returns the stale cache without a synchronous reload. - let menuSnapshot = settings.codexAccountReconciliationSnapshotForMenuDisplay - #expect(menuSnapshot.liveSystemAccount?.email == "before@example.com") + let result = await settings.revalidateCodexAccountMenuProjection() - // The scheduled revalidation refreshes the cache off the menu path. - let revalidation = try #require(settings.codexAccountSnapshotRevalidationTask) - await revalidation.value - #expect(settings.codexAccountSnapshotRevalidationTask == nil) + #expect(result == .updated) + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) #expect( - settings.codexAccountReconciliationSnapshotForMenuDisplay.liveSystemAccount?.email == + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == "after@example.com") } @Test - @MainActor - func `menu display snapshot loads synchronously without a cache`() throws { - let suite = "CodexAccountMenuDisplaySnapshotTests-cold-cache" - let settings = try Self.makeSettings(suite: suite) - let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( - UUID().uuidString, - isDirectory: true) - try Self.writeCodexAuthFile(homeURL: ambientHome, email: "cold@example.com", plan: "pro") - settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path] + func `revalidation discards result after reconciliation generation changes`() async { + let settings = self.makeSettings() + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe( + snapshot: self.liveSnapshot(email: "discarded@example.com"), + blocks: true) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 defer { + probe.release() SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil - settings._test_codexReconciliationEnvironment = nil - try? FileManager.default.removeItem(at: ambientHome) + settings._test_codexAccountSnapshotLoader = nil + } + + let task = Task { await settings.revalidateCodexAccountMenuProjection() } + await probe.waitUntilCalled() + settings.invalidateCodexAccountReconciliationSnapshotCache() + probe.release() + + #expect(await task.value == .discarded) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "before@example.com") + } + + @Test + func `fresh menu open coalesces account projection revalidation and identity stays read only`() async throws { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + StatusItemController.setCodexAccountMenuProjectionRevalidationEnabledForTesting(true) + defer { + StatusItemController.resetCodexAccountMenuProjectionRevalidationEnabledForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() } - #expect(settings.cachedCodexAccountReconciliationSnapshot == nil) - let menuSnapshot = settings.codexAccountReconciliationSnapshotForMenuDisplay - #expect(menuSnapshot.liveSystemAccount?.email == "cold@example.com") - #expect(settings.codexAccountSnapshotRevalidationTask == nil) - #expect(settings.cachedCodexAccountReconciliationSnapshot != nil) + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe( + snapshot: self.liveSnapshot(email: "after@example.com"), + blocks: true) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + probe.release() + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + let menu = NSMenu() + controller.menuProviders[ObjectIdentifier(menu)] = .codex + controller.markMenuFresh(menu) + #expect(controller.codexAccountMenuDisplay(for: .codex) == nil) + #expect(probe.callCount == 0) + + let versionBeforeOpen = controller.menuContentVersion + controller.menuWillOpen(menu) + let revalidation = try #require(controller.codexAccountMenuProjectionRevalidationTask) + controller.menuWillOpen(menu) + await probe.waitUntilCalled() + + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + probe.release() + await revalidation.value + + #expect(controller.codexAccountMenuProjectionRevalidationTask == nil) + #expect(controller.menuContentVersion == versionBeforeOpen + 1) + } + + @Test + func `selecting displayed account uses captured source without reconciliation`() throws { + let settings = self.makeSettings() + let firstID = UUID() + let secondID = UUID() + let first = ManagedCodexAccount( + id: firstID, + email: "first@example.com", + managedHomePath: "/tmp/first", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let second = ManagedCodexAccount( + id: secondID, + email: "second@example.com", + managedHomePath: "/tmp/second", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [first, second], + activeStoredAccount: first, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: firstID), + hasUnreadableAddedAccountStore: false) + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let displayedAccount = try #require(projection.visibleAccounts.first { + $0.selectionSource == .managedAccount(id: secondID) + }) + let probe = CodexAccountSnapshotLoaderProbe(snapshot: snapshot) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: snapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + defer { settings._test_codexAccountSnapshotLoader = nil } + + settings.selectDisplayedCodexVisibleAccount(displayedAccount) + + #expect(probe.callCount == 0) + #expect(settings.codexActiveSource == .managedAccount(id: secondID)) + #expect(settings.cachedCodexAccountMenuProjection == nil) + } +} + +private final class CodexAccountSnapshotLoaderProbe: @unchecked Sendable { + private let lock = NSLock() + private let snapshot: CodexAccountReconciliationSnapshot + private let blocks: Bool + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var _callCount = 0 + private var _loadedOffMainThread = false + private var released = false + + init(snapshot: CodexAccountReconciliationSnapshot, blocks: Bool = false) { + self.snapshot = snapshot + self.blocks = blocks + } + + var callCount: Int { + self.lock.withLock { self._callCount } + } + + var loadedOffMainThread: Bool { + self.lock.withLock { self._loadedOffMainThread } + } + + func load() -> CodexAccountReconciliationSnapshot { + self.lock.withLock { + self._callCount += 1 + self._loadedOffMainThread = self._loadedOffMainThread || !Thread.isMainThread + } + if self.blocks { + self.releaseSemaphore.wait() + } + return self.snapshot + } + + func waitUntilCalled() async { + while self.callCount == 0 { + await Task.yield() + } + } + + func release() { + let shouldSignal = self.lock.withLock { + guard !self.released else { return false } + self.released = true + return true + } + if shouldSignal { + self.releaseSemaphore.signal() + } } } diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index 4ad6ce80..c7feea47 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -223,6 +223,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -285,6 +286,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -449,6 +451,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) From fda0fae9fd6cfd9e2bdef08ec61d3e2f3c7c30fc Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:06:45 -0700 Subject: [PATCH 33/51] Run cost-usage corpus scans off the Swift cooperative thread pool CostUsageScanner and PiSessionCostScanner scans execute synchronously for minutes on large session archives. Running them inline on cooperative-pool task threads starves every other async task in the process: menus freeze while the main thread sits idle, and overlapping provider scans multiply the pressure. Field samples on a 2.5GB corpus showed both provider scans saturating pool threads for 7+ minutes after a cache schema bump while menu opens stalled. All corpus scans and persisted-cache decoding now run on one dedicated serial utility queue (CostUsageScanExecutor), with task cancellation bridged into the scanner-level cancellation checks. Serialization also removes concurrent provider scans racing the same disk. --- CHANGELOG.md | 1 + Sources/CodexBarCore/CostUsageFetcher.swift | 88 +++++++++-------- .../CodexBarCore/CostUsageScanExecutor.swift | 43 +++++++++ .../CostUsageScanExecutorTests.swift | 96 +++++++++++++++++++ 4 files changed, 189 insertions(+), 39 deletions(-) create mode 100644 Sources/CodexBarCore/CostUsageScanExecutor.swift create mode 100644 Tests/CodexBarTests/CostUsageScanExecutorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a578f899..641c925a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Cost history: keep all per-day model breakdown rows available in a bounded scrolling detail area instead of hiding models after the first four (#1370). Thanks @MoollaMore! +- Cost usage: run local session-corpus scans and cache decoding on a dedicated serial queue instead of the Swift cooperative thread pool, so multi-minute scans of large archives no longer starve the app's async work or freeze menus (#1387, #1392). Thanks @ProspectOre! - Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! - Security: block credentialed provider redirects that leave the original HTTPS origin while preserving same-origin redirects (#1237). Thanks @Hinotoi-agent! - Codex: keep local token and cost history visible when remote quota data is unavailable (#1390). Thanks @vaibhavarora14! diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 0dd1a79d..a8019ea1 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -146,53 +146,60 @@ public struct CostUsageFetcher: Sendable { if forceRefresh { options.refreshMinIntervalSeconds = 0 } - let checkCancellation: CostUsageScanner.CancellationCheck = { - try Task.checkCancellation() + var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() + if resolvedPiOptions.cacheRoot == nil { + resolvedPiOptions.cacheRoot = options.cacheRoot } - try Task.checkCancellation() - var daily = try CostUsageScanner.loadDailyReportCancellable( - provider: provider, - since: since, - until: until, - now: now, - options: options, - checkCancellation: checkCancellation) - try Task.checkCancellation() + if forceRefresh { + resolvedPiOptions.refreshMinIntervalSeconds = 0 + } + let piOptions = resolvedPiOptions - if provider == .vertexai, - !allowVertexClaudeFallback, - options.claudeLogProviderFilter == .vertexAIOnly, - daily.data.isEmpty - { - var fallback = options - fallback.claudeLogProviderFilter = .all - daily = try CostUsageScanner.loadDailyReportCancellable( + try Task.checkCancellation() + // The corpus scans below are synchronous and can run for minutes on large session + // archives. They execute on the dedicated scan queue so they never occupy a cooperative + // pool thread; CostUsageScanExecutor bridges this task's cancellation into the + // scanner-level checks. + let scanOptions = options + let daily = try await CostUsageScanExecutor.run { checkCancellation in + var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, until: until, now: now, - options: fallback, + options: scanOptions, checkCancellation: checkCancellation) - try Task.checkCancellation() - } + try checkCancellation() - if provider == .codex || provider == .claude { - var piOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() - if piOptions.cacheRoot == nil { - piOptions.cacheRoot = options.cacheRoot + if provider == .vertexai, + !allowVertexClaudeFallback, + scanOptions.claudeLogProviderFilter == .vertexAIOnly, + daily.data.isEmpty + { + var fallback = scanOptions + fallback.claudeLogProviderFilter = .all + daily = try CostUsageScanner.loadDailyReportCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: fallback, + checkCancellation: checkCancellation) + try checkCancellation() } - if forceRefresh { - piOptions.refreshMinIntervalSeconds = 0 + + if provider == .codex || provider == .claude { + let piReport = try PiSessionCostScanner.loadDailyReportCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: piOptions, + checkCancellation: checkCancellation) + try checkCancellation() + daily = CostUsageDailyReport.merged([daily, piReport]) } - let piReport = try PiSessionCostScanner.loadDailyReportCancellable( - provider: provider, - since: since, - until: until, - now: now, - options: piOptions, - checkCancellation: checkCancellation) - try Task.checkCancellation() - daily = CostUsageDailyReport.merged([daily, piReport]) + return daily } return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) @@ -210,7 +217,9 @@ public struct CostUsageFetcher: Sendable { return nil } - return await Task.detached(priority: .utility) { + // Decoding the persisted scan cache parses multi-megabyte JSON; keep it off the + // cooperative pool alongside the scans themselves. + let cachedSnapshot: CostUsageTokenSnapshot?? = try? await CostUsageScanExecutor.run { _ in let clampedHistoryDays = max(1, min(365, historyDays)) let until = now let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now @@ -247,7 +256,8 @@ public struct CostUsageFetcher: Sendable { from: CostUsageDailyReport.merged(reports), now: now, historyDays: clampedHistoryDays) - }.value + } + return cachedSnapshot.flatMap(\.self) } private static func loadBedrockDailyReport( diff --git a/Sources/CodexBarCore/CostUsageScanExecutor.swift b/Sources/CodexBarCore/CostUsageScanExecutor.swift new file mode 100644 index 00000000..a21c43e5 --- /dev/null +++ b/Sources/CodexBarCore/CostUsageScanExecutor.swift @@ -0,0 +1,43 @@ +import Foundation +import os + +/// Cost-usage scans read and parse the full local session corpus synchronously and can run for +/// minutes on large archives. Executing that work inline on Swift's cooperative thread pool +/// starves every other async task in the process — menus freeze while the main thread sits idle — +/// and overlapping provider scans multiply both the pool pressure and the disk load. This +/// executor pins all corpus scans to a single serial utility queue off the cooperative pool, so +/// long scans cost one dedicated thread instead of the app's async runtime. +public enum CostUsageScanExecutor { + public static let queueLabel = "com.steipete.codexbar.cost-usage-scan" + + private static let queue = DispatchQueue(label: queueLabel, qos: .utility) + + /// Runs `work` on the serial scan queue and bridges Swift task cancellation into the + /// scanner's cooperative `checkCancellation` callbacks. Work that is still queued when the + /// awaiting task is cancelled resumes immediately with `CancellationError` instead of + /// waiting behind an in-flight scan. + public static func run( + _ work: @escaping @Sendable (_ checkCancellation: @escaping @Sendable () throws -> Void) throws -> T) + async throws -> T + { + let cancelled = OSAllocatedUnfairLock(initialState: false) + let checkCancellation: @Sendable () throws -> Void = { + if cancelled.withLock({ $0 }) { + throw CancellationError() + } + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.queue.async { + if cancelled.withLock({ $0 }) { + continuation.resume(throwing: CancellationError()) + return + } + continuation.resume(with: Result { try work(checkCancellation) }) + } + } + } onCancel: { + cancelled.withLock { $0 = true } + } + } +} diff --git a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift new file mode 100644 index 00000000..369d92cb --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift @@ -0,0 +1,96 @@ +import Foundation +import os +import Testing +@testable import CodexBarCore + +struct CostUsageScanExecutorTests { + @Test + func `runs work on the dedicated scan queue and returns its value`() async throws { + let label = try await CostUsageScanExecutor.run { _ in + String(cString: __dispatch_queue_get_label(nil)) + } + #expect(label == CostUsageScanExecutor.queueLabel) + } + + @Test + func `propagates thrown errors`() async { + struct ScanFailure: Error {} + await #expect(throws: ScanFailure.self) { + try await CostUsageScanExecutor.run { _ -> Int in + throw ScanFailure() + } + } + } + + @Test + func `serializes overlapping scans`() async throws { + let state = OSAllocatedUnfairLock(initialState: (active: 0, maxActive: 0)) + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<4 { + group.addTask { + try await CostUsageScanExecutor.run { _ in + state.withLock { + $0.active += 1 + $0.maxActive = max($0.maxActive, $0.active) + } + usleep(20000) + state.withLock { $0.active -= 1 } + } + } + } + try await group.waitForAll() + } + #expect(state.withLock { $0.maxActive } == 1) + } + + @Test + func `cancellation reaches in-flight work through checkCancellation`() async { + let workStarted = OSAllocatedUnfairLock(initialState: false) + let task = Task { + try await CostUsageScanExecutor.run { checkCancellation in + workStarted.withLock { $0 = true } + while true { + try checkCancellation() + usleep(5000) + } + } + } + while !workStarted.withLock({ $0 }) { + usleep(1000) + } + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `work cancelled while queued resumes with CancellationError`() async { + let blockerStarted = OSAllocatedUnfairLock(initialState: false) + let releaseBlocker = OSAllocatedUnfairLock(initialState: false) + let blocker = Task { + try await CostUsageScanExecutor.run { _ in + blockerStarted.withLock { $0 = true } + while !releaseBlocker.withLock({ $0 }) { + usleep(2000) + } + } + } + while !blockerStarted.withLock({ $0 }) { + usleep(1000) + } + + let queued = Task { + try await CostUsageScanExecutor.run { _ in + Issue.record("queued work should not run after cancellation") + } + } + queued.cancel() + releaseBlocker.withLock { $0 = true } + + await #expect(throws: CancellationError.self) { + try await queued.value + } + _ = try? await blocker.value + } +} From 01350b23cc6f0c1d67c1621352753a9b61cbf6a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 04:45:59 +0100 Subject: [PATCH 34/51] fix: make cost scan cancellation immediate --- .../CodexBarCore/CostUsageScanExecutor.swift | 101 ++++++++++++++++-- .../CostUsageScanExecutorTests.swift | 99 ++++++++++++----- .../CostUsageScanExecutorLinuxTests.swift | 29 +++++ 3 files changed, 194 insertions(+), 35 deletions(-) create mode 100644 TestsLinux/CostUsageScanExecutorLinuxTests.swift diff --git a/Sources/CodexBarCore/CostUsageScanExecutor.swift b/Sources/CodexBarCore/CostUsageScanExecutor.swift index a21c43e5..059240fe 100644 --- a/Sources/CodexBarCore/CostUsageScanExecutor.swift +++ b/Sources/CodexBarCore/CostUsageScanExecutor.swift @@ -1,5 +1,4 @@ import Foundation -import os /// Cost-usage scans read and parse the full local session corpus synchronously and can run for /// minutes on large archives. Executing that work inline on Swift's cooperative thread pool @@ -12,6 +11,90 @@ public enum CostUsageScanExecutor { private static let queue = DispatchQueue(label: queueLabel, qos: .utility) + private final class RunState: @unchecked Sendable { + private enum Phase { + case initial + case queued + case running + case completed + } + + private let lock = NSLock() + private var phase: Phase = .initial + private var cancellationRequested = false + private var continuation: CheckedContinuation? + + func install(_ continuation: CheckedContinuation) -> Bool { + let shouldEnqueue: Bool + let shouldResumeCancellation: Bool + self.lock.lock() + if self.cancellationRequested { + self.phase = .completed + shouldEnqueue = false + shouldResumeCancellation = true + } else { + self.phase = .queued + self.continuation = continuation + shouldEnqueue = true + shouldResumeCancellation = false + } + self.lock.unlock() + + if shouldResumeCancellation { + continuation.resume(throwing: CancellationError()) + } + return shouldEnqueue + } + + func begin() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + guard self.phase == .queued else { return false } + self.phase = .running + return true + } + + func cancel() { + let continuation: CheckedContinuation? + self.lock.lock() + self.cancellationRequested = true + if self.phase == .queued { + self.phase = .completed + continuation = self.continuation + self.continuation = nil + } else { + continuation = nil + } + self.lock.unlock() + continuation?.resume(throwing: CancellationError()) + } + + func checkCancellation() throws { + self.lock.lock() + let cancellationRequested = self.cancellationRequested + self.lock.unlock() + if cancellationRequested { + throw CancellationError() + } + } + + func complete(with result: Result) { + let continuation: CheckedContinuation? + let resolvedResult: Result + self.lock.lock() + guard self.phase == .running else { + self.lock.unlock() + return + } + self.phase = .completed + continuation = self.continuation + self.continuation = nil + resolvedResult = self.cancellationRequested ? .failure(CancellationError()) : result + self.lock.unlock() + continuation?.resume(with: resolvedResult) + } + } + /// Runs `work` on the serial scan queue and bridges Swift task cancellation into the /// scanner's cooperative `checkCancellation` callbacks. Work that is still queued when the /// awaiting task is cancelled resumes immediately with `CancellationError` instead of @@ -20,24 +103,20 @@ public enum CostUsageScanExecutor { _ work: @escaping @Sendable (_ checkCancellation: @escaping @Sendable () throws -> Void) throws -> T) async throws -> T { - let cancelled = OSAllocatedUnfairLock(initialState: false) + let state = RunState() let checkCancellation: @Sendable () throws -> Void = { - if cancelled.withLock({ $0 }) { - throw CancellationError() - } + try state.checkCancellation() } return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in + guard state.install(continuation) else { return } self.queue.async { - if cancelled.withLock({ $0 }) { - continuation.resume(throwing: CancellationError()) - return - } - continuation.resume(with: Result { try work(checkCancellation) }) + guard state.begin() else { return } + state.complete(with: Result { try work(checkCancellation) }) } } } onCancel: { - cancelled.withLock { $0 = true } + state.cancel() } } } diff --git a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift index 369d92cb..078de479 100644 --- a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift +++ b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift @@ -1,5 +1,4 @@ import Foundation -import os import Testing @testable import CodexBarCore @@ -24,40 +23,38 @@ struct CostUsageScanExecutorTests { @Test func `serializes overlapping scans`() async throws { - let state = OSAllocatedUnfairLock(initialState: (active: 0, maxActive: 0)) + let state = LockedValue((active: 0, maxActive: 0)) try await withThrowingTaskGroup(of: Void.self) { group in for _ in 0..<4 { group.addTask { try await CostUsageScanExecutor.run { _ in - state.withLock { + state.update { $0.active += 1 $0.maxActive = max($0.maxActive, $0.active) } - usleep(20000) - state.withLock { $0.active -= 1 } + Thread.sleep(forTimeInterval: 0.02) + state.update { $0.active -= 1 } } } } try await group.waitForAll() } - #expect(state.withLock { $0.maxActive } == 1) + #expect(state.read { $0.maxActive } == 1) } @Test func `cancellation reaches in-flight work through checkCancellation`() async { - let workStarted = OSAllocatedUnfairLock(initialState: false) + let workStarted = LockedValue(false) let task = Task { try await CostUsageScanExecutor.run { checkCancellation in - workStarted.withLock { $0 = true } + workStarted.set(true) while true { try checkCancellation() - usleep(5000) + Thread.sleep(forTimeInterval: 0.005) } } } - while !workStarted.withLock({ $0 }) { - usleep(1000) - } + #expect(await self.waitUntil { workStarted.value }) task.cancel() await #expect(throws: CancellationError.self) { try await task.value @@ -66,31 +63,85 @@ struct CostUsageScanExecutorTests { @Test func `work cancelled while queued resumes with CancellationError`() async { - let blockerStarted = OSAllocatedUnfairLock(initialState: false) - let releaseBlocker = OSAllocatedUnfairLock(initialState: false) + let blockerStarted = LockedValue(false) + let releaseBlocker = LockedValue(false) let blocker = Task { try await CostUsageScanExecutor.run { _ in - blockerStarted.withLock { $0 = true } - while !releaseBlocker.withLock({ $0 }) { - usleep(2000) + blockerStarted.set(true) + while !releaseBlocker.value { + Thread.sleep(forTimeInterval: 0.002) } } } - while !blockerStarted.withLock({ $0 }) { - usleep(1000) - } + #expect(await self.waitUntil { blockerStarted.value }) + let queuedWorkStarted = LockedValue(false) let queued = Task { try await CostUsageScanExecutor.run { _ in + queuedWorkStarted.set(true) Issue.record("queued work should not run after cancellation") } } - queued.cancel() - releaseBlocker.withLock { $0 = true } + try? await Task.sleep(for: .milliseconds(50)) - await #expect(throws: CancellationError.self) { - try await queued.value + let cancellationObserved = LockedValue(nil) + let observer = Task { + do { + try await queued.value + cancellationObserved.set(false) + } catch is CancellationError { + cancellationObserved.set(true) + } catch { + cancellationObserved.set(false) + } } + queued.cancel() + + #expect(await self.waitUntil { cancellationObserved.value != nil }) + #expect(cancellationObserved.value == true) + #expect(!queuedWorkStarted.value) + #expect(!releaseBlocker.value) + + releaseBlocker.set(true) + await observer.value _ = try? await blocker.value } + + private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping @Sendable () -> Bool) async -> Bool + { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(5)) + } + return condition() + } +} + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + self.storage = value + } + + var value: Value { + self.lock.withLock { self.storage } + } + + func read(_ body: (Value) -> Result) -> Result { + self.lock.withLock { body(self.storage) } + } + + func set(_ value: Value) { + self.lock.withLock { self.storage = value } + } + + func update(_ body: (inout Value) -> Void) { + self.lock.withLock { body(&self.storage) } + } } diff --git a/TestsLinux/CostUsageScanExecutorLinuxTests.swift b/TestsLinux/CostUsageScanExecutorLinuxTests.swift new file mode 100644 index 00000000..e286e535 --- /dev/null +++ b/TestsLinux/CostUsageScanExecutorLinuxTests.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct CostUsageScanExecutorLinuxTests { + @Test + func returnsWorkValue() async throws { + let value = try await CostUsageScanExecutor.run { _ in 42 } + #expect(value == 42) + } + + @Test + func cancelledTaskThrowsCancellationError() async { + let task = Task { + try await CostUsageScanExecutor.run { checkCancellation in + while true { + try checkCancellation() + Thread.sleep(forTimeInterval: 0.005) + } + } + } + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } +} From e7aa65290b6950ce4f4bf5888d96dae768fe1b8f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 04:53:02 +0100 Subject: [PATCH 35/51] test: isolate cost scan executor queues --- .../CodexBarCore/CostUsageScanExecutor.swift | 10 +++++++- .../CostUsageScanExecutorTests.swift | 23 +++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageScanExecutor.swift b/Sources/CodexBarCore/CostUsageScanExecutor.swift index 059240fe..aef8b2b7 100644 --- a/Sources/CodexBarCore/CostUsageScanExecutor.swift +++ b/Sources/CodexBarCore/CostUsageScanExecutor.swift @@ -102,6 +102,14 @@ public enum CostUsageScanExecutor { public static func run( _ work: @escaping @Sendable (_ checkCancellation: @escaping @Sendable () throws -> Void) throws -> T) async throws -> T + { + try await self.run(on: self.queue, work) + } + + static func run( + on queue: DispatchQueue, + _ work: @escaping @Sendable (_ checkCancellation: @escaping @Sendable () throws -> Void) throws -> T) + async throws -> T { let state = RunState() let checkCancellation: @Sendable () throws -> Void = { @@ -110,7 +118,7 @@ public enum CostUsageScanExecutor { return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in guard state.install(continuation) else { return } - self.queue.async { + queue.async { guard state.begin() else { return } state.complete(with: Result { try work(checkCancellation) }) } diff --git a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift index 078de479..8ea5a03b 100644 --- a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift +++ b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift @@ -5,17 +5,19 @@ import Testing struct CostUsageScanExecutorTests { @Test func `runs work on the dedicated scan queue and returns its value`() async throws { - let label = try await CostUsageScanExecutor.run { _ in + let queue = self.makeQueue() + let label = try await CostUsageScanExecutor.run(on: queue) { _ in String(cString: __dispatch_queue_get_label(nil)) } - #expect(label == CostUsageScanExecutor.queueLabel) + #expect(label == queue.label) } @Test func `propagates thrown errors`() async { struct ScanFailure: Error {} + let queue = self.makeQueue() await #expect(throws: ScanFailure.self) { - try await CostUsageScanExecutor.run { _ -> Int in + try await CostUsageScanExecutor.run(on: queue) { _ -> Int in throw ScanFailure() } } @@ -23,11 +25,12 @@ struct CostUsageScanExecutorTests { @Test func `serializes overlapping scans`() async throws { + let queue = self.makeQueue() let state = LockedValue((active: 0, maxActive: 0)) try await withThrowingTaskGroup(of: Void.self) { group in for _ in 0..<4 { group.addTask { - try await CostUsageScanExecutor.run { _ in + try await CostUsageScanExecutor.run(on: queue) { _ in state.update { $0.active += 1 $0.maxActive = max($0.maxActive, $0.active) @@ -44,9 +47,10 @@ struct CostUsageScanExecutorTests { @Test func `cancellation reaches in-flight work through checkCancellation`() async { + let queue = self.makeQueue() let workStarted = LockedValue(false) let task = Task { - try await CostUsageScanExecutor.run { checkCancellation in + try await CostUsageScanExecutor.run(on: queue) { checkCancellation in workStarted.set(true) while true { try checkCancellation() @@ -63,10 +67,11 @@ struct CostUsageScanExecutorTests { @Test func `work cancelled while queued resumes with CancellationError`() async { + let queue = self.makeQueue() let blockerStarted = LockedValue(false) let releaseBlocker = LockedValue(false) let blocker = Task { - try await CostUsageScanExecutor.run { _ in + try await CostUsageScanExecutor.run(on: queue) { _ in blockerStarted.set(true) while !releaseBlocker.value { Thread.sleep(forTimeInterval: 0.002) @@ -77,7 +82,7 @@ struct CostUsageScanExecutorTests { let queuedWorkStarted = LockedValue(false) let queued = Task { - try await CostUsageScanExecutor.run { _ in + try await CostUsageScanExecutor.run(on: queue) { _ in queuedWorkStarted.set(true) Issue.record("queued work should not run after cancellation") } @@ -107,6 +112,10 @@ struct CostUsageScanExecutorTests { _ = try? await blocker.value } + private func makeQueue() -> DispatchQueue { + DispatchQueue(label: "\(CostUsageScanExecutor.queueLabel).tests.\(UUID().uuidString)") + } + private func waitUntil( timeout: Duration = .seconds(1), condition: @escaping @Sendable () -> Bool) async -> Bool From 9d7967ea8a0f5dccb0cd6776f071517ec4d5f076 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 05:10:55 +0100 Subject: [PATCH 36/51] fix: keep Cost menu row width stable --- CHANGELOG.md | 1 + .../StatusItemController+CostMenuCard.swift | 62 +++++++++++++- .../CodexBar/StatusItemController+Menu.swift | 5 +- .../StatusMenuCostMenuCardTests.swift | 84 +++++++++++++++++++ .../StatusMenuHostedSubmenuRefreshTests.swift | 6 +- 5 files changed, 153 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 641c925a..56633ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localization: add Japanese as a selectable app language (#1385). Thanks @naoterumaker! ### Fixed +- Menu bar: keep large dynamic Cost totals inside the fixed-width hosted row so switching providers no longer widens the menu or misaligns submenu arrows. - Cost history: keep all per-day model breakdown rows available in a bounded scrolling detail area instead of hiding models after the first four (#1370). Thanks @MoollaMore! - Cost usage: run local session-corpus scans and cache decoding on a dedicated serial queue instead of the Swift cooperative thread pool, so multi-minute scans of large archives no longer starve the app's async work or freeze menus (#1387, #1392). Thanks @ProspectOre! - Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index a0bf4793..c84f296f 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -1,13 +1,73 @@ import AppKit +import SwiftUI + +private struct CostMenuCardRowView: View { + let title: String + let detailLines: [String] + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(self.title) + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + ForEach(self.detailLines.indices, id: \.self) { index in + Text(self.detailLines[index]) + .font(.system(size: NSFont.smallSystemFontSize)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + } + } + .padding(.leading, 14) + .padding(.trailing, 28) + .padding(.vertical, 6) + .frame(width: self.width, alignment: .leading) + } +} extension StatusItemController { static var costMenuTitle: String { L("Cost") } - func makeCostMenuCardItem(model: UsageMenuCardView.Model, submenu: NSMenu?) -> NSMenuItem { + func makeCostMenuCardItem( + model: UsageMenuCardView.Model, + submenu: NSMenu?, + width: CGFloat) -> NSMenuItem + { let tooltipLines = Self.costMenuTooltipLines(tokenUsage: model.tokenUsage) let visibleDetailLines = Self.costMenuVisibleDetailLines(tokenUsage: model.tokenUsage) + guard Self.menuCardRenderingEnabled else { + return Self.makeNativeCostMenuCardItem( + visibleDetailLines: visibleDetailLines, + tooltipLines: tooltipLines, + submenu: submenu) + } + + let item = self.makeMenuCardItem( + CostMenuCardRowView( + title: Self.costMenuTitle, + detailLines: visibleDetailLines, + width: width), + id: "menuCardCost", + width: width, + heightCacheScope: model.provider.rawValue, + heightCacheFingerprint: "costMenuRow:\(visibleDetailLines.count)", + submenu: submenu, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + item.title = Self.costMenuTitle + item.toolTip = tooltipLines.joined(separator: "\n") + return item + } + + private static func makeNativeCostMenuCardItem( + visibleDetailLines: [String], + tooltipLines: [String], + submenu: NSMenu?) -> NSMenuItem + { let item = NSMenuItem(title: Self.costMenuTitle, action: nil, keyEquivalent: "") item.isEnabled = true item.representedObject = "menuCardCost" diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index d1a491b7..dbc61ab6 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1326,7 +1326,10 @@ extension StatusItemController { } let costSubmenu = webItems.hasCostHistory ? self .makeCostHistorySubmenu(provider: provider, width: width) : nil - menu.addItem(self.makeCostMenuCardItem(model: model, submenu: costSubmenu)) + menu.addItem(self.makeCostMenuCardItem( + model: model, + submenu: costSubmenu, + width: width)) } } diff --git a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift index b1f73389..b09889fc 100644 --- a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift +++ b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift @@ -1,3 +1,6 @@ +import AppKit +import CodexBarCore +import SwiftUI import Testing @testable import CodexBar @@ -43,4 +46,85 @@ struct StatusMenuCostMenuCardTests { "Cost refresh failed.", ]) } + + @Test + func `rendered cost menu keeps long dynamic details inside fixed row width`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let width = StatusItemController.menuCardBaseWidth + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $227.42 - 267M tokens - " + String(repeating: "wide ", count: 20), + monthLine: "Last 30 days: $52,431.09 - 77B tokens - " + String(repeating: "wide ", count: 20), + hintLine: "Costs are estimated from local usage.", + errorLine: nil, + errorCopyText: nil) + let model = self.makeModel(tokenUsage: tokenUsage) + let submenu = NSMenu() + + let item = controller.makeCostMenuCardItem( + model: model, + submenu: submenu, + width: width) + let view = try #require(item.view) + + #expect(view is any MenuCardMeasuring) + #expect(abs(view.frame.width - width) <= 0.5) + #expect(item.title == "Cost") + #expect(item.toolTip?.contains("$52,431.09") == true) + #expect(item.submenu === submenu) + #expect(item.target === controller) + #expect(item.action.map(NSStringFromSelector) == "menuCardNoOp:") + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCostMenuCardTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeModel( + tokenUsage: UsageMenuCardView.Model.TokenUsageSection) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "user@example.com", + subtitleText: "Updated now", + subtitleStyle: .info, + planText: "Pro", + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: tokenUsage, + placeholder: nil, + progressColor: .blue) + } } diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 86ad13c1..b509d998 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -43,11 +43,11 @@ struct StatusMenuHostedSubmenuRefreshTests { controller.menuVersions[parentKey] = controller.menuContentVersion let costItem = try #require(menu.items.first { ($0.representedObject as? String) == "menuCardCost" }) - #expect(costItem.view == nil) + #expect(costItem.view is any MenuCardMeasuring) let submenu = try #require(costItem.submenu) let submenuAction = try #require(costItem.action) - #expect(NSStringFromSelector(submenuAction) == "submenuAction:") - #expect((costItem.target as? NSMenu) === submenu) + #expect(NSStringFromSelector(submenuAction) == "menuCardNoOp:") + #expect(costItem.target === controller) #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) #expect(submenu.items.first?.view == nil) From b4b1edcc17a3eeaf1930e08119455baca0f1468a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 06:03:00 +0100 Subject: [PATCH 37/51] chore: prepare 0.33.0 release --- CHANGELOG.md | 2 +- version.env | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56633ee4..4eef0d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.32.6 — Unreleased +## 0.33.0 — 2026-06-11 ### Added - Settings: choose Terminal.app or iTerm for Open Terminal actions, including Vertex AI login commands (#1225, fixes #1147). Thanks @Yuxin-Qiao! diff --git a/version.env b/version.env index 977410df..59084b97 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.32.6 +MARKETING_VERSION=0.33.0 BUILD_NUMBER=81 From 6d156e70c712d3a9942ff1f5d7b246fa4d045073 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 06:06:37 +0100 Subject: [PATCH 38/51] style: apply SwiftFormat to utilization history tests --- .../CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift index 3d41598d..8ee80c99 100644 --- a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift @@ -2,7 +2,6 @@ import Foundation import Testing @testable import CodexBar -@Suite struct PlanUtilizationHistoryChartMenuViewTests { @Test func `merged entries preserve first occurrence order while removing duplicates`() { From 6cf422512061924c5bae185fef5dd39194e0ac50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 06:35:12 +0100 Subject: [PATCH 39/51] docs: update appcast for 0.33.0 --- appcast.xml | 64 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/appcast.xml b/appcast.xml index af60a21d..c4825ded 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,50 @@ CodexBar + + 0.33.0 + Thu, 11 Jun 2026 06:35:11 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 81 + 0.33.0 + 14.0 + CodexBar 0.33.0 +

Added

+
    +
  • Settings: choose Terminal.app or iTerm for Open Terminal actions, including Vertex AI login commands (#1225, fixes #1147). Thanks @Yuxin-Qiao!
  • +
  • Localization: add Japanese as a selectable app language (#1385). Thanks @naoterumaker!
  • +
+

Fixed

+
    +
  • Menu bar: keep large dynamic Cost totals inside the fixed-width hosted row so switching providers no longer widens the menu or misaligns submenu arrows.
  • +
  • Cost history: keep all per-day model breakdown rows available in a bounded scrolling detail area instead of hiding models after the first four (#1370). Thanks @MoollaMore!
  • +
  • Cost usage: run local session-corpus scans and cache decoding on a dedicated serial queue instead of the Swift cooperative thread pool, so multi-minute scans of large archives no longer starve the app's async work or freeze menus (#1387, #1392). Thanks @ProspectOre!
  • +
  • Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta!
  • +
  • Security: block credentialed provider redirects that leave the original HTTPS origin while preserving same-origin redirects (#1237). Thanks @Hinotoi-agent!
  • +
  • Codex: keep local token and cost history visible when remote quota data is unavailable (#1390). Thanks @vaibhavarora14!
  • +
  • Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra!
  • +
  • Menu bar: defer pasteboard writes and copy feedback outside the NSMenu tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405!
  • +
  • Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7!
  • +
  • Provider status: decode status feeds on the concurrent executor and reuse ISO8601 formatters, removing a measured main-thread stall during refreshes (#1406). Thanks @ProspectOre!
  • +
  • Menu bar: keep one stable width across merged provider tabs and resize every hosted card row to AppKit's final menu width so provider switching no longer leaves a widened menu with inset submenu arrows (#1410).
  • +
  • Menu bar: keep Codex auth.json reads, JWT parsing, and fingerprint hashing off the menu-build path by rendering a cached account snapshot and revalidating it asynchronously (#1401). Thanks @ProspectOre!
  • +
  • Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325).
  • +
  • Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial!
  • +
  • Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl!
  • +
  • Menu bar: gate the provider-switcher shortcut monitor's event-queue peek behind session event counters so hover-driven menu tracking no longer calls NSApp.nextEvent on every run-loop pass (#1397). Thanks @bcssewl!
  • +
  • Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao!
  • +
  • Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner!
  • +
  • Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore!
  • +
  • Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405!
  • +
  • Menu: reuse unchanged hosted chart submenus and precompute utilization history models to reduce expand and hover stalls (#1379). Thanks @hhh2210!
  • +
  • Menu bar: defer data-refresh rebuilds until the tracked menu closes, avoiding multi-second WindowServer stalls with slower providers such as Grok (#1376). Thanks @jangisaac-dev!
  • +
  • OpenAI Web: evict cached dashboard WebViews after their idle timeout even when no later cache activity occurs, releasing hidden WebKit helper processes (#1386). Thanks @naoterumaker!
  • +
  • Xiaomi MiMo: import automatic session cookies from Safari, Chrome variants, Firefox, and Edge instead of limiting discovery to Chrome (#1304). Thanks @Yuxin-Qiao!
  • +
+

View full changelog

+]]>
+ +
0.32.5 Tue, 09 Jun 2026 08:30:33 +0100 @@ -61,26 +105,6 @@ ]]> - - 0.32.3 - Tue, 02 Jun 2026 12:30:56 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 78 - 0.32.3 - 14.0 - CodexBar 0.32.3 -

Fixed

-
    -
  • Menu bar: stop forcing a private preferred-position value for fresh status items; suspicious stored positions are now cleared so AppKit can place CodexBar normally on macOS 26 / 5K displays (#1267). Thanks @AdrianSimionov, @kirocop, and @Yuxin-Qiao!
  • -
  • Menu bar: cache provider brand icons so merged-icon status updates no longer repeatedly parse SVG assets on the main thread during hover/open animations (#1235, #1274). Thanks @andradebruno, @xingpz2008, and @Yuxin-Qiao!
  • -
  • Copilot: treat GitHub Copilot Business token-billing zero-entitlement quotas as unavailable instead of showing misleading 0% used usage (#1258, #1270). Thanks @devYRPauli!
  • -
  • Menu bar: prepare closed menus after refresh and only reuse stale dropdown content for data-refresh invalidations so merged menu opens stay responsive without bypassing privacy or structure changes (#1261). Thanks @ProspectOre!
  • -
  • OpenAI Web: stop reloading away from login and Cloudflare blocking states so the dashboard WebView does not loop on route corrections (#1259). Thanks @ProspectOre!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 2b76dc9b89a1aca3a1a1916dfd432d25972215fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 06:44:14 +0100 Subject: [PATCH 40/51] chore: start 0.33.1 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eef0d09..2221245d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.33.1 — Unreleased + ## 0.33.0 — 2026-06-11 ### Added diff --git a/version.env b/version.env index 59084b97..6e2aa055 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.33.0 -BUILD_NUMBER=81 +MARKETING_VERSION=0.33.1 +BUILD_NUMBER=82 From 9015e94901c40c3293c43f49e81eee7e26f94955 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 06:58:03 +0100 Subject: [PATCH 41/51] docs: document Sparkle release key --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b5f6f49d..2d9f6313 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ - Run `./Scripts/compile_and_run.sh` only when UI/runtime behavior needs bundle-level validation; it builds, tests, packages, relaunches, and verifies the app stays running. - Widget/Tahoe UI issues: use Parallels macOS VM plus screenshots/clicks for autonomous verification. - Release script: keep it in the foreground; do not background it—wait until it finishes. -- Release keys: find in `~/.profile` if missing (Sparkle + App Store Connect). +- Sparkle release key: use `.mac-release.env` `MAC_RELEASE_SIGNING_KEY_FILE`, the legacy `AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=` key. Do not use `sparkle-private-key-KEEP-SECURE.txt`; that is VibeTunnel's mismatched key. - Swift concurrency: treat sibling `async let` tasks as a review red flag when one child is required and another is optional/best-effort. Prefer sequential awaits or a drained `withThrowingTaskGroup` that surfaces required failures and explicitly contains optional failures; crash stacks mentioning `swift_task_dealloc` or `asyncLet_finish_after_task_completion` should trigger an audit of nearby `async let` usage. - Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`. - Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.). From be4818c68c1af34d6e56428d889f7754374c0f40 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:37:01 -0700 Subject: [PATCH 42/51] feat: add Devin usage provider (#1264) Adds browser-backed Devin quota usage with authenticated Chrome session import, organization discovery, settings integration, and regression coverage. Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com> --- CHANGELOG.md | 3 + README.md | 1 + .../Devin/DevinProviderImplementation.swift | 123 +++++ .../Providers/Devin/DevinSettingsStore.swift | 43 ++ .../ProviderImplementationRegistry.swift | 1 + .../CodexBar/Resources/ProviderIcon-devin.svg | 3 + Sources/CodexBar/UsageStore.swift | 2 +- .../Config/CodexBarConfigValidation.swift | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Devin/DevinProviderDescriptor.swift | 93 ++++ .../Devin/DevinSessionImporter.swift | 459 ++++++++++++++++++ .../Providers/Devin/DevinUsageFetcher.swift | 271 +++++++++++ .../Providers/Devin/DevinUsageSnapshot.swift | 320 ++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 21 + .../CodexBarCore/Providers/Providers.swift | 12 + .../Vendored/CostUsage/CostUsageScanner.swift | 8 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../DevinUsageFetcherTests.swift | 392 +++++++++++++++ .../ProviderIconResourcesTests.swift | 1 + .../ProviderSettingsDescriptorTests.swift | 14 + .../project.pbxproj | 4 +- docs/devin.md | 43 ++ docs/providers.md | 10 +- 26 files changed, 1824 insertions(+), 9 deletions(-) create mode 100644 Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-devin.svg create mode 100644 Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Devin/DevinSessionImporter.swift create mode 100644 Sources/CodexBarCore/Providers/Devin/DevinUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/DevinUsageFetcherTests.swift create mode 100644 docs/devin.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2221245d..a1738643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.33.1 — Unreleased +### Added +- Devin: add daily and weekly quota tracking from the signed-in Chrome session or a manual Bearer token (#1264, fixes #800). Thanks @coygeek! + ## 0.33.0 — 2026-06-11 ### Added diff --git a/README.md b/README.md index 407f3c4b..9be3ed5f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth. - [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing. - [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API. +- [Devin](docs/devin.md) — Chrome localStorage session or manual Bearer token for daily and weekly quotas. - [z.ai](docs/zai.md) — API token for quota + MCP windows. - [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking. - [MiniMax](docs/minimax.md) — API token, cookie header, or browser cookies for coding-plan usage. diff --git a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift new file mode 100644 index 00000000..2ebb5003 --- /dev/null +++ b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift @@ -0,0 +1,123 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct DevinProviderImplementation: ProviderImplementation { + let id: UsageProvider = .devin + let supportsLoginFlow: Bool = true + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.devinCookieSource + _ = settings.devinBearerToken + _ = settings.devinOrganization + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .devin(context.settings.devinSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.devinCookieSource.rawValue }, + set: { raw in + context.settings.devinCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.devinCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports the app.devin.ai session from Chrome.", + manual: "Paste an Authorization Bearer token from app.devin.ai.", + off: "Paste an Authorization Bearer token from app.devin.ai.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "devin-cookie-source", + title: "Auth source", + subtitle: "Automatically imports the app.devin.ai session from Chrome.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "devin-organization", + title: "Organization", + subtitle: "Optional. Use the slug from app.devin.ai/org/, or paste the full Devin org URL.", + kind: .plain, + placeholder: "org/example-org", + binding: context.stringBinding(\.devinOrganization), + actions: [ + ProviderSettingsActionDescriptor( + id: "devin-open-usage", + title: "Open Devin Usage", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(Self.usageURL(organization: context.settings.devinOrganization)) + }), + ], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "devin-bearer-token", + title: "Bearer token", + subtitle: "Paste the Authorization header value from app.devin.ai.", + kind: .secure, + placeholder: "Bearer eyJ...", + binding: context.stringBinding(\.devinBearerToken), + actions: [], + isVisible: { context.settings.devinCookieSource == .manual }, + onActivate: nil), + ] + } + + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Open Devin...", .loginToProvider(url: Self.usageURL(organization: nil).absoluteString)) + } + + @MainActor + func runLoginFlow(context: ProviderLoginContext) async -> Bool { + let organization = context.controller.settings.devinOrganization + NSWorkspace.shared.open(Self.usageURL(organization: organization)) + return false + } + + private static func usageURL(organization: String?) -> URL { + let normalized = DevinUsageFetcher.normalizedOrganization(organization) + let urlString: String + if let normalized, normalized.hasPrefix("org/") { + let slug = String(normalized.dropFirst(4)) + urlString = "https://app.devin.ai/org/\(slug)/settings/usage" + } else { + urlString = "https://app.devin.ai/settings/usage" + } + return URL(string: urlString) ?? URL(string: "https://app.devin.ai")! + } +} diff --git a/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift b/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift new file mode 100644 index 00000000..430f4404 --- /dev/null +++ b/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift @@ -0,0 +1,43 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var devinBearerToken: String { + get { self.configSnapshot.providerConfig(for: .devin)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .devin, field: "cookieHeader", value: newValue) + } + } + + var devinCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .devin, fallback: .auto) } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .devin, field: "cookieSource", value: newValue.rawValue) + } + } + + var devinOrganization: String { + get { self.configSnapshot.providerConfig(for: .devin)?.sanitizedWorkspaceID ?? "" } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + } + } +} + +extension SettingsStore { + func devinSettingsSnapshot(tokenOverride _: TokenAccountOverride?) -> ProviderSettingsSnapshot + .DevinProviderSettings { + ProviderSettingsSnapshot.DevinProviderSettings( + cookieSource: self.devinCookieSource, + manualBearerToken: self.devinBearerToken, + organization: self.devinOrganization) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index ef97d3f0..41d6102c 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -26,6 +26,7 @@ enum ProviderImplementationRegistry { case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() case .copilot: CopilotProviderImplementation() + case .devin: DevinProviderImplementation() case .zai: ZaiProviderImplementation() case .minimax: MiniMaxProviderImplementation() case .manus: ManusProviderImplementation() diff --git a/Sources/CodexBar/Resources/ProviderIcon-devin.svg b/Sources/CodexBar/Resources/ProviderIcon-devin.svg new file mode 100644 index 00000000..e2b1cd5f --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-devin.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e3ea41a7..b16e3f65 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1141,7 +1141,7 @@ extension UsageStore { configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) - case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, + case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .deepgram: diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index dd692011..c1a1758e 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -33,6 +33,7 @@ public enum CodexBarConfigValidator { .openai, .opencode, .opencodego, + .devin, .deepgram, ] diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 7cbc1d12..d38cce4b 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "6162cc6387d12e86" + static let value = "3c27f997569eb3c5" } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 2119ec26..5fcd2cdc 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -26,6 +26,7 @@ public enum LogCategories { public static let deepSeekSettings = "deepseek-settings" public static let deepSeekUsage = "deepseek-usage" public static let deepgramUsage = "deepgram-usage" + public static let devin = "devin" public static let doubaoUsage = "doubao-usage" public static let elevenLabsUsage = "elevenlabs-usage" public static let geminiProbe = "gemini-probe" diff --git a/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift new file mode 100644 index 00000000..121d22ba --- /dev/null +++ b/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift @@ -0,0 +1,93 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum DevinProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .devin, + metadata: ProviderMetadata( + id: .devin, + displayName: "Devin", + sessionLabel: "Daily", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Devin usage", + cliName: "devin", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.devinCookieImportOrder, + dashboardURL: "https://app.devin.ai", + subscriptionDashboardURL: "https://app.devin.ai/settings/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .devin, + iconResourceName: "ProviderIcon-devin", + color: ProviderColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Devin cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DevinWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "devin", + versionDetector: nil)) + } +} + +struct DevinWebFetchStrategy: ProviderFetchStrategy { + let id: String = "devin.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let settings = context.settings?.devin + let source = settings?.cookieSource ?? .auto + guard source != .off else { return false } + if source == .manual { + return DevinUsageFetcher.manualAuth(from: Self.bearerTokenOverride(context: context)) != nil + } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = DevinUsageFetcher(browserDetection: context.browserDetection) + let settings = context.settings?.devin + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.devin).verbose(msg) } + : nil + let snapshot = try await fetcher.fetch( + bearerTokenOverride: settings?.cookieSource == .manual ? Self.bearerTokenOverride(context: context) : nil, + organizationOverride: Self.organizationOverride(context: context), + timeout: context.webTimeout, + logger: logger) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func bearerTokenOverride(context: ProviderFetchContext) -> String? { + context.env["DEVIN_BEARER_TOKEN"] + ?? context.env["DEVIN_AUTHORIZATION"] + ?? context.settings?.devin?.manualBearerToken + } + + private static func organizationOverride(context: ProviderFetchContext) -> String? { + context.env["DEVIN_ORGANIZATION"] + ?? context.env["DEVIN_ORG"] + ?? context.settings?.devin?.organization + } +} diff --git a/Sources/CodexBarCore/Providers/Devin/DevinSessionImporter.swift b/Sources/CodexBarCore/Providers/Devin/DevinSessionImporter.swift new file mode 100644 index 00000000..102ed657 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Devin/DevinSessionImporter.swift @@ -0,0 +1,459 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +enum DevinSessionImporter { + nonisolated(unsafe) static var importSessionOverrideForTesting: + ((BrowserDetection, String?, ((String) -> Void)?) -> SessionInfo?)? + + private static let storageOrigin = "https://app.devin.ai" + private static let externalOrgPrefix = "last-internal-org-for-external-org-v1-" + + struct SessionInfo: Equatable { + let accessToken: String + let organization: String? + let internalOrganizationID: String? + let sourceLabel: String + } + + struct LocalStorageCandidate { + let label: String + let url: URL + } + + static func importSession( + browserDetection: BrowserDetection, + organizationOverride: String? = nil, + logger: ((String) -> Void)? = nil) -> SessionInfo? + { + if let override = self.importSessionOverrideForTesting { + return override(browserDetection, organizationOverride, logger) + } + + let sessions = self.importSessions( + browserDetection: browserDetection, + organizationOverride: organizationOverride, + logger: logger) + return sessions.first + } + + static func importSessions( + browserDetection: BrowserDetection, + organizationOverride: String? = nil, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importSessionOverrideForTesting { + return override(browserDetection, organizationOverride, logger).map { [$0] } ?? [] + } + + let log: (String) -> Void = { msg in logger?("[devin-storage] \(msg)") } + let candidates = self.chromeLocalStorageCandidates(browserDetection: browserDetection) + if !candidates.isEmpty { + log("Chrome local storage candidates: \(candidates.count)") + } + + var sessions: [SessionInfo] = [] + for candidate in candidates { + let storage = self.readLocalStorage(from: candidate.url, logger: log) + guard let session = self.session( + from: storage, + organizationOverride: organizationOverride, + sourceLabel: candidate.label) + else { + continue + } + log( + "Found Devin session in \(candidate.label); " + + "organization=\(session.organization != nil), internalOrganizationID=" + + "\(session.internalOrganizationID != nil)") + sessions.append(session) + } + sessions = self.rankSessions(self.deduplicateSessions(sessions)) + + if sessions.isEmpty { + log("No Devin session found in browser local storage") + } + return sessions + } + + static func session( + from storage: [String: String], + organizationOverride: String? = nil, + sourceLabel: String) -> SessionInfo? + { + guard let accessToken = self.accessToken(from: storage) else { + return nil + } + let organizationInfo = self.organizationInfo(from: storage, organizationOverride: organizationOverride) + return SessionInfo( + accessToken: accessToken, + organization: organizationInfo.organization, + internalOrganizationID: organizationInfo.internalOrganizationID, + sourceLabel: sourceLabel) + } + + static func accessToken(from storage: [String: String]) -> String? { + for (key, value) in storage where self.isAuth1StorageKey(key) { + guard let json = self.jsonObject(from: value), + let token = self.findAuth1Token(in: json) + else { + continue + } + return token + } + + for (key, value) in storage where self.isAuth0StorageKey(key) { + guard let json = self.jsonObject(from: value), + let token = self.findAccessToken(in: json) + else { + continue + } + return token + } + + for value in storage.values { + guard let json = self.jsonObject(from: value), + let token = self.findAccessToken(in: json) + else { + continue + } + return token + } + + return nil + } + + static func deduplicateSessions(_ sessions: [SessionInfo]) -> [SessionInfo] { + var order: [String] = [] + var bestByToken: [String: SessionInfo] = [:] + for session in sessions { + if let existing = bestByToken[session.accessToken] { + if self.organizationScore(session) > self.organizationScore(existing) { + bestByToken[session.accessToken] = session + } + } else { + order.append(session.accessToken) + bestByToken[session.accessToken] = session + } + } + return order.compactMap { bestByToken[$0] } + } + + static func rankSessions(_ sessions: [SessionInfo]) -> [SessionInfo] { + sessions.enumerated() + .sorted { lhs, rhs in + let lhsScore = self.organizationScore(lhs.element) + let rhsScore = self.organizationScore(rhs.element) + return lhsScore == rhsScore ? lhs.offset < rhs.offset : lhsScore > rhsScore + } + .map(\.element) + } + + private static func organizationScore(_ session: SessionInfo) -> Int { + (session.organization == nil ? 0 : 1) + (session.internalOrganizationID == nil ? 0 : 2) + } + + static func organizationInfo( + from storage: [String: String], + organizationOverride: String?) -> (organization: String?, internalOrganizationID: String?) + { + let override = DevinUsageFetcher.normalizedOrganization(organizationOverride) + let overrideSlug = override.flatMap(self.slug(fromNormalizedOrganization:)) + var firstInternalOrgID: String? + + for (key, value) in storage where self.isExternalOrgStorageKey(key) { + let suffix = self.externalOrgSlug(from: key) + let orgID = self.cleanedOrgID(value) + if firstInternalOrgID == nil { + firstInternalOrgID = orgID + } + if let overrideSlug, suffix == overrideSlug { + return (override, orgID) + } + if override == nil, suffix != "null" { + return ("org/\(suffix)", orgID) + } + } + + if let inferred = self.inferredOrganizationInfo(from: storage, override: override) { + return inferred + } + + if let override { + return (override, firstInternalOrgID ?? self.orgID(fromNormalizedOrganization: override)) + } + + return (firstInternalOrgID.map { "organizations/\($0)" }, firstInternalOrgID) + } + + static func decodedStorageValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if let data = trimmed.data(using: .utf8), + let decoded = try? JSONDecoder().decode(String.self, from: data) + { + return decoded.trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func chromeLocalStorageCandidates(browserDetection: BrowserDetection) -> [LocalStorageCandidate] { + let installedBrowsers = self.localStorageBrowsers(browserDetection: browserDetection) + let roots = ChromiumProfileLocator + .roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories()) + .map { (url: $0.url, labelPrefix: $0.labelPrefix) } + + var candidates: [LocalStorageCandidate] = [] + for root in roots { + candidates.append(contentsOf: self.chromeProfileLocalStorageDirs( + root: root.url, + labelPrefix: root.labelPrefix)) + } + return candidates + } + + static func localStorageBrowsers(browserDetection: BrowserDetection) -> [Browser] { + let order = ProviderDefaults.metadata[.devin]?.browserCookieOrder ?? [.chrome] + return order.browsersWithProfileData(using: browserDetection) + } + + private static func chromeProfileLocalStorageDirs(root: URL, labelPrefix: String) -> [LocalStorageCandidate] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + + return entries.filter { url in + guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else { + return false + } + let name = url.lastPathComponent + return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .compactMap { dir in + let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb") + guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil } + return LocalStorageCandidate(label: "\(labelPrefix) \(dir.lastPathComponent)", url: levelDBURL) + } + } + + private static func readLocalStorage(from levelDBURL: URL, logger: ((String) -> Void)?) -> [String: String] { + var storage: [String: String] = [:] + let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( + for: self.storageOrigin, + in: levelDBURL, + logger: logger) + for entry in entries { + storage[entry.key] = self.decodedStorageValue(entry.value) + } + + let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries( + in: levelDBURL, + logger: logger) + for entry in textEntries where storage[entry.key] == nil { + if self.isUsefulStorageKey(entry.key) { + storage[entry.key] = self.decodedStorageValue(entry.value) + } + } + + return storage + } + + private static func jsonObject(from raw: String) -> Any? { + guard let data = raw.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) + } + + private static func findAuth1Token(in object: Any) -> String? { + guard let dictionary = object as? [String: Any], + let token = dictionary["token"] as? String + else { + return nil + } + let value = token.trimmingCharacters(in: .whitespacesAndNewlines) + return value.hasPrefix("auth1_") && value.count > 20 ? value : nil + } + + private static func findAccessToken(in object: Any) -> String? { + if let dictionary = object as? [String: Any] { + for key in ["access_token", "accessToken"] { + if let value = dictionary[key] as? String, + self.looksLikeToken(value) + { + return value + } + } + for value in dictionary.values { + if let found = self.findAccessToken(in: value) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.findAccessToken(in: value) { + return found + } + } + } + + return nil + } + + private static func looksLikeToken(_ raw: String) -> Bool { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return value.count > 20 && (value.hasPrefix("eyJ") || value.contains(".")) + } + + private static func isAuth1StorageKey(_ key: String) -> Bool { + key.hasSuffix("auth1_session") + } + + private static func isAuth0StorageKey(_ key: String) -> Bool { + key.contains("auth0spajs@@::") + } + + private static func isExternalOrgStorageKey(_ key: String) -> Bool { + key.contains(self.externalOrgPrefix) + } + + private static func isUsefulStorageKey(_ key: String) -> Bool { + self.isAuth1StorageKey(key) || + self.isAuth0StorageKey(key) || + self.isExternalOrgStorageKey(key) || + key.contains("post-auth-v") || + key.contains("member-info-v") || + key.contains("feature-flags-cache:org-") || + key.contains("feature-flags-cache:org_") + } + + private static func inferredOrganizationInfo( + from storage: [String: String], + override: String?) -> (organization: String?, internalOrganizationID: String?)? + { + let overrideSlug = override.flatMap(self.slug(fromNormalizedOrganization:)) + let overrideOrgID = override.flatMap(self.orgID(fromNormalizedOrganization:)) + var fallbackSlug: String? + var fallbackInternalOrgID: String? + + for (key, value) in storage { + let object = self.jsonObject(from: value) + let internalOrgID = self.cleanedOrgID(self.firstString( + in: object, + matching: ["internalOrgId", "internal_org_id", "org_id", "orgId"])) + ?? self.internalOrgIDFromStorageKey(key) + let slug = self.cleanedSlug( + self.slugFromPostAuthKey(key) ?? + self.firstString(in: object, matching: ["orgName", "org_name", "externalOrgId", "external_org_id"])) + + if let overrideOrgID, internalOrgID == overrideOrgID { + return (override, internalOrgID) + } + if let overrideSlug, slug == overrideSlug { + return (override, internalOrgID) + } + + if fallbackSlug == nil, let slug { + fallbackSlug = slug + } + if fallbackInternalOrgID == nil, let internalOrgID { + fallbackInternalOrgID = internalOrgID + } + } + + if let override, fallbackInternalOrgID != nil { + return (override, fallbackInternalOrgID) + } + + if let fallbackSlug { + return ("org/\(fallbackSlug)", fallbackInternalOrgID) + } + if let fallbackInternalOrgID { + return ("organizations/\(fallbackInternalOrgID)", fallbackInternalOrgID) + } + + return nil + } + + private static func externalOrgSlug(from key: String) -> String { + guard let range = key.range(of: self.externalOrgPrefix) else { return key } + return String(key[range.upperBound...]) + } + + private static func cleanedOrgID(_ raw: String) -> String? { + let value = self.decodedStorageValue(raw) + guard DevinUsageFetcher.isInternalOrganizationID(value) else { return nil } + return value + } + + private static func cleanedOrgID(_ raw: String?) -> String? { + guard let raw else { return nil } + return self.cleanedOrgID(raw) + } + + private static func cleanedSlug(_ raw: String?) -> String? { + guard let raw else { return nil } + let value = self.decodedStorageValue(raw) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value != "null", !DevinUsageFetcher.isInternalOrganizationID(value) else { + return nil + } + if value.hasPrefix("org/") { + return String(value.dropFirst(4)) + } + return value + } + + private static func slugFromPostAuthKey(_ key: String) -> String? { + guard let range = key.range(of: "-org_name-") else { return nil } + return String(key[range.upperBound...]) + } + + private static func internalOrgIDFromStorageKey(_ key: String) -> String? { + guard let range = key.range(of: #"org[-_][A-Za-z0-9]{8,}"#, options: .regularExpression) else { + return nil + } + return self.cleanedOrgID(String(key[range])) + } + + private static func firstString(in object: Any?, matching keys: Set) -> String? { + if let dictionary = object as? [String: Any] { + for (key, value) in dictionary { + if keys.contains(key), let string = value as? String, !string.isEmpty { + return string + } + if let found = self.firstString(in: value, matching: keys) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.firstString(in: value, matching: keys) { + return found + } + } + } + + return nil + } + + private static func slug(fromNormalizedOrganization organization: String) -> String? { + guard organization.hasPrefix("org/") else { return nil } + return String(organization.dropFirst(4)) + } + + private static func orgID(fromNormalizedOrganization organization: String) -> String? { + guard organization.hasPrefix("organizations/") else { return nil } + return String(organization.dropFirst("organizations/".count)) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Devin/DevinUsageFetcher.swift b/Sources/CodexBarCore/Providers/Devin/DevinUsageFetcher.swift new file mode 100644 index 00000000..5ae76578 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Devin/DevinUsageFetcher.swift @@ -0,0 +1,271 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct DevinUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.devin) + private static let baseURL = URL(string: "https://app.devin.ai")! + private static let defaultUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + public struct RequestAuth: Sendable, Equatable { + public let bearerToken: String + public let organization: String? + public let internalOrganizationID: String? + public let sourceLabel: String + + public init( + bearerToken: String, + organization: String?, + internalOrganizationID: String?, + sourceLabel: String) + { + self.bearerToken = bearerToken + self.organization = organization + self.internalOrganizationID = internalOrganizationID + self.sourceLabel = sourceLabel + } + } + + public let browserDetection: BrowserDetection + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + } + + public func fetch( + bearerTokenOverride: String? = nil, + organizationOverride: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DevinUsageSnapshot + { + let auths = try self.resolveAuths( + bearerTokenOverride: bearerTokenOverride, + organizationOverride: organizationOverride, + logger: logger) + var lastError: Error? + for auth in auths { + do { + return try await Self.fetchQuotaUsage( + auth: auth, + organizationOverride: organizationOverride, + timeout: timeout, + logger: logger, + now: now, + transport: transport) + } catch { + lastError = error + logger?("[devin] Session from \(auth.sourceLabel) failed: \(error.localizedDescription)") + if auth.sourceLabel == "manual" || !Self.shouldTryNextSession(after: error) { + throw error + } + } + } + throw lastError ?? DevinUsageError.noSession + } + + public static func fetchQuotaUsage( + auth: RequestAuth, + organizationOverride: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DevinUsageSnapshot + { + let organization = self.normalizedOrganization(organizationOverride) ?? + self.normalizedOrganization(auth.organization) + guard let organization else { + throw DevinUsageError.missingOrganization + } + + var lastError: Error? + for path in self.candidatePaths( + organization: organization, + internalOrganizationID: auth.internalOrganizationID) + { + let data: Data + do { + data = try await self.fetch( + path: path, + auth: auth, + timeout: timeout, + transport: transport) + } catch { + lastError = error + logger?("[devin] /api/\(path) failed: \(error.localizedDescription)") + if case DevinUsageError.invalidCredentials = error { + throw error + } + continue + } + logger?("[devin] Fetched quota usage from /api/\(path)") + return try DevinUsageParser.parse(data, organization: organization, now: now) + } + + throw lastError ?? DevinUsageError.apiError("No Devin quota endpoint succeeded.") + } + + public static func manualAuth(from raw: String?, organization: String? = nil) -> RequestAuth? { + guard var token = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { + return nil + } + if token.lowercased().hasPrefix("authorization:") { + token = token.dropHeaderName().trimmingCharacters(in: .whitespacesAndNewlines) + } + if token.lowercased().hasPrefix("bearer ") { + token = String(token.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines) + } + guard !token.isEmpty else { return nil } + return RequestAuth( + bearerToken: token, + organization: self.normalizedOrganization(organization), + internalOrganizationID: self.internalOrganizationID(from: organization), + sourceLabel: "manual") + } + + private func resolveAuths( + bearerTokenOverride: String?, + organizationOverride: String?, + logger: ((String) -> Void)?) throws -> [RequestAuth] + { + if let manual = Self.manualAuth(from: bearerTokenOverride, organization: organizationOverride) { + logger?("[devin] Using manual Bearer token") + return [manual] + } + + #if os(macOS) + let normalizedOrganizationOverride = Self.normalizedOrganization(organizationOverride) + let sessions = DevinSessionImporter.importSessions( + browserDetection: self.browserDetection, + organizationOverride: normalizedOrganizationOverride, + logger: logger) + guard !sessions.isEmpty else { + throw DevinUsageError.noSession + } + logger?("[devin] Found \(sessions.count) browser session(s)") + return sessions.map { session in + RequestAuth( + bearerToken: session.accessToken, + organization: normalizedOrganizationOverride ?? Self.normalizedOrganization(session.organization), + internalOrganizationID: session.internalOrganizationID, + sourceLabel: session.sourceLabel) + } + #else + throw DevinUsageError.noSession + #endif + } + + static func shouldTryNextSession(after error: Error) -> Bool { + switch error { + case DevinUsageError.invalidCredentials, DevinUsageError.apiError, DevinUsageError.missingOrganization: + true + default: + false + } + } + + private static func fetch( + path: String, + auth: RequestAuth, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> Data + { + let url = self.baseURL.appending(path: "api/\(path)") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.defaultUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(auth.bearerToken)", forHTTPHeaderField: "Authorization") + if let internalOrganizationID = auth.internalOrganizationID { + request.setValue(internalOrganizationID, forHTTPHeaderField: "x-cog-org-id") + } + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + let body = String(data: response.data.prefix(200), encoding: .utf8) ?? "" + if response.statusCode == 401 || response.statusCode == 403 { + throw DevinUsageError.invalidCredentials + } + Self.log.error("Devin API returned \(response.statusCode): \(body)") + throw DevinUsageError.apiError("HTTP \(response.statusCode)") + } + return response.data + } + + private static func candidatePaths(organization: String, internalOrganizationID: String?) -> [String] { + var paths: [String] = [] + let normalized = self.normalizedOrganization(organization) ?? organization + if let internalOrganizationID { + paths.append("\(internalOrganizationID)/billing/quota/usage") + } + paths.append("\(normalized)/billing/quota/usage") + if normalized.hasPrefix("org/") { + let slug = String(normalized.dropFirst(4)) + paths.append("\(slug)/billing/quota/usage") + } + if !normalized.hasPrefix("org/"), !normalized.hasPrefix("organizations/") { + paths.append("org/\(normalized)/billing/quota/usage") + } + if let internalOrganizationID { + paths.append("organizations/\(internalOrganizationID)/billing/quota/usage") + } + return paths.removingDuplicates() + } + + public static func normalizedOrganization(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if let url = URL(string: value), + let host = url.host?.lowercased(), + host == "devin.ai" || host.hasSuffix(".devin.ai") + { + let components = url.path.split(separator: "/").map(String.init) + if components.count >= 2, components[0] == "org" { + value = "org/\(components[1])" + } else if components.count >= 2, components[0] == "organizations" { + value = "organizations/\(components[1])" + } + } + value = value.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if value.hasPrefix("org/") || value.hasPrefix("organizations/") { + return value + } + if self.isInternalOrganizationID(value) { + return "organizations/\(value)" + } + return "org/\(value)" + } + + private static func internalOrganizationID(from raw: String?) -> String? { + guard let normalized = self.normalizedOrganization(raw), + normalized.hasPrefix("organizations/") + else { + return nil + } + return String(normalized.dropFirst("organizations/".count)) + } + + static func isInternalOrganizationID(_ value: String) -> Bool { + value.hasPrefix("org-") || value.hasPrefix("org_") + } +} + +extension String { + fileprivate func dropHeaderName() -> String { + guard let index = self.firstIndex(of: ":") else { return self } + return String(self[self.index(after: index)...]) + } +} + +extension [String] { + fileprivate func removingDuplicates() -> [String] { + var seen = Set() + return self.filter { seen.insert($0).inserted } + } +} diff --git a/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift new file mode 100644 index 00000000..53bbcc50 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift @@ -0,0 +1,320 @@ +import CoreFoundation +import Foundation + +public enum DevinUsageError: LocalizedError, Sendable { + case noSession + case missingOrganization + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .noSession: + "No Devin browser session found. Please log in to app.devin.ai or paste a Bearer token." + case .missingOrganization: + "No Devin organization was found. Open an app.devin.ai/org/... page " + + "or set the organization in Devin settings." + case .invalidCredentials: + "Devin session token is invalid or expired." + case let .apiError(message): + "Devin API error: \(message)" + case let .parseFailed(message): + "Could not parse Devin usage: \(message)" + } + } +} + +public struct DevinQuotaWindow: Sendable, Equatable { + public let usedPercent: Double + public let resetsAt: Date? + + public init(usedPercent: Double, resetsAt: Date? = nil) { + self.usedPercent = min(100, max(0, usedPercent)) + self.resetsAt = resetsAt + } +} + +public struct DevinUsageSnapshot: Sendable, Equatable { + public let daily: DevinQuotaWindow? + public let weekly: DevinQuotaWindow? + public let planName: String? + public let organization: String? + public let updatedAt: Date + + public init( + daily: DevinQuotaWindow?, + weekly: DevinQuotaWindow?, + planName: String?, + organization: String?, + updatedAt: Date) + { + self.daily = daily + self.weekly = weekly + self.planName = planName + self.organization = organization + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = self.daily.map { + RateWindow( + usedPercent: $0.usedPercent, + windowMinutes: 24 * 60, + resetsAt: $0.resetsAt, + resetDescription: "Daily") + } + let secondary = self.weekly.map { + RateWindow( + usedPercent: $0.usedPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: $0.resetsAt, + resetDescription: "Weekly") + } + let identity = ProviderIdentitySnapshot( + providerID: .devin, + accountEmail: nil, + accountOrganization: self.organization, + loginMethod: self.planName) + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: self.updatedAt, + identity: identity) + } +} + +public enum DevinUsageParser { + public static func parse(_ data: Data, organization: String?, now: Date = Date()) throws -> DevinUsageSnapshot { + let object = try JSONSerialization.jsonObject(with: data) + return try self.parse(object, organization: organization, now: now) + } + + public static func parse(_ object: Any, organization: String?, now: Date = Date()) throws -> DevinUsageSnapshot { + let current = (object as? [String: Any]).map(self.currentQuotaWindows) + let daily = current?.daily ?? self.findWindow(in: object, matching: self.isDailyKey) + let weekly = current?.weekly ?? self.findWindow(in: object, matching: self.isWeeklyKey) + guard daily != nil || weekly != nil else { + throw DevinUsageError.parseFailed("Missing Devin quota windows.") + } + + return DevinUsageSnapshot( + daily: daily, + weekly: weekly, + planName: self.findPlanName(in: object), + organization: self.displayOrganization(from: organization), + updatedAt: now) + } + + private static func currentQuotaWindows(_ dictionary: [String: Any]) + -> (daily: DevinQuotaWindow?, weekly: DevinQuotaWindow?) + { + let daily = self.currentQuotaWindow( + percent: dictionary["daily_percentage"], + resetsAt: dictionary["daily_reset_at"]) + let weekly = self.currentQuotaWindow( + percent: dictionary["weekly_percentage"], + resetsAt: dictionary["weekly_reset_at"]) + return (daily, weekly) + } + + private static func currentQuotaWindow(percent: Any?, resetsAt: Any?) -> DevinQuotaWindow? { + guard let usedPercent = self.double(percent) else { return nil } + return DevinQuotaWindow( + usedPercent: usedPercent <= 1 ? usedPercent * 100 : usedPercent, + resetsAt: self.date(from: resetsAt)) + } + + private static func findWindow(in object: Any, matching keyMatches: (String) -> Bool) -> DevinQuotaWindow? { + if let dictionary = object as? [String: Any] { + for (key, value) in dictionary where keyMatches(key) { + if let window = self.window(from: value) { + return window + } + } + for value in dictionary.values { + if let found = self.findWindow(in: value, matching: keyMatches) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.findWindow(in: value, matching: keyMatches) { + return found + } + } + } + + return nil + } + + private static func window(from object: Any) -> DevinQuotaWindow? { + guard let dictionary = object as? [String: Any] else { + guard let percent = self.percent(from: object) else { return nil } + return DevinQuotaWindow(usedPercent: percent, resetsAt: nil) + } + + if let percent = self.percent(from: dictionary) { + return DevinQuotaWindow( + usedPercent: percent, + resetsAt: self.findResetDate(in: dictionary)) + } + + if let nested = dictionary.values.lazy.compactMap({ self.window(from: $0) }).first { + return nested + } + + return nil + } + + private static func percent(from object: Any) -> Double? { + if let number = self.double(object) { + return number <= 1 ? number * 100 : number + } + guard let dictionary = object as? [String: Any] else { return nil } + + let directKeys = [ + "used_percent", + "usedPercent", + "usage_percent", + "usagePercent", + "percent_used", + "percentUsed", + "percent", + ] + for key in directKeys { + if let value = self.double(dictionary[key]) { + return value <= 1 ? value * 100 : value + } + } + + let remainingKeys = ["remaining_percent", "remainingPercent", "percent_remaining", "percentRemaining"] + for key in remainingKeys { + if let value = self.double(dictionary[key]) { + let percent = value <= 1 ? value * 100 : value + return 100 - percent + } + } + + let used = self.firstDouble(in: dictionary, keys: ["used", "usage", "used_count", "usedCount", "consumed"]) + let limit = self.firstDouble(in: dictionary, keys: ["limit", "quota", "total", "max", "available"]) + if let used, let limit, limit > 0 { + return used / limit * 100 + } + + let remaining = self.firstDouble(in: dictionary, keys: ["remaining", "left", "available"]) + if let remaining, let limit, limit > 0 { + return (limit - remaining) / limit * 100 + } + + return nil + } + + private static func findPlanName(in object: Any) -> String? { + if let dictionary = object as? [String: Any] { + for key in ["plan_name", "planName", "plan", "tier", "subscription_tier", "subscriptionTier"] { + if let value = dictionary[key] as? String, + let cleaned = self.cleanDisplay(value) + { + return cleaned + } + } + for value in dictionary.values { + if let found = self.findPlanName(in: value) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.findPlanName(in: value) { + return found + } + } + } + + return nil + } + + private static func findResetDate(in dictionary: [String: Any]) -> Date? { + for (key, value) in dictionary where key.localizedCaseInsensitiveContains("reset") { + if let date = self.date(from: value) { + return date + } + } + return nil + } + + private static func date(from value: Any?) -> Date? { + if let raw = value as? String { + if let date = ISO8601DateFormatter().date(from: raw) { + return date + } + if let number = Double(raw) { + return self.date(from: number) + } + } + if let number = self.double(value) { + return self.date(from: number) + } + return nil + } + + private static func date(from number: Double) -> Date? { + guard number > 0 else { return nil } + let seconds = number > 10_000_000_000 ? number / 1000 : number + return Date(timeIntervalSince1970: seconds) + } + + private static func firstDouble(in dictionary: [String: Any], keys: [String]) -> Double? { + for key in keys { + if let value = self.double(dictionary[key]) { + return value + } + } + return nil + } + + private static func double(_ value: Any?) -> Double? { + switch value { + case let number as NSNumber: + CFGetTypeID(number) == CFBooleanGetTypeID() ? nil : number.doubleValue + case let string as String: + Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } + } + + private static func isDailyKey(_ raw: String) -> Bool { + let key = raw.lowercased() + return !key.contains("hide") && (key.contains("daily") || key.contains("day")) + } + + private static func isWeeklyKey(_ raw: String) -> Bool { + let key = raw.lowercased() + return !key.contains("hide") && (key.contains("weekly") || key.contains("week")) + } + + private static func displayOrganization(from raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + if raw.hasPrefix("org/") { + return String(raw.dropFirst(4)) + } + if raw.hasPrefix("organizations/") { + return String(raw.dropFirst("organizations/".count)) + } + return raw + } + + private static func cleanDisplay(_ raw: String) -> String? { + let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + return cleaned.split(separator: "_").flatMap { $0.split(separator: "-") }.map { part in + part.prefix(1).uppercased() + String(part.dropFirst()) + }.joined(separator: " ") + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 21e058df..f0fe02ef 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -66,6 +66,7 @@ public enum ProviderDescriptorRegistry { .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, .copilot: CopilotProviderDescriptor.descriptor, + .devin: DevinProviderDescriptor.descriptor, .zai: ZaiProviderDescriptor.descriptor, .minimax: MiniMaxProviderDescriptor.descriptor, .manus: ManusProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 71a29e7f..af033f4b 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -22,6 +22,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, t3chat: T3ChatProviderSettings? = nil, + devin: DevinProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, windsurf: WindsurfProviderSettings? = nil, @@ -52,6 +53,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: moonshot, amp: amp, t3chat: t3chat, + devin: devin, ollama: ollama, jetbrains: jetbrains, windsurf: windsurf, @@ -277,6 +279,18 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct DevinProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualBearerToken: String? + public let organization: String? + + public init(cookieSource: ProviderCookieSource, manualBearerToken: String?, organization: String?) { + self.cookieSource = cookieSource + self.manualBearerToken = manualBearerToken + self.organization = organization + } + } + public struct CommandCodeProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -392,6 +406,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? public let t3chat: T3ChatProviderSettings? + public let devin: DevinProviderSettings? public let commandcode: CommandCodeProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? @@ -427,6 +442,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, t3chat: T3ChatProviderSettings? = nil, + devin: DevinProviderSettings? = nil, commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil, @@ -457,6 +473,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.moonshot = moonshot self.amp = amp self.t3chat = t3chat + self.devin = devin self.commandcode = commandcode self.ollama = ollama self.jetbrains = jetbrains @@ -488,6 +505,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) case t3chat(ProviderSettingsSnapshot.T3ChatProviderSettings) + case devin(ProviderSettingsSnapshot.DevinProviderSettings) case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) @@ -520,6 +538,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings? + public var devin: ProviderSettingsSnapshot.DevinProviderSettings? public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? @@ -556,6 +575,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value case let .t3chat(value): self.t3chat = value + case let .devin(value): self.devin = value case let .commandcode(value): self.commandcode = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value @@ -590,6 +610,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { moonshot: self.moonshot, amp: self.amp, t3chat: self.t3chat, + devin: self.devin, commandcode: self.commandcode, ollama: self.ollama, jetbrains: self.jetbrains, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index aa8d0a3c..6678c6cc 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -16,6 +16,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case gemini case antigravity case copilot + case devin case zai case minimax case manus @@ -70,6 +71,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case alibaba case factory case copilot + case devin case kimi case kimik2 case kilo @@ -231,4 +233,14 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// Devin sessions are normally in Chrome. Keep automatic import narrow so live probes do not + /// touch unrelated browser keychains; users can select another browser explicitly. + public static var devinCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 4316bbb7..f35602c9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -449,10 +449,10 @@ enum CostUsageScanner { checkCancellation: checkCancellation) case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .alibabatokenplan, .factory, - .copilot, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, - .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, - .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .groq, - .llmproxy, .deepgram: + .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, + .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, + .mistral, .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, + .groq, .llmproxy, .deepgram: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index aa3c5cb3..32564061 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -68,6 +68,7 @@ enum ProviderChoice: String, AppEnum { case .zai: self = .zai case .factory: return nil // Factory not yet supported in widgets case .copilot: self = .copilot + case .devin: return nil // Devin not yet supported in widgets case .minimax: self = .minimax case .manus: return nil // Manus not yet supported in widgets case .vertexai: return nil // Vertex AI not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 68fd9722..8e1606cf 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -277,6 +277,7 @@ private struct ProviderSwitchChip: View { case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" + case .devin: "Devin" case .minimax: "MiniMax" case .manus: "Manus" case .vertexai: "Vertex" @@ -673,6 +674,8 @@ enum WidgetColors { Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange case .copilot: Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple + case .devin: + Color(red: 70 / 255, green: 180 / 255, blue: 130 / 255) case .minimax: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) case .manus: diff --git a/Tests/CodexBarTests/DevinUsageFetcherTests.swift b/Tests/CodexBarTests/DevinUsageFetcherTests.swift new file mode 100644 index 00000000..7590df20 --- /dev/null +++ b/Tests/CodexBarTests/DevinUsageFetcherTests.swift @@ -0,0 +1,392 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct DevinUsageFetcherTests { + private static let now = Date(timeIntervalSince1970: 1_780_000_000) + + @Test + func `parses quota usage response into daily and weekly windows`() throws { + let response: [String: Any] = [ + "plan_name": "pro", + "quota_usage": [ + "daily_quota": [ + "used": 3, + "limit": 10, + "reset_at": "2026-06-01T08:00:00Z", + ], + "weekly_quota": [ + "remaining_percent": 0.25, + "next_reset_at": 1_780_560_000, + ], + ], + ] + + let snapshot = try DevinUsageParser.parse(response, organization: "org/example-org", now: Self.now) + + #expect(snapshot.daily?.usedPercent == 30) + #expect(snapshot.weekly?.usedPercent == 75) + #expect(snapshot.daily?.resetsAt?.timeIntervalSince1970 == 1_780_300_800) + #expect(snapshot.weekly?.resetsAt?.timeIntervalSince1970 == 1_780_560_000) + #expect(snapshot.planName == "Pro") + #expect(snapshot.organization == "example-org") + } + + @Test + func `parses current Devin quota response with reset timestamps`() throws { + let response: [String: Any] = [ + "is_quota_plan": true, + "has_quota_allocation": true, + "daily_percentage": 0.12, + "weekly_percentage": 42, + "daily_reset_at": "2026-06-11T00:00:00-08:00", + "weekly_reset_at": "2026-06-14T00:00:00-08:00", + "hide_daily_quota": false, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: "org/example-org", now: Self.now) + + #expect(snapshot.daily?.usedPercent == 12) + #expect(snapshot.weekly?.usedPercent == 42) + #expect(snapshot.daily?.resetsAt?.timeIntervalSince1970 == 1_781_164_800) + #expect(snapshot.weekly?.resetsAt?.timeIntervalSince1970 == 1_781_424_000) + } + + @Test + func `keeps weekly quota when current plan hides daily quota`() throws { + let response: [String: Any] = [ + "weekly_percentage": 25, + "weekly_reset_at": "2026-06-14T00:00:00-08:00", + "hide_daily_quota": true, + ] + + let usage = try DevinUsageParser.parse(response, organization: nil, now: Self.now).toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 25) + } + + @Test + func `parses zero percentages from JSON response`() throws { + let data = Data(""" + { + "daily_percentage": 0, + "weekly_percentage": 0, + "daily_reset_at": "2026-06-11T00:00:00-08:00", + "weekly_reset_at": "2026-06-14T00:00:00-08:00" + } + """.utf8) + + let snapshot = try DevinUsageParser.parse(data, organization: nil, now: Self.now) + + #expect(snapshot.daily?.usedPercent == 0) + #expect(snapshot.weekly?.usedPercent == 0) + } + + @Test + func `usage snapshot maps Devin quotas to primary and secondary windows`() { + let snapshot = DevinUsageSnapshot( + daily: DevinQuotaWindow(usedPercent: 12), + weekly: DevinQuotaWindow(usedPercent: 42), + planName: "Free", + organization: "example-org", + updatedAt: Self.now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 12) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetDescription == "Daily") + #expect(usage.secondary?.usedPercent == 42) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetDescription == "Weekly") + #expect(usage.identity?.providerID == .devin) + #expect(usage.identity?.accountOrganization == "example-org") + #expect(usage.identity?.loginMethod == "Free") + } + + @Test + func `fetch sends bearer token and organization header`() async throws { + let auth = DevinUsageFetcher.RequestAuth( + bearerToken: "secret-token", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "test") + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "app.devin.ai") + #expect(request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer secret-token") + #expect(request.value(forHTTPHeaderField: "x-cog-org-id") == "org_GQ6LhcfkW1TSinM6") + let body = """ + {"daily":{"used_percent":10},"weekly":{"used_percent":20},"plan":"free"} + """ + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(body.utf8), response) + } + + let snapshot = try await DevinUsageFetcher.fetchQuotaUsage( + auth: auth, + now: Self.now, + transport: stub) + + #expect(snapshot.daily?.usedPercent == 10) + #expect(snapshot.weekly?.usedPercent == 20) + #expect(snapshot.planName == "Free") + } + + @Test + func `fetch does not mask parser failure with fallback endpoint errors`() async { + let auth = DevinUsageFetcher.RequestAuth( + bearerToken: "secret-token", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "test") + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage" ? 200 : 404, + httpVersion: nil, + headerFields: nil)! + return (Data("{}".utf8), response) + } + + do { + _ = try await DevinUsageFetcher.fetchQuotaUsage( + auth: auth, + now: Self.now, + transport: stub) + Issue.record("Expected quota parsing to fail") + } catch let error as DevinUsageError { + guard case .parseFailed = error else { + Issue.record("Expected parseFailed, got \(error)") + return + } + } catch { + Issue.record("Expected DevinUsageError, got \(error)") + } + + #expect(await stub.requests().count == 1) + } + + @Test + func `normalizes organization inputs`() { + #expect(DevinUsageFetcher.normalizedOrganization("example-org") == "org/example-org") + #expect(DevinUsageFetcher.normalizedOrganization("org/example-org") == "org/example-org") + #expect(DevinUsageFetcher.normalizedOrganization("org_GQ6LhcfkW1TSinM6") == + "organizations/org_GQ6LhcfkW1TSinM6") + #expect(DevinUsageFetcher.normalizedOrganization("org-b31f951cd01d4c6da84991cf5b970cfb") == + "organizations/org-b31f951cd01d4c6da84991cf5b970cfb") + #expect(DevinUsageFetcher.normalizedOrganization("https://app.devin.ai/org/example-org/settings/usage") == + "org/example-org") + } + + @Test + func `manual auth strips Authorization and Bearer prefixes`() throws { + let auth = try #require(DevinUsageFetcher.manualAuth( + from: "Authorization: Bearer secret-token", + organization: "example-org")) + + #expect(auth.bearerToken == "secret-token") + #expect(auth.organization == "org/example-org") + #expect(auth.sourceLabel == "manual") + } + + #if os(macOS) + @Test + func `empty app organization setting preserves imported organization`() async throws { + defer { DevinSessionImporter.importSessionOverrideForTesting = nil } + DevinSessionImporter.importSessionOverrideForTesting = { _, organizationOverride, _ in + #expect(organizationOverride == nil) + return DevinSessionImporter.SessionInfo( + accessToken: "auth1_abcdefghijklmnopqrstuvwxyz0123456789", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Default") + } + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(#"{"daily_percentage":0,"weekly_percentage":0}"#.utf8), response) + } + + let snapshot = try await DevinUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)).fetch( + organizationOverride: "", + now: Self.now, + transport: stub) + + #expect(snapshot.organization == "example-org") + #expect(snapshot.daily?.usedPercent == 0) + #expect(snapshot.weekly?.usedPercent == 0) + } + + @Test + func `session importer extracts current auth1 token and matching org`() throws { + let accessToken = "auth1_abcdefghijklmnopqrstuvwxyz0123456789" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}auth1_session": + #"{"token":"\#(accessToken)","userId":"github|123"}"#, + "_https://app.devin.ai\u{0000}\u{0001}last-internal-org-for-external-org-v1-example-org": + "\"org_GQ6LhcfkW1TSinM6\"", + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: "example-org", + sourceLabel: "Chrome Default")) + + #expect(session.accessToken == accessToken) + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + #expect(session.sourceLabel == "Chrome Default") + } + + @Test + func `session importer infers organization from post auth storage`() throws { + let accessToken = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwczovL2F1dGguZGV2aW4uYWkvIn0.signature" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}@@auth0spajs@@::client::audience::scope": + #"{"body":{"access_token":"\#(accessToken)"}}"#, + "_https://app.devin.ai\u{0000}\u{0001}post-auth-v3-null-github|123-org_name-example-org": """ + { + "externalOrgId": null, + "userId": "github|123", + "internalOrgId": "org_GQ6LhcfkW1TSinM6", + "orgName": "example-org" + } + """, + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: nil, + sourceLabel: "Brave Default")) + + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer infers organization from member info storage`() throws { + let accessToken = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwczovL2F1dGguZGV2aW4uYWkvIn0.signature" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}@@auth0spajs@@::client::audience::scope": + #"{"body":{"access_token":"\#(accessToken)"}}"#, + "_https://app.devin.ai\u{0000}\u{0001}member-info-v1-org-github|123": """ + { + "value": { + "org_id": "org_GQ6LhcfkW1TSinM6", + "org_name": "example-org" + } + } + """, + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: nil, + sourceLabel: "Brave Default")) + + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer falls back to internal organization id`() { + let result = DevinSessionImporter.organizationInfo( + from: [ + "_https://app.devin.ai\u{0000}\u{0001}feature-flags-cache:org_GQ6LhcfkW1TSinM6": "{}", + "_https://app.devin.ai\u{0000}\u{0001}member-info-v1-org-github|123": """ + {"value":{"org_id":"org_GQ6LhcfkW1TSinM6"}} + """, + ], + organizationOverride: nil) + + #expect(result.organization == "organizations/org_GQ6LhcfkW1TSinM6") + #expect(result.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer ignores org words inside storage key names`() { + let result = DevinSessionImporter.organizationInfo( + from: [ + "_https://app.devin.ai\u{0000}\u{0001}last-internal-org-for-external-org-v1-null": "\"null\"", + "_https://app.devin.ai\u{0000}\u{0001}feature-flags-cache:org_GQ6LhcfkW1TSinM6": "{}", + ], + organizationOverride: nil) + + #expect(result.organization == "organizations/org_GQ6LhcfkW1TSinM6") + #expect(result.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer deduplicates repeated browser tokens using richest organization metadata`() { + let sessions = [ + DevinSessionImporter.SessionInfo( + accessToken: "auth1_abcdefghijklmnopqrstuvwxyz0123456789", + organization: nil, + internalOrganizationID: nil, + sourceLabel: "Chrome Default"), + DevinSessionImporter.SessionInfo( + accessToken: "auth1_abcdefghijklmnopqrstuvwxyz0123456789", + organization: "org/example", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Profile 1"), + ] + + let deduplicated = DevinSessionImporter.deduplicateSessions(sessions) + + #expect(deduplicated.count == 1) + #expect(deduplicated.first?.sourceLabel == "Chrome Profile 1") + #expect(deduplicated.first?.organization == "org/example") + #expect(deduplicated.first?.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer ranks organization aware profiles first`() { + let incomplete = DevinSessionImporter.SessionInfo( + accessToken: "auth1_incomplete", + organization: nil, + internalOrganizationID: nil, + sourceLabel: "Chrome Default") + let complete = DevinSessionImporter.SessionInfo( + accessToken: "auth1_complete", + organization: "org/example", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Profile 1") + + let ranked = DevinSessionImporter.rankSessions([incomplete, complete]) + + #expect(ranked.map(\.sourceLabel) == ["Chrome Profile 1", "Chrome Default"]) + } + + @Test + func `missing organization retries the next browser profile`() { + #expect(DevinUsageFetcher.shouldTryNextSession(after: DevinUsageError.missingOrganization)) + #expect(!DevinUsageFetcher.shouldTryNextSession(after: DevinUsageError.parseFailed("invalid response"))) + } + + @Test + func `automatic local storage import does not fall back beyond Chrome`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: temp) } + + let braveRoot = temp + .appendingPathComponent("Library/Application Support/BraveSoftware/Brave-Browser/Default") + try FileManager.default.createDirectory(at: braveRoot, withIntermediateDirectories: true) + let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + + #expect(detection.hasUsableProfileData(.brave)) + #expect(!detection.hasUsableProfileData(.chrome)) + #expect(DevinSessionImporter.localStorageBrowsers(browserDetection: detection).isEmpty) + } + #endif +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 6db1d3c3..65751b0a 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -24,6 +24,7 @@ struct ProviderIconResourcesTests { "antigravity", "factory", "copilot", + "devin", "crof", "commandcode", "t3chat", diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 43ae2fe0..29c9178e 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -182,6 +182,20 @@ struct ProviderSettingsDescriptorTests { #expect(detailLine == fixture.store.sourceLabel(for: .alibaba)) } + @Test + func `devin presentation follows store source label`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-devin-presentation") + fixture.store.lastSourceLabels[.devin] = "web" + let metadata = try #require(ProviderDescriptorRegistry.metadata[.devin]) + let context = fixture.presentationContext(provider: .devin, metadata: metadata) + + let detailLine = DevinProviderImplementation() + .presentation(context: context) + .detailLine(context) + + #expect(detailLine == "web") + } + @Test func `alibaba token plan settings expose cookie controls`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-token-plan-settings") diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index 195b28ba..83a8c7f5 100644 --- a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -20,9 +20,9 @@ 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetBundle.swift; sourceTree = ""; }; 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetProvider.swift; sourceTree = ""; }; 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetViews.swift; sourceTree = ""; }; + 9FA0A78FB7CA1D877E7BA54B /* codexbar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = codexbar; path = ..; sourceTree = SOURCE_ROOT; }; E430B27E4F28973A5E77EA3F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexBarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - EFBE36CB6481E7133E2A5CF3 /* CodexBar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CodexBar; path = ..; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -42,7 +42,7 @@ 4FAD1E2FCD6C4AC65D308ABC /* Packages */ = { isa = PBXGroup; children = ( - EFBE36CB6481E7133E2A5CF3 /* CodexBar */, + 9FA0A78FB7CA1D877E7BA54B /* codexbar */, ); name = Packages; sourceTree = ""; diff --git a/docs/devin.md b/docs/devin.md new file mode 100644 index 00000000..df4f1469 --- /dev/null +++ b/docs/devin.md @@ -0,0 +1,43 @@ +--- +summary: "Devin provider auth, quota endpoint, and setup." +read_when: + - Adding or modifying the Devin provider + - Debugging Devin localStorage import or quota parsing + - Explaining Devin setup +--- + +# Devin Provider + +The Devin provider tracks included daily and weekly usage quotas from +[app.devin.ai](https://app.devin.ai). + +## Setup + +1. Sign in to Devin in Google Chrome. +2. Open the organization Usage & Limits page once. +3. Enable **Devin** in **Settings → Providers**. + +Automatic mode reads only the Devin session and organization metadata from Chrome localStorage. It does not scan other +browsers. CodexBar sends the session token only to `https://app.devin.ai`. + +## Manual Auth + +Set **Auth source** to **Manual**, then paste either the bare token or the full `Authorization: Bearer ...` header value +from an app.devin.ai API request. The optional organization field accepts a slug, an internal `org_...` ID, or the full +organization URL. + +Environment overrides: + +- `DEVIN_BEARER_TOKEN` or `DEVIN_AUTHORIZATION` +- `DEVIN_ORGANIZATION` or `DEVIN_ORG` + +## Data Source + +CodexBar requests: + +```text +GET https://app.devin.ai/api//billing/quota/usage +``` + +The response supplies daily and weekly usage percentages plus reset timestamps. If Devin changes or expires the browser +session, sign in again and refresh CodexBar. diff --git a/docs/providers.md b/docs/providers.md index 18f52352..29b838da 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 48 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 49 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -33,6 +33,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | | Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | +| Devin | Chrome localStorage session or manual Bearer token → daily and weekly quota API (`web`). | | z.ai | API token from config/env → quota API (`api`). | | Manus | Browser `session_id` cookie (auto/manual/env) → credits API (`web`). | | MiniMax | Manual/browser session via Coding Plan web path (`web`), or Coding Plan API token (`api`). | @@ -107,6 +108,13 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Status: none yet. - Details: `docs/zai.md`. +## Devin +- Automatic auth reads the current `auth1_session` token and organization metadata from Chrome localStorage. +- Manual auth accepts the `Authorization: Bearer ...` value from an app.devin.ai request. +- Usage endpoint: `GET /api//billing/quota/usage`. +- Shows daily and weekly quota percentages with their reset timestamps. +- Details: `docs/devin.md`. + ## Manus - Session token via browser `session_id` cookie, manual Settings entry, `MANUS_SESSION_TOKEN`, or `MANUS_COOKIE`. - Credits endpoint: `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits`. From 5745118184b9da1ad18c1b9aef787edf8c19bc53 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: Thu, 11 Jun 2026 17:19:38 +0800 Subject: [PATCH 43/51] fix: show Cursor Full Disk Access hint first (#1419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show Cursor's Safari Full Disk Access recovery guidance before the long browser login hint, with ordering regression coverage. Co-authored-by: Larry Hao(郝卓远) --- CHANGELOG.md | 3 +++ .../Providers/Cursor/CursorStatusProbe.swift | 7 +++++-- Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1738643..718eb8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Added - Devin: add daily and weekly quota tracking from the signed-in Chrome session or a manual Bearer token (#1264, fixes #800). Thanks @coygeek! +### Fixed +- Cursor: show the Safari Full Disk Access recovery hint before the long browser login list so permission guidance remains visible when menu errors truncate (#1419, fixes #1417). Thanks @hhh2210! + ## 0.33.0 — 2026-06-11 ### Added diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index b530c7b4..7379ca55 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -542,6 +542,9 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { case parseFailed(String) case noSessionCookie + static let safariFullDiskAccessHint = + "If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security." + public var errorDescription: String? { switch self { case .notLoggedIn: @@ -551,8 +554,8 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { case let .parseFailed(msg): "Could not parse Cursor usage: \(msg)" case .noSessionCookie: - "No Cursor session found. Please log in to cursor.com in \(cursorCookieImportOrder.loginHint). " - + "If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. " + "No Cursor session found. \(Self.safariFullDiskAccessHint) " + + "Please log in to cursor.com in \(cursorCookieImportOrder.loginHint). " + "You can also sign in to Cursor from the CodexBar menu (Add / switch account)." } } diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index 666007d0..d2bdd21d 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -23,6 +23,16 @@ struct BrowserCookieOrderStatusStringTests { #expect(message.contains(order.loginHint)) } + @Test + func `cursor no session shows full disk access hint before browser list`() throws { + let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder + let message = try #require(CursorStatusProbeError.noSessionCookie.errorDescription) + let fullDiskAccessRange = try #require(message.range(of: CursorStatusProbeError.safariFullDiskAccessHint)) + let browserListRange = try #require(message.range(of: order.loginHint)) + + #expect(fullDiskAccessRange.lowerBound < browserListRange.lowerBound) + } + @Test func `factory no session includes browser login hint`() { let order = ProviderDefaults.metadata[.factory]?.browserCookieOrder ?? Browser.defaultImportOrder From 159d03ceb3189e5e5936edeaf5b9c24a29727d50 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: Thu, 11 Jun 2026 18:12:39 +0800 Subject: [PATCH 44/51] fix: show Cursor legacy request quotas (#1420) Present usable legacy Cursor request plans as one Requests quota with the raw used/limit count, while preserving Total/Auto/API fallback for incomplete or zero-limit payloads. Adds projection and menu-model regression coverage plus the 0.33.1 changelog entry. Co-authored-by: hhh2210 Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + Sources/CodexBar/MenuCardView.swift | 21 ++++++- .../Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../Providers/Cursor/CursorStatusProbe.swift | 34 +++++------ .../CursorLegacyRequestProjectionTests.swift | 59 +++++++++++++++++++ .../CursorMenuCardModelTests.swift | 44 ++++++++++++++ 8 files changed, 142 insertions(+), 23 deletions(-) create mode 100644 Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 718eb8df..dc8e4b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Cursor: show the Safari Full Disk Access recovery hint before the long browser login list so permission guidance remains visible when menu errors truncate (#1419, fixes #1417). Thanks @hhh2210! +- Cursor: present legacy request-based plans as one Requests quota with the raw used/limit count instead of unrelated token-based Auto/API bars (#1420, fixes #1418). Thanks @hhh2210! ## 0.33.0 — 2026-06-11 diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1c3532da..8b2ce17c 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1217,9 +1217,16 @@ extension UsageMenuCardView.Model { if input.provider == .factory, snapshot.tertiary != nil { return ("5-hour", L("Weekly"), L("Monthly"), true) } - let primaryLabel = input.provider == .grok - ? GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel - : input.metadata.sessionLabel + // Legacy request-based Cursor plans track a request quota, not the token-based "Total" pool — + // relabel the primary bar so it reads as a request count instead of a dollar percentage. + let primaryLabel = if input.provider == .cursor, snapshot.cursorRequests != nil { + "Requests" + } else if input.provider == .grok { + GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata + .sessionLabel + } else { + input.metadata.sessionLabel + } return ( L(primaryLabel), L(input.metadata.weeklyLabel), @@ -1324,6 +1331,14 @@ extension UsageMenuCardView.Model { primaryPacePercent = paceDetail.pacePercent primaryPaceOnTop = paceDetail.paceOnTop } + // Legacy request-based Cursor plans: surface the raw used/limit quota on its own line, + // since the percentage bar and pace detail alone never spell out the request cap. + if input.provider == .cursor, let requests = input.snapshot?.cursorRequests { + primaryDetailText = String( + format: L("Request quota: %@ / %@"), + "\(requests.used)", + "\(requests.limit)") + } if input.provider == .synthetic, let regen = Self.syntheticRollingRegenDetail( window: primary, diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index b52d91e1..cdc0c56b 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1069,3 +1069,5 @@ "Search providers" = "Search providers"; "language_vietnamese" = "Vietnamese"; + +"Request quota: %@ / %@" = "Request quota: %@ / %@"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index c42fe010..661ad978 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1041,3 +1041,5 @@ "Search providers" = "搜索提供商"; "language_vietnamese" = "越南语"; + +"Request quota: %@ / %@" = "请求额度:%@ / %@"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 025f0916..769076f2 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -937,3 +937,5 @@ "Search providers" = "搜尋提供者"; "language_vietnamese" = "越南語"; + +"Request quota: %@ / %@" = "請求額度:%@ / %@"; diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 7379ca55..0ec69107 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -421,17 +421,18 @@ public struct CursorStatusSnapshot: Sendable { /// Convert to UsageSnapshot for the common provider interface public func toUsageSnapshot() -> UsageSnapshot { - // Primary: For legacy request-based plans, use request usage; otherwise use plan percentage - let primaryUsedPercent: Double = if self.isLegacyRequestPlan, - let used = self.requestsUsed, - let limit = self.requestsLimit, - limit > 0 + let cursorRequests: CursorRequestUsage? = if let used = self.requestsUsed, + let limit = self.requestsLimit, + limit > 0 { - (Double(used) / Double(limit)) * 100 + CursorRequestUsage(used: used, limit: limit) } else { - self.planPercentUsed + nil } + // Primary: For usable legacy request quotas, use request usage; otherwise preserve plan percentage. + let primaryUsedPercent = cursorRequests?.usedPercent ?? self.planPercentUsed + let billingCycleWindowMinutes = Self.billingCycleWindowMinutes( start: self.billingCycleStart, end: self.billingCycleEnd) @@ -442,8 +443,10 @@ public struct CursorStatusSnapshot: Sendable { resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) - // Secondary: Auto + Composer usage (shown as its own bar below Total) - let secondary: RateWindow? = self.autoPercentUsed.map { pct in + // Secondary: Auto + Composer usage (shown as its own bar below Total). + // Legacy request-based plans don't have the token-based Auto/API breakdown — those percentages + // come from the new usage-based pricing and are meaningless next to a request quota, so hide them. + let secondary: RateWindow? = cursorRequests != nil ? nil : self.autoPercentUsed.map { pct in RateWindow( usedPercent: pct, windowMinutes: billingCycleWindowMinutes, @@ -451,8 +454,8 @@ public struct CursorStatusSnapshot: Sendable { resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) } - // Tertiary: API (named model) usage - let tertiary: RateWindow? = self.apiPercentUsed.map { pct in + // Tertiary: API (named model) usage — hidden for legacy request-based plans (see above). + let tertiary: RateWindow? = cursorRequests != nil ? nil : self.apiPercentUsed.map { pct in RateWindow( usedPercent: pct, windowMinutes: billingCycleWindowMinutes, @@ -479,15 +482,6 @@ public struct CursorStatusSnapshot: Sendable { nil } - // Legacy plan request usage (when maxRequestUsage is set) - let cursorRequests: CursorRequestUsage? = if let used = self.requestsUsed, - let limit = self.requestsLimit - { - CursorRequestUsage(used: used, limit: limit) - } else { - nil - } - let identity = ProviderIdentitySnapshot( providerID: .cursor, accountEmail: self.accountEmail, diff --git a/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift b/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift new file mode 100644 index 00000000..3ee0cf47 --- /dev/null +++ b/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import CodexBarCore + +struct CursorLegacyRequestProjectionTests { + @Test + func `legacy plan hides token-based auto and api bars`() { + let snapshot = Self.snapshot(requestsUsed: 347, requestsLimit: 500) + + let usageSnapshot = snapshot.toUsageSnapshot() + + #expect(abs((usageSnapshot.primary?.usedPercent ?? 0) - 69.4) < 0.01) + #expect(usageSnapshot.cursorRequests?.used == 347) + #expect(usageSnapshot.cursorRequests?.limit == 500) + #expect(usageSnapshot.secondary == nil) + #expect(usageSnapshot.tertiary == nil) + } + + @Test + func `unusable legacy request quota preserves token bars`() { + let requestCases: [(used: Int?, limit: Int?)] = [ + (nil, 500), + (12, 0), + ] + + for requestCase in requestCases { + let usageSnapshot = Self.snapshot( + requestsUsed: requestCase.used, + requestsLimit: requestCase.limit).toUsageSnapshot() + + #expect(usageSnapshot.primary?.usedPercent == 7.0) + #expect(usageSnapshot.cursorRequests == nil) + #expect(usageSnapshot.secondary?.usedPercent == 11.0) + #expect(usageSnapshot.tertiary?.usedPercent == 22.0) + } + } + + private static func snapshot( + requestsUsed: Int?, + requestsLimit: Int?) -> CursorStatusSnapshot + { + CursorStatusSnapshot( + planPercentUsed: 7.0, + autoPercentUsed: 11.0, + apiPercentUsed: 22.0, + planUsedUSD: 1.4, + planLimitUSD: 20.0, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: "user@example.com", + accountName: nil, + rawJSON: nil, + requestsUsed: requestsUsed, + requestsLimit: requestsLimit) + } +} diff --git a/Tests/CodexBarTests/CursorMenuCardModelTests.swift b/Tests/CodexBarTests/CursorMenuCardModelTests.swift index 7ac845b4..77564841 100644 --- a/Tests/CodexBarTests/CursorMenuCardModelTests.swift +++ b/Tests/CodexBarTests/CursorMenuCardModelTests.swift @@ -46,4 +46,48 @@ struct CursorMenuCardModelTests { #expect(metric.paceOnTop == false) } } + + @Test + func `legacy request plan shows single requests bar with count`() throws { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval(6 * 24 * 3600) + let cycleMinutes = 30 * 24 * 60 + // A legacy snapshot, as produced by CursorStatusSnapshot.toUsageSnapshot(): only the request + // window survives, Auto/API are dropped, and the request count rides along. + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 69.4, + windowMinutes: cycleMinutes, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + tertiary: nil, + cursorRequests: CursorRequestUsage(used: 347, limit: 500), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Requests"]) + #expect(model.metrics.first?.detailText == "Request quota: 347 / 500") + } } From dd8cf8b06ebb761cd850f194bd2d8e8aeffffc4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 03:36:18 -0700 Subject: [PATCH 45/51] perf: reduce Codex cost refresh metadata work (#1430) Co-authored-by: pickaxe <54486432+ProspectOre@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 25 ++++++++++-- .../CostUsageScanner+CacheHelpers.swift | 25 +++++++++--- .../Vendored/CostUsage/CostUsageScanner.swift | 35 +++++++++------- Tests/CodexBarTests/CostUsageCacheTests.swift | 20 ++++++++++ .../CodexBarTests/CostUsageScannerTests.swift | 40 +++++++++++++++++++ 7 files changed, 124 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8e4b00..76cbbed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Devin: add daily and weekly quota tracking from the signed-in Chrome session or a manual Bearer token (#1264, fixes #800). Thanks @coygeek! ### Fixed +- Cost usage: replace repeated Foundation metadata/root checks with one portable file-stat pass so expired Codex history refreshes stay responsive on very large session archives (#1392). Thanks @TheAngryPit and @ProspectOre! - Cursor: show the Safari Full Disk Access recovery hint before the long browser login list so permission guidance remains visible when menu errors truncate (#1419, fixes #1417). Thanks @hhh2210! - Cursor: present legacy request-based plans as one Requests quota with the raw used/limit count instead of unrelated token-based Auto/API bars (#1420, fixes #1418). Thanks @hhh2210! diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d38cce4b..dda51b38 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "3c27f997569eb3c5" + static let value = "41322b25ff12b545" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 2fbfabee..40c2d8c6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,6 +1,10 @@ import Foundation enum CostUsageCacheIO { + private static let compatibleCodexProducerKeys: Set = [ + "codex:cu:p3c27f997569eb3c5", + ] + private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: @@ -32,17 +36,32 @@ enum CostUsageCacheIO { { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) - if let decoded = self.loadCache(at: url, expectedProducerKey: expectedProducerKey) { return decoded } + let compatibleProducerKeys = producerKey == nil && provider == .codex + ? self.compatibleCodexProducerKeys + : [] + if let decoded = self.loadCache( + at: url, + expectedProducerKey: expectedProducerKey, + compatibleProducerKeys: compatibleProducerKeys) + { + return decoded + } return CostUsageCache() } - private static func loadCache(at url: URL, expectedProducerKey: String?) -> CostUsageCache? { + private static func loadCache( + at url: URL, + expectedProducerKey: String?, + compatibleProducerKeys: Set) -> CostUsageCache? + { guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) else { return nil } guard decoded.version == 1 else { return nil } if let expectedProducerKey { - guard decoded.producerKey == expectedProducerKey else { return nil } + guard decoded.producerKey == expectedProducerKey + || decoded.producerKey.map(compatibleProducerKeys.contains) == true + else { return nil } } return decoded } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 64d351eb..d6744254 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1,4 +1,9 @@ import Foundation +#if os(Linux) +import Glibc +#else +import Darwin +#endif extension CostUsageScanner { static func codexRowsByDayModel( @@ -617,14 +622,22 @@ extension CostUsageScanner { static func codexFileMetadata(fileURL: URL) -> CodexFileMetadata { let path = fileURL.path - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 + var info = stat() + guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { + return CodexFileMetadata(path: path, mtimeUnixMs: 0, size: 0, fileId: nil) + } + #if os(Linux) + let modifiedSeconds = Int64(info.st_mtim.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtim.tv_nsec) + #else + let modifiedSeconds = Int64(info.st_mtimespec.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtimespec.tv_nsec) + #endif return CodexFileMetadata( path: path, - mtimeUnixMs: Int64(mtime * 1000), - size: size, - fileId: Self.fileIdentityString(fileURL: fileURL)) + mtimeUnixMs: modifiedSeconds * 1000 + modifiedNanoseconds / 1_000_000, + size: Int64(info.st_size), + fileId: "\(info.st_dev):\(info.st_ino)") } static func dropCachedCodexFile( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index f35602c9..41e952af 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -540,9 +540,11 @@ enum CostUsageScanner { private static func cachedCodexSessionFiles( cache: CostUsageCache, range: CostUsageDayRange, - roots: [URL]) -> [URL] + roots: [URL], + excludingPaths: Set) -> [URL] { cache.files.compactMap { path, usage in + guard !excludingPaths.contains(path) else { return nil } let hasRelevantDay = usage.days.keys.contains { CostUsageDayRange.isInRange(dayKey: $0, since: range.scanSinceKey, until: range.scanUntilKey) } @@ -554,10 +556,18 @@ enum CostUsageScanner { } } - private static func cachedCodexSessionIndex(cache: CostUsageCache, roots: [URL]) -> [String: URL] { + private static func cachedCodexSessionIndex( + cache: CostUsageCache, + roots: [URL], + knownExistingPaths: Set) -> [String: URL] + { var out: [String: URL] = [:] for (path, usage) in cache.files { guard let sessionId = usage.sessionId, !sessionId.isEmpty else { continue } + if knownExistingPaths.contains(path) { + out[sessionId] = URL(fileURLWithPath: path) + continue + } guard FileManager.default.fileExists(atPath: path) else { continue } let fileURL = URL(fileURLWithPath: path) guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { continue } @@ -923,15 +933,6 @@ enum CostUsageScanner { return String(filename[matchRange]) } - static func fileIdentityString(fileURL: URL) -> String? { - guard let values = try? fileURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) else { return nil } - guard let identifier = values.fileResourceIdentifier else { return nil } - if let data = identifier as? Data { - return data.base64EncodedString() - } - return String(describing: identifier) - } - private struct CodexSessionMetadata { let sessionId: String? let forkedFromId: String? @@ -2292,9 +2293,12 @@ enum CostUsageScanner { } } - for fileURL in Self.cachedCodexSessionFiles(cache: cache, range: range, roots: plan.roots) + for fileURL in Self.cachedCodexSessionFiles( + cache: cache, + range: range, + roots: plan.roots, + excludingPaths: seenPaths) .sorted(by: { $0.path < $1.path }) - where !seenPaths.contains(fileURL.path) { seenPaths.insert(fileURL.path) files.append(fileURL) @@ -2305,7 +2309,10 @@ enum CostUsageScanner { let fileIndex = CodexSessionFileIndex( files: files, roots: plan.roots, - cachedSessionFiles: Self.cachedCodexSessionIndex(cache: cache, roots: plan.roots), + cachedSessionFiles: Self.cachedCodexSessionIndex( + cache: cache, + roots: plan.roots, + knownExistingPaths: filePathsInScan), checkCancellation: checkCancellation) let inheritedResolver = CodexInheritedTotalsResolver( fileIndex: fileIndex, diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index d59f1c95..b7252a24 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -80,6 +80,26 @@ struct CostUsageCacheTests { #expect(loaded.days.isEmpty) } + @Test + func `current codex cache accepts parser compatible 0_33 producer`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.lastScanUnixMs = 123 + cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p3c27f997569eb3c5") + + let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) + + #expect(loaded.lastScanUnixMs == 123) + #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) + } + @Test func `non codex cache does not require producer key`() throws { let root = try self.makeTemporaryCacheRoot() diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 7cd12393..c94ce42a 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -3,6 +3,46 @@ import Testing @testable import CodexBarCore struct CostUsageScannerTests { + @Test + func `codex file metadata detects append truncation and replacement`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-codex-metadata-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fileURL = root.appendingPathComponent("session.jsonl") + try Data("abc".utf8).write(to: fileURL) + + let initial = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(initial.size == 3) + #expect(initial.fileId != nil) + let linkURL = root.appendingPathComponent("linked-session.jsonl") + try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: fileURL) + let linked = CostUsageScanner.codexFileMetadata(fileURL: linkURL) + #expect(linked.size == initial.size) + #expect(linked.fileId == initial.fileId) + + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data("def".utf8)) + try handle.close() + let appended = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(appended.size == 6) + #expect(appended.fileId == initial.fileId) + + let truncateHandle = try FileHandle(forWritingTo: fileURL) + try truncateHandle.truncate(atOffset: 2) + try truncateHandle.close() + let truncated = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(truncated.size == 2) + #expect(truncated.fileId == initial.fileId) + + try FileManager.default.removeItem(at: fileURL) + try Data("replacement".utf8).write(to: fileURL) + let replaced = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(replaced.size == 11) + #expect(replaced.fileId != initial.fileId) + } + @Test func `vertex daily report filters claude logs`() throws { let env = try CostUsageTestEnvironment() From 6916dfc1902013ff4485be156815a945ff6623d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 11:58:59 +0000 Subject: [PATCH 46/51] Fix CI fallout from 0.33.1 upstream sync - Handle the new .devin provider in the fork's exhaustive switches: AccountIdentityComputer stays non-Tier-A (per-device legacy bucket) and SyncCoordinator never marks Devin costs as estimated, matching every other quota-only provider. - Move the APIKeyDebugContext struct and its four provider builders out of UsageStore.swift into UsageStore+APIKeyDebug.swift; the merge had pushed the file past the SwiftLint file_length limit (1509 > 1500). Verified on Linux with Swift 6.2.1: CodexBarCLI release build and swift test pass; SwiftFormat 0.59.1 lint clean on changed files. https://claude.ai/code/session_01Mji1HrQ5wkVndbmF3TF6b1 --- Sources/CodexBar/Sync/SyncCoordinator.swift | 5 +- Sources/CodexBar/UsageStore+APIKeyDebug.swift | 75 +++++++++++++++++++ Sources/CodexBar/UsageStore.swift | 64 ---------------- .../Sync/AccountIdentityComputer.swift | 4 +- 4 files changed, 82 insertions(+), 66 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+APIKeyDebug.swift diff --git a/Sources/CodexBar/Sync/SyncCoordinator.swift b/Sources/CodexBar/Sync/SyncCoordinator.swift index 56178efa..e507937c 100644 --- a/Sources/CodexBar/Sync/SyncCoordinator.swift +++ b/Sources/CodexBar/Sync/SyncCoordinator.swift @@ -1904,7 +1904,10 @@ final class SyncCoordinator { // (deployment validation), Alibaba Token Plan (Bailian quota), // and T3 Chat (web session) all surface pre-computed numbers // from their own APIs — never via the local pricing tables. - .azureopenai, .alibabatokenplan, .t3chat: + .azureopenai, .alibabatokenplan, .t3chat, + // Upstream 0.33 new provider. Devin quota numbers come from + // its own API — never via the local pricing tables. + .devin: // These providers never reach the local pricing table — their // costs come pre-computed from upstream APIs (or don't exist). // No fallback applies, so they are never "estimated". diff --git a/Sources/CodexBar/UsageStore+APIKeyDebug.swift b/Sources/CodexBar/UsageStore+APIKeyDebug.swift new file mode 100644 index 00000000..00e04fad --- /dev/null +++ b/Sources/CodexBar/UsageStore+APIKeyDebug.swift @@ -0,0 +1,75 @@ +import CodexBarCore +import Foundation + +// MARK: - API key debug contexts + +// Extracted from the debug-dump extension in UsageStore.swift to keep that +// file under the SwiftLint file_length limit. `private` became internal in +// the move; these remain debug-only helpers for `debugLog(for:)`. + +@MainActor +extension UsageStore { + struct APIKeyDebugContext { + let label: String + let resolution: ProviderTokenResolution? + let configToken: String? + let hasEnvToken: Bool + let hasTokenAccount: Bool + } + + func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .openai) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .openai, + config: config) + return APIKeyDebugContext( + label: "OPENAI_API_KEY", + resolution: ProviderTokenResolver.openAIAPIResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: OpenAIAPISettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .azureopenai) + let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: processEnvironment, + provider: .azureopenai, + config: config) + return APIKeyDebugContext( + label: "AZURE_OPENAI_API_KEY", + resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .openrouter) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .openrouter, + config: config) + return APIKeyDebugContext( + label: "OPENROUTER_API_KEY", + resolution: ProviderTokenResolver.openRouterResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .elevenlabs) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .elevenlabs, + config: config) + return APIKeyDebugContext( + label: "ELEVENLABS_API_KEY", + resolution: ProviderTokenResolver.elevenLabsResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: ElevenLabsSettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } +} diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5550608a..b6c10084 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1295,70 +1295,6 @@ extension UsageStore { #endif } - private struct APIKeyDebugContext { - let label: String - let resolution: ProviderTokenResolution? - let configToken: String? - let hasEnvToken: Bool - let hasTokenAccount: Bool - } - - private func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openai) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openai, - config: config) - return APIKeyDebugContext( - label: "OPENAI_API_KEY", - resolution: ProviderTokenResolver.openAIAPIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenAIAPISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .azureopenai) - let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( - base: processEnvironment, - provider: .azureopenai, - config: config) - return APIKeyDebugContext( - label: "AZURE_OPENAI_API_KEY", - resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openrouter) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openrouter, - config: config) - return APIKeyDebugContext( - label: "OPENROUTER_API_KEY", - resolution: ProviderTokenResolver.openRouterResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .elevenlabs) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .elevenlabs, - config: config) - return APIKeyDebugContext( - label: "ELEVENLABS_API_KEY", - resolution: ProviderTokenResolver.elevenLabsResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: ElevenLabsSettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - private nonisolated static func apiKeyDebugLine(_ context: APIKeyDebugContext) -> String { self.apiKeyDebugLine( label: context.label, diff --git a/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift b/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift index d0056fc7..86ad48f2 100644 --- a/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift +++ b/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift @@ -71,7 +71,9 @@ public enum AccountIdentityComputer { // Upstream v0.28.0–v0.29.0 new providers. iOS 1.9 surfaces // these via single-account cards. Promote to Tier-A only if a // user files a cross-Mac merging request for them. - .azureopenai, .alibabatokenplan, .t3chat: + .azureopenai, .alibabatokenplan, .t3chat, + // Upstream 0.33 new provider. Same rationale as above. + .devin: // Non-Tier-A providers: no stable account model required by // iOS today. Return nil → iOS falls back to per-device legacy // bucket. If a future provider needs cross-Mac merging, add From a9890884fe26c102a07c1c4bb093295f3a4f12d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 12:04:28 +0000 Subject: [PATCH 47/51] Satisfy palette parity and parser-version audits for 0.33.1 sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add the Devin provider to the iOS raw color palette, mirroring the Mac descriptor color (70, 180, 130). - Bump parserLogicVersion 5 → 6: the merged upstream scanner changes Claude token attribution (native 1-hour cache-write split, dated historical long-context pricing) and moves Codex scans to the dedicated executor, so the pricingFingerprint must roll to invalidate caches written by the v0.32.4 parser. https://claude.ai/code/session_01Mji1HrQ5wkVndbmF3TF6b1 --- .../CodexBarMobile/Models/ProviderColorPalette.swift | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 11 ++++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift b/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift index 62d258f0..dd5a3a3d 100644 --- a/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift +++ b/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift @@ -119,6 +119,7 @@ enum ProviderColorPalette { (["groq", "groqcloud", "groqapi"], RawColor(red: 245 / 255, green: 104 / 255, blue: 68 / 255)), (["llmproxy"], RawColor(red: 36 / 255, green: 180 / 255, blue: 126 / 255)), (["deepgram"], RawColor(red: 0.49, green: 0.23, blue: 0.93)), + (["devin"], RawColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)), ] var table: [String: RawColor] = [:] diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 154d3640..3b2fddb7 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "7e11c9c5b31c513d" + static let value = "61473beda7b915f0" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index b2e0824f..41bf2887 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -405,6 +405,15 @@ enum CostUsagePricing { /// `CostUsageJsonl.swift` change vs origin/mobile-dev. /// /// History: + /// - `6` (0.33.1 sync): merged upstream v0.32.4→0.33.1-dev cost-scanner + /// changes — Claude parser now splits native 1-hour cache-write usage + /// (`ClaudeCostTokens.cacheCreation1h`) under corrected Claude pricing + /// (#1368/#1372), threads `pricingDate` for dated historical + /// long-context rates, and Codex scans moved to the dedicated scan + /// executor with reduced metadata work (#1392/#1430). The regenerated + /// parser hash rolls the Codex producerKey axis; this bump rolls the + /// pricingFingerprint so Claude caches (no producerKey) written by the + /// v0.32.4 parser are invalidated and re-scanned. /// - `5` (0.32.4.1): merged upstream v0.32.0→v0.32.4 Codex cost-scanner /// rewrite (new `CostUsageScanner+CodexFastJSON.swift`, reworked truncated-prefix /// handling, scan-perf changes). The regenerated parser hash rolls the Codex @@ -434,7 +443,7 @@ enum CostUsagePricing { /// in `parseCodexFile`. Bumping rolls every previous version's /// cache and re-scans with the fixed parser. /// - `1` (0.23.1): initial fingerprint contract. - static let parserLogicVersion = 5 + static let parserLogicVersion = 6 /// Stable string fingerprint of the pricing tables + parser logic. /// `CostUsageCacheIO.load` compares this against the value stored From 463053abbc6e5f50d3a8b2baa02dcd52637fabe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 12:39:34 +0000 Subject: [PATCH 48/51] Update Japanese quit_app test expectation to QuotaKit branding Upstream's language-preference smoke test asserts the upstream-branded Japanese quit label; the fork rebrands displayed localization values to QuotaKit, so the expectation follows the ja.lproj value. https://claude.ai/code/session_01Mji1HrQ5wkVndbmF3TF6b1 --- Tests/CodexBarTests/PreferencesPaneSmokeTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index 38848208..770f1775 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -87,7 +87,8 @@ struct PreferencesPaneSmokeTests { #expect(UserDefaults.standard.string(forKey: "appLanguage") == "ja") #expect(L("language_title") == "言語") #expect(L("start_at_login_title") == "ログイン時に起動") - #expect(L("quit_app") == "CodexBar を終了") + // Fork: ja.lproj displayed values are rebranded to QuotaKit. + #expect(L("quit_app") == "QuotaKit を終了") } private static func makeSettingsStore(suite: String) -> SettingsStore { From f2fdfcf2d4d147fd26d3bbfafbd905e1509cce2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:15:32 +0000 Subject: [PATCH 49/51] Adapt merged-icon deferral test to fork app-icon fallback Upstream's deferral test ends by asserting the deferred render produced an empty quota icon (primary=nil). The fork renders the QuotaKit app-icon fallback when no snapshot data is available, so the deferred render's signature is mode=appIcon with no primary field. Assert that instead; the deferral mechanics the test exists for are unchanged. https://claude.ai/code/session_01Mji1HrQ5wkVndbmF3TF6b1 --- Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index 397767f8..ff759175 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -301,7 +301,9 @@ struct StatusItemAnimationSignatureTests { controller.menuDidClose(menu) #expect(controller.animationDriver == nil) - #expect(controller.lastAppliedMergedIconRenderSignature?.contains("primary=nil") == true) + // Fork: with no snapshot data the deferred render applies the + // QuotaKit app-icon fallback, whose signature has no primary field. + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("mode=appIcon") == true) } @Test From 848538b268d25478d0a131befe92da82cce0095b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:57:03 +0000 Subject: [PATCH 50/51] Pin token-account coalescing test to single-fetch path The fork enables iCloud sync by default, which fans out one fetch per token account on a global refresh (Phase G CloudKit fix), so upstream's new assertion that exactly one fetch is in flight before the blocker drains saw two. Disable iCloud sync in this test so it exercises the single-selected-account path whose refresh coalescing upstream's assertion verifies; the fork's fan-out behavior keeps its own coverage in ShouldFetchAllTokenAccountsTests. https://claude.ai/code/session_01Mji1HrQ5wkVndbmF3TF6b1 --- .../StatusMenuTokenAccountSwitcherTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 1e505ce0..f63a5b76 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -101,6 +101,13 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false + // Fork: iCloud sync defaults on and fans out a fetch per token + // account, so the global refresh alone would put two fetches in + // flight. Disable it so this test exercises upstream's + // single-selected-account path and its refresh-coalescing + // assertion; the sync fan-out is covered by + // ShouldFetchAllTokenAccountsTests. + settings.iCloudSyncEnabled = false self.enableOnlyClaude(settings) settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") From 1633ee2b106acbb7e3ad9ee16fa479991786b76e Mon Sep 17 00:00:00 2001 From: ColumbusLabs <287001685+ColumbusLabs@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:30:58 -0400 Subject: [PATCH 51/51] Fix QuotaKit provider docs wording --- docs/devin.md | 6 +++--- docs/mimo.md | 6 +++--- docs/providers.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/devin.md b/docs/devin.md index df4f1469..4e5d2475 100644 --- a/docs/devin.md +++ b/docs/devin.md @@ -18,7 +18,7 @@ The Devin provider tracks included daily and weekly usage quotas from 3. Enable **Devin** in **Settings → Providers**. Automatic mode reads only the Devin session and organization metadata from Chrome localStorage. It does not scan other -browsers. CodexBar sends the session token only to `https://app.devin.ai`. +browsers. QuotaKit sends the session token only to `https://app.devin.ai`. ## Manual Auth @@ -33,11 +33,11 @@ Environment overrides: ## Data Source -CodexBar requests: +QuotaKit requests: ```text GET https://app.devin.ai/api//billing/quota/usage ``` The response supplies daily and weekly usage percentages plus reset timestamps. If Devin changes or expires the browser -session, sign in again and refresh CodexBar. +session, sign in again and refresh QuotaKit. diff --git a/docs/mimo.md b/docs/mimo.md index 772f334f..71cc3aeb 100644 --- a/docs/mimo.md +++ b/docs/mimo.md @@ -22,9 +22,9 @@ The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo consol 2. Enable **Xiaomi MiMo** 3. Leave **Cookie source** on **Auto** (recommended) -CodexBar imports cookies from these browsers in order: **Safari**, **Chrome** / **Chrome Beta** / **Chrome Canary**, **Firefox**, and **Microsoft Edge**. Switch to **Manual** and paste a `Cookie:` header if your active MiMo session lives in Arc, Brave, or another browser profile CodexBar does not auto-detect. +QuotaKit imports cookies from these browsers in order: **Safari**, **Chrome** / **Chrome Beta** / **Chrome Canary**, **Firefox**, and **Microsoft Edge**. Switch to **Manual** and paste a `Cookie:` header if your active MiMo session lives in Arc, Brave, or another browser profile QuotaKit does not auto-detect. -Safari cookie import may require granting CodexBar Full Disk Access in **System Settings → Privacy & Security**. +Safari cookie import may require granting QuotaKit Full Disk Access in **System Settings → Privacy & Security**. ### Manual cookie import (optional) @@ -49,7 +49,7 @@ Safari cookie import may require granting CodexBar Full Disk Access in **System ### “No Xiaomi MiMo browser session found” -Log in at `https://platform.xiaomimimo.com/#/console/balance` in Safari, Chrome, Firefox, or Edge, then refresh CodexBar. If your session lives in another browser, switch the MiMo provider to **Cookie source → Manual** and paste the `Cookie:` header instead. +Log in at `https://platform.xiaomimimo.com/#/console/balance` in Safari, Chrome, Firefox, or Edge, then refresh QuotaKit. If your session lives in another browser, switch the MiMo provider to **Cookie source → Manual** and paste the `Cookie:` header instead. ### “Xiaomi MiMo requires the api-platform_serviceToken and userId cookies” diff --git a/docs/providers.md b/docs/providers.md index 29b838da..a891bd33 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 49 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +QuotaKit currently registers 49 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current)