From 6fe01054424b4742b3460e306f57d03c027d5ea2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 28 May 2026 23:20:38 +0100 Subject: [PATCH 01/79] chore: start 0.31.1 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7b9a0e..ba14e490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.31.1 — Unreleased + ## 0.31.0 — 2026-05-28 ### Changed diff --git a/version.env b/version.env index 608c655b..511f4225 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.31.0 -BUILD_NUMBER=73 +MARKETING_VERSION=0.31.1 +BUILD_NUMBER=74 From 4c9e6a87009b929b928586e39520267570c1bd6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 10:06:36 +0100 Subject: [PATCH 02/79] feat: add provider search --- .../PreferencesProviderSidebarView.swift | 104 +++++++++++++----- .../CodexBar/PreferencesProvidersPane.swift | 46 +++++++- .../Resources/ca.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 3 + .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/sv.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 3 + .../ProvidersPaneCoverageTests.swift | 24 ++++ 10 files changed, 163 insertions(+), 32 deletions(-) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index e3f21cf0..59e21ffb 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -5,43 +5,58 @@ import UniformTypeIdentifiers @MainActor struct ProviderSidebarListView: View { let providers: [UsageProvider] + let orderedProviders: [UsageProvider] @Bindable var store: UsageStore let isEnabled: (UsageProvider) -> Binding let subtitle: (UsageProvider) -> String + @Binding var searchText: String @Binding var selection: UsageProvider? let moveProviders: (IndexSet, Int) -> Void @State private var draggingProvider: UsageProvider? var body: some View { - ScrollView { - VStack(spacing: 0) { - ForEach(self.providers, id: \.self) { provider in - ProviderSidebarRowView( - provider: provider, - store: self.store, - isEnabled: self.isEnabled(provider), - subtitle: self.subtitle(provider), - draggingProvider: self.$draggingProvider) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill( - self.selection == provider - ? Color(nsColor: .selectedContentBackgroundColor) - : Color.clear) - .padding(.horizontal, 4)) - .contentShape(Rectangle()) - .onTapGesture { self.selection = provider } - .onDrop( - of: [UTType.plainText], - delegate: ProviderSidebarDropDelegate( - item: provider, - providers: self.providers, - dragging: self.$draggingProvider, - moveProviders: self.moveProviders)) + VStack(spacing: 8) { + ProviderSidebarSearchField(searchText: self.$searchText) + .padding(.horizontal, 8) + .padding(.top, 8) + + ScrollView { + VStack(spacing: 0) { + if self.providers.isEmpty { + Text(L("No matching providers")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 80) + } + + ForEach(self.providers, id: \.self) { provider in + ProviderSidebarRowView( + provider: provider, + store: self.store, + isEnabled: self.isEnabled(provider), + subtitle: self.subtitle(provider), + draggingProvider: self.$draggingProvider) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + self.selection == provider + ? Color(nsColor: .selectedContentBackgroundColor) + : Color.clear) + .padding(.horizontal, 4)) + .contentShape(Rectangle()) + .onTapGesture { self.selection = provider } + .onDrop( + of: [UTType.plainText], + delegate: ProviderSidebarDropDelegate( + item: provider, + providers: self.orderedProviders, + dragging: self.$draggingProvider, + moveProviders: self.moveProviders)) + } } + .padding(.vertical, 4) } - .padding(.vertical, 4) } .background( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) @@ -54,6 +69,41 @@ struct ProviderSidebarListView: View { } } +private struct ProviderSidebarSearchField: View { + @Binding var searchText: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + + TextField(L("Search providers"), text: self.$searchText) + .textFieldStyle(.plain) + + if !self.searchText.isEmpty { + Button { + self.searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .accessibilityLabel(L("Clear")) + } + .buttonStyle(.plain) + } + } + .font(.callout) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor))) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1)) + } +} + @MainActor private struct ProviderSidebarRowView: View { let provider: UsageProvider diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 22423d90..55d11441 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -16,12 +16,20 @@ struct ProvidersPane: View { @State private var activeConfirmation: ProviderSettingsConfirmationState? @State private var codexAccountsNotice: CodexAccountsSectionNotice? @State private var isAuthenticatingLiveCodexAccount = false + @State private var providerSearchText = "" @State private var selectedProvider: UsageProvider? private var providers: [UsageProvider] { self.settings.orderedProviders() } + private var filteredProviders: [UsageProvider] { + Self.filteredProviders( + self.providers, + query: self.providerSearchText, + displayName: { provider in self.store.metadata(for: provider).displayName }) + } + init( settings: SettingsStore, store: UsageStore, @@ -45,16 +53,18 @@ struct ProvidersPane: View { var body: some View { HStack(alignment: .top, spacing: 16) { ProviderSidebarListView( - providers: self.providers, + providers: self.filteredProviders, + orderedProviders: self.providers, store: self.store, isEnabled: { provider in self.binding(for: provider) }, subtitle: { provider in self.providerSubtitle(provider) }, + searchText: self.$providerSearchText, selection: self.$selectedProvider, moveProviders: { fromOffsets, toOffset in self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset) }) - if let provider = self.selectedProvider ?? self.providers.first { + if let provider = self.selectedVisibleProvider { ProviderDetailView( provider: provider, store: self.store, @@ -116,6 +126,9 @@ struct ProvidersPane: View { .onChange(of: self.providers) { _, _ in self.ensureSelection() } + .onChange(of: self.providerSearchText) { _, _ in + self.ensureSelection() + } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in self.runSettingsDidBecomeActiveHooks() } @@ -142,15 +155,38 @@ struct ProvidersPane: View { }) } + private var selectedVisibleProvider: UsageProvider? { + let filteredProviders = self.filteredProviders + if let selected = self.selectedProvider, filteredProviders.contains(selected) { + return selected + } + return filteredProviders.first + } + + static func filteredProviders( + _ providers: [UsageProvider], + query: String, + displayName: (UsageProvider) -> String) -> [UsageProvider] + { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return providers } + + return providers.filter { provider in + displayName(provider).localizedCaseInsensitiveContains(trimmedQuery) + || provider.rawValue.localizedCaseInsensitiveContains(trimmedQuery) + } + } + private func ensureSelection() { - guard !self.providers.isEmpty else { + let filteredProviders = self.filteredProviders + guard !filteredProviders.isEmpty else { self.selectedProvider = nil return } - if let selected = self.selectedProvider, self.providers.contains(selected) { + if let selected = self.selectedProvider, filteredProviders.contains(selected) { return } - self.selectedProvider = self.providers.first + self.selectedProvider = filteredProviders.first } private func triggerRefresh(for provider: UsageProvider) { diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 9fc46ba2..6c7a760a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -913,3 +913,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no enganxa el valor de __Secure-next-auth.session-token"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no enganxa el valor del token kimi-auth"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no enganxa només el valor de session_id"; +"Clear" = "Esborra"; +"No matching providers" = "No hi ha proveïdors coincidents"; +"Search providers" = "Cerca proveïdors"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 324589f4..6030d94f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1057,3 +1057,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nor paste the __Secure-next-auth.session-token value"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nor paste the kimi-auth token value"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nor paste just the session_id value"; +"Clear" = "Clear"; +"No matching providers" = "No matching providers"; +"Search providers" = "Search providers"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 958bd89d..293183e7 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -913,3 +913,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no pega el valor de __Secure-next-auth.session-token"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no pega el valor del token kimi-auth"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no pega solo el valor de session_id"; +"Clear" = "Borrar"; +"No matching providers" = "No hay proveedores coincidentes"; +"Search providers" = "Buscar proveedores"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 5210e0c6..bf843334 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1056,3 +1056,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nou cole o valor de __Secure-next-auth.session-token"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nou cole o valor do token kimi-auth"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou cole apenas o valor de session_id"; +"Clear" = "Limpar"; +"No matching providers" = "Nenhum provedor correspondente"; +"Search providers" = "Buscar provedores"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ecd33813..429d98e0 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1055,3 +1055,6 @@ "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Synthetic-API-nyckel så att användning kan hämtas. Klicka på OK för att fortsätta."; "CodexBar could not update managed account storage." = "CodexBar kunde inte uppdatera hanterad kontolagring."; "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Augment-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Clear" = "Rensa"; +"No matching providers" = "Inga matchande leverantörer"; +"Search providers" = "Sök leverantörer"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index c0319f32..dfed63ad 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1030,3 +1030,6 @@ "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 token 值"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n或只粘贴 session_id 值"; +"Clear" = "清除"; +"No matching providers" = "没有匹配的提供商"; +"Search providers" = "搜索提供商"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index f87b1583..46034fcf 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -926,3 +926,6 @@ "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 token 值"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n或只貼上 session_id 值"; +"Clear" = "清除"; +"No matching providers" = "沒有相符的提供者"; +"Search providers" = "搜尋提供者"; diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index b2629ece..7c01c3e2 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -27,6 +27,30 @@ struct ProvidersPaneCoverageTests { #expect(!copilotDescriptor.showsOrganizationField) } + @Test + func `provider search filters display names and raw ids`() { + let providers: [UsageProvider] = [.codex, .claude, .openrouter, .deepseek] + let names: [UsageProvider: String] = [ + .codex: "Codex", + .claude: "Claude", + .openrouter: "OpenRouter", + .deepseek: "DeepSeek", + ] + + #expect( + ProvidersPane.filteredProviders(providers, query: " ", displayName: { names[$0] ?? $0.rawValue }) + == providers) + #expect( + ProvidersPane.filteredProviders(providers, query: "router", displayName: { names[$0] ?? $0.rawValue }) + == [.openrouter]) + #expect( + ProvidersPane.filteredProviders(providers, query: "CLA", displayName: { names[$0] ?? $0.rawValue }) + == [.claude]) + #expect( + ProvidersPane.filteredProviders(providers, query: "deepseek", displayName: { _ in "API" }) + == [.deepseek]) + } + @Test func `open router menu bar metric picker shows only automatic and primary`() { Self.withEnglishLocalization { From b3c0d57eb08d13ad7e98317caedbdc31fa4c0631 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 19:06:34 +0100 Subject: [PATCH 03/79] docs: update changelog for provider search --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba14e490..ccb94e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.31.1 — Unreleased +### Added +- Settings: add search to the Providers pane so large provider lists can be filtered by name or id (#1184). Thanks @046081-dotcom! + ## 0.31.0 — 2026-05-28 ### Changed From 01528f5ee6ebd1d6df2c42d10a195d48e2f32549 Mon Sep 17 00:00:00 2001 From: Yash Raj Pandey <55940078+devYRPauli@users.noreply.github.com> Date: Fri, 29 May 2026 17:46:44 -0400 Subject: [PATCH 04/79] fix: add Opus 4.8 Claude pricing fallback Fixes #1210.\n\nAdds the missing built-in Claude Opus 4.8 pricing fallback plus regression coverage, and records the contributor credit in the changelog. --- CHANGELOG.md | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 10 ++++ .../CodexBarTests/CostUsagePricingTests.swift | 16 +++++++ ...ostUsageScannerClaudeRegressionTests.swift | 48 +++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb94e98..bbaf770e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Localization: add Swedish as a selectable app language (#1186). Thanks @yeager! ### Fixed +- Claude: add Opus 4.8 to the built-in pricing fallback so stale models.dev caches still show token cost (#1214, fixes #1210). Thanks @devYRPauli! - Cost history: make token-cost JSONL scans cancellation-aware so quitting, forced refreshes, and account switches can stop stale scans sooner. - Codex: show Spark 5-hour and weekly usage as separate quota lanes in Codex breakdowns (#1201). - Codex: show captured `codex login` output when managed Add Account fails so users can recover from account-selection or OAuth failures (#1199). Thanks @chapati23! diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index aecdf43d..a6e62fab 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 = "0d6c12b99dd77d4e" + static let value = "deae0d501740801d" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 057098b0..410fd142 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -275,6 +275,16 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: nil, cacheCreationInputCostPerTokenAboveThreshold: nil, cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-8": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 4d4ea170..a9a90d2a 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -360,6 +360,22 @@ struct CostUsagePricingTests { #expect(cost == expected) } + @Test + func `claude cost supports opus48`() throws { + // Point at a fresh, empty cache root so the models.dev lookup misses and this + // exercises the built-in fallback table specifically — not a local cache hit. + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-opus-4-8", + inputTokens: 10, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (10.0 * 5e-6) + (5.0 * 2.5e-5) + #expect(cost == expected) + } + @Test func `claude cost returns nil for unknown models`() { let cost = CostUsagePricing.claudeCostUSD( diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift index 7600dfc0..5cacd77a 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift @@ -158,6 +158,54 @@ struct CostUsageScannerClaudeRegressionTests { #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) } + /// Regression for https://github.com/steipete/CodexBar/issues/1210: an Opus 4.8 row + /// priced to an empty cost because the built-in Claude pricing table had no + /// claude-opus-4-8 entry (used when the models.dev cache is missing/stale). + @Test + func `claude opus 4 8 issue row gets priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 29) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/opus-48.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-opus-4-8", + "id": "msg_01NrvWoSMk2Eig6vkCgyRZqc", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 6, + "cache_creation_input_tokens": 1389, + "cache_read_input_tokens": 50352, + "output_tokens": 3922, + ], + ], + "requestId": "req_011CaLLcFQD712ZnCTxHFk71", + "type": "assistant", + "timestamp": "2026-05-29T07:51:34.428Z", + "sessionId": "39d4b923-8273-4c35-ad9c-e098395286f1", + ], + ])) + + 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-opus-4-8") + #expect(parsed.rows[0].input == 6) + #expect(parsed.rows[0].cacheCreate == 1389) + #expect(parsed.rows[0].cacheRead == 50352) + #expect(parsed.rows[0].output == 3922) + + let expected = 0.13193725 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + @Test func `claude streaming keeps the last cumulative chunk`() throws { let env = try CostUsageTestEnvironment() From 96745231187f4293ba1c0b90be8ed4374176b12d Mon Sep 17 00:00:00 2001 From: soumikbhatta <29822748+soumikbhatta@users.noreply.github.com> Date: Fri, 29 May 2026 18:29:35 -0400 Subject: [PATCH 05/79] fix: preserve Codex web credits-only refresh Fixes #1204.\n\nPreserves authorized credits-only OpenAI dashboard snapshots instead of reporting missing usage when rate-limit windows are absent. Includes regression coverage and changelog credit. --- CHANGELOG.md | 1 + .../Codex/CodexWebDashboardStrategy.swift | 27 ++++++++++- ...exWebDashboardStrategyAuthorityTests.swift | 48 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaf770e..292f274e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixed - Claude: add Opus 4.8 to the built-in pricing fallback so stale models.dev caches still show token cost (#1214, fixes #1210). Thanks @devYRPauli! +- Codex: preserve authorized web dashboard credits-only snapshots instead of treating missing usage windows as a failed refresh (#1206, fixes #1204). Thanks @soumikbhatta! - Cost history: make token-cost JSONL scans cancellation-aware so quitting, forced refreshes, and account switches can stop stale scans sooner. - Codex: show Spark 5-hour and weekly usage as separate quota lanes in Codex breakdowns (#1201). - Codex: show captured `codex login` output when managed Add Account fails so users can recover from account-selection or OAuth failures (#1199). Thanks @chapati23! diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index b613a54e..5b21d120 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -217,10 +217,15 @@ extension CodexWebDashboardStrategy { switch decision.disposition { case .attach: let attachedAccountEmail = CodexCLIDashboardAuthorityContext.attachmentEmail(from: input) - guard let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail) else { + let credits = dashboard.toCreditsSnapshot() + let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail) + ?? Self.makeCreditsOnlyUsageSnapshot( + dashboard: dashboard, + attachedAccountEmail: attachedAccountEmail, + credits: credits) + guard let usage else { throw OpenAIWebCodexError.missingUsage } - let credits = dashboard.toCreditsSnapshot() if let attachedAccountEmail { OpenAIDashboardCacheStore.save(OpenAIDashboardCache( accountEmail: attachedAccountEmail, @@ -240,6 +245,24 @@ extension CodexWebDashboardStrategy { } } + private static func makeCreditsOnlyUsageSnapshot( + dashboard: OpenAIDashboardSnapshot, + attachedAccountEmail: String?, + credits: CreditsSnapshot?) -> UsageSnapshot? + { + guard credits != nil else { return nil } + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: dashboard.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: attachedAccountEmail ?? dashboard.signedInEmail, + accountOrganization: nil, + loginMethod: dashboard.accountPlan)) + } + private struct OpenAIWebDashboardFetchResult { let dashboard: OpenAIDashboardSnapshot let routingTargetEmail: String? diff --git a/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift b/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift index 631b8cbf..90d15059 100644 --- a/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift +++ b/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift @@ -31,6 +31,39 @@ struct CodexWebDashboardStrategyAuthorityTests { #expect(result.credits?.remaining == 42) } + @Test + func `web dashboard attach preserves credits when usage limits are absent`() throws { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let authHome = try self.makeAuthHome( + email: "owner@example.com", + accountId: "acct-owner") + defer { try? FileManager.default.removeItem(at: authHome) } + + let context = self.makeContext( + authHome: authHome, + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-owner"), + normalizedEmail: "owner@example.com"), + ]) + let dashboard = self.makeDashboardWithoutUsageLimits(email: "owner@example.com") + + let result = try CodexWebDashboardStrategy.makeAuthorizedDashboardResultForTesting( + dashboard: dashboard, + context: context, + routingTargetEmail: "route@example.com") + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.updatedAt == dashboard.updatedAt) + #expect(result.usage.identity?.accountEmail == "owner@example.com") + #expect(result.usage.identity?.loginMethod == "pro") + #expect(result.credits?.remaining == 42) + #expect(result.dashboard == dashboard) + } + @Test func `web dashboard display only throws typed policy error`() throws { OpenAIDashboardCacheStore.clear() @@ -332,6 +365,21 @@ struct CodexWebDashboardStrategyAuthorityTests { updatedAt: Date(timeIntervalSince1970: 2000)) } + private func makeDashboardWithoutUsageLimits(email: String) -> OpenAIDashboardSnapshot { + OpenAIDashboardSnapshot( + signedInEmail: email, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: nil, + creditsRemaining: 42, + accountPlan: "pro", + updatedAt: Date(timeIntervalSince1970: 2000)) + } + private func makeAuthHome(email: String?, accountId: String? = nil) throws -> URL { let homeURL = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, From 06b7de126f1a9b06ab17ccde8b1b25d599fe0ce2 Mon Sep 17 00:00:00 2001 From: Ellis Nieuwpoort <121954036+enieuwy@users.noreply.github.com> Date: Sat, 30 May 2026 07:06:10 +0800 Subject: [PATCH 06/79] fix: bound serve requests and coalesce cache misses Adds a configurable codexbar serve request timeout, returns 504 for timed-out usage/cost requests without caching them, and single-flights concurrent cache misses.\n\nMaintainer follow-up documented --request-timeout, added a coalesced-timeout regression test, and updated the changelog.\n\nProof: swift test --filter CLIServeRouterTests; swift test; make check; provider-free CodexBarCLI serve smoke; autoreview clean; CI run 26665641722 green. --- CHANGELOG.md | 1 + Sources/CodexBarCLI/CLILocalHTTPServer.swift | 8 +- Sources/CodexBarCLI/CLIServeCommand.swift | 206 ++++++++++++++++-- Tests/CodexBarTests/CLIServeRouterTests.swift | 175 +++++++++++++++ docs/cli.md | 2 + 5 files changed, 373 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 292f274e..70f5604e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Localization: add Swedish as a selectable app language (#1186). Thanks @yeager! ### Fixed +- CLI: bound `codexbar serve` requests with a configurable timeout and coalesce concurrent cache misses so hung `/usage` callers no longer stampede provider refreshes (#1208). Thanks @enieuwy! - Claude: add Opus 4.8 to the built-in pricing fallback so stale models.dev caches still show token cost (#1214, fixes #1210). Thanks @devYRPauli! - Codex: preserve authorized web dashboard credits-only snapshots instead of treating missing usage windows as a failed refresh (#1206, fixes #1204). Thanks @soumikbhatta! - Cost history: make token-cost JSONL scans cancellation-aware so quitting, forced refreshes, and account switches can stop stale scans sooner. diff --git a/Sources/CodexBarCLI/CLILocalHTTPServer.swift b/Sources/CodexBarCLI/CLILocalHTTPServer.swift index 65a486db..31dc8b62 100644 --- a/Sources/CodexBarCLI/CLILocalHTTPServer.swift +++ b/Sources/CodexBarCLI/CLILocalHTTPServer.swift @@ -127,14 +127,14 @@ enum CLILocalHTTPRequestParseError: Error, Equatable { case disallowedHost } -enum CLIHTTPStatus { +enum CLIHTTPStatus: Sendable { case ok case badRequest case forbidden case notFound case methodNotAllowed case internalServerError - + case gatewayTimeout var code: Int { switch self { case .ok: 200 @@ -143,6 +143,7 @@ enum CLIHTTPStatus { case .notFound: 404 case .methodNotAllowed: 405 case .internalServerError: 500 + case .gatewayTimeout: 504 } } @@ -154,11 +155,12 @@ enum CLIHTTPStatus { case .notFound: "Not Found" case .methodNotAllowed: "Method Not Allowed" case .internalServerError: "Internal Server Error" + case .gatewayTimeout: "Gateway Timeout" } } } -struct CLILocalHTTPResponse { +struct CLILocalHTTPResponse: Sendable { let status: CLIHTTPStatus let body: Data let contentType: String diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 569780b4..460ee310 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -17,6 +17,11 @@ struct ServeOptions: CommanderParsable { @Option(name: .long("refresh-interval"), help: "Response cache TTL in seconds (default: 60)") var refreshInterval: Double? + + @Option( + name: .long("request-timeout"), + help: "Total per-request deadline in seconds; 0 disables (default: 30)") + var requestTimeout: Double? } enum CLIServeRoute: Equatable { @@ -60,15 +65,82 @@ private struct ServeHealthPayload: Encodable { let status: String } -private actor CLIServeResponseCache { +private final class CLIServeDeadlineState: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var workTask: Task? + private var timeoutTask: Task? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func setWorkTask(_ task: Task) { + var shouldCancel = false + self.lock.lock() + if self.continuation == nil { + shouldCancel = true + } else { + self.workTask = task + } + self.lock.unlock() + + if shouldCancel { + task.cancel() + } + } + + func setTimeoutTask(_ task: Task) { + var shouldCancel = false + self.lock.lock() + if self.continuation == nil { + shouldCancel = true + } else { + self.timeoutTask = task + } + self.lock.unlock() + + if shouldCancel { + task.cancel() + } + } + + func finish(_ response: CLILocalHTTPResponse, cancelWork: Bool, cancelTimeout: Bool) { + let continuation: CheckedContinuation? + let workTask: Task? + let timeoutTask: Task? + + self.lock.lock() + continuation = self.continuation + self.continuation = nil + workTask = cancelWork ? self.workTask : nil + timeoutTask = cancelTimeout ? self.timeoutTask : nil + self.workTask = nil + self.timeoutTask = nil + self.lock.unlock() + + workTask?.cancel() + timeoutTask?.cancel() + continuation?.resume(returning: response) + } +} + +private enum CLIServeCacheLookup { + case response(CLILocalHTTPResponse) + case miss +} + +actor CLIServeResponseCache { private struct Entry { let expiresAt: Date let response: CLILocalHTTPResponse } private var entries: [String: Entry] = [:] + private var inFlightKeys: Set = [] + private var waiters: [String: [CheckedContinuation]] = [:] - func response(for key: String, now: Date) -> CLILocalHTTPResponse? { + private func response(for key: String, now: Date) -> CLILocalHTTPResponse? { guard let entry = self.entries[key] else { return nil } guard entry.expiresAt > now else { self.entries[key] = nil @@ -77,7 +149,39 @@ private actor CLIServeResponseCache { return entry.response } - func store(_ response: CLILocalHTTPResponse, for key: String, ttl: TimeInterval, now: Date) { + fileprivate func responseOrStartFetch(for key: String, now: Date) async -> CLIServeCacheLookup { + if let cached = self.response(for: key, now: now) { + return .response(cached) + } + + if self.inFlightKeys.contains(key) { + return await withCheckedContinuation { continuation in + self.waiters[key, default: []].append(continuation) + } + } + + self.inFlightKeys.insert(key) + return .miss + } + + fileprivate func completeFetch( + _ response: CLILocalHTTPResponse, + for key: String, + ttl: TimeInterval, + now: Date, + shouldCache: Bool) + { + if shouldCache { + self.store(response, for: key, ttl: ttl, now: now) + } + self.inFlightKeys.remove(key) + let waiters = self.waiters.removeValue(forKey: key) ?? [] + for waiter in waiters { + waiter.resume(returning: .response(response)) + } + } + + private func store(_ response: CLILocalHTTPResponse, for key: String, ttl: TimeInterval, now: Date) { guard ttl > 0, response.status == .ok else { return } self.entries[key] = Entry(expiresAt: now.addingTimeInterval(ttl), response: response) } @@ -86,6 +190,7 @@ private actor CLIServeResponseCache { private enum CLIServeArgumentError: LocalizedError { case invalidPort case invalidRefreshInterval + case invalidRequestTimeout case invalidProvider(String) var errorDescription: String? { @@ -94,6 +199,8 @@ private enum CLIServeArgumentError: LocalizedError { "--port must be between 1 and 65535." case .invalidRefreshInterval: "--refresh-interval must be zero or greater." + case .invalidRequestTimeout: + "--request-timeout must be zero or greater." case let .invalidProvider(provider): "Unknown provider '\(provider)'." } @@ -101,10 +208,13 @@ private enum CLIServeArgumentError: LocalizedError { } extension CodexBarCLI { + static let defaultServeRequestTimeout: TimeInterval = 30 + static func runServe(_ values: ParsedValues) async { let output = CLIOutputPreferences(format: .json, jsonOnly: true, pretty: false) let port = Self.decodeServePort(from: values) let refreshInterval = Self.decodeServeRefreshInterval(from: values) + let requestTimeout = Self.decodeServeRequestTimeout(from: values) guard let port else { Self.exit( @@ -122,6 +232,14 @@ extension CodexBarCLI { kind: .args) } + guard let requestTimeout else { + Self.exit( + code: .failure, + message: CLIServeArgumentError.invalidRequestTimeout.localizedDescription, + output: output, + kind: .args) + } + let config = Self.loadConfig(output: output) let cache = CLIServeResponseCache() let server = CLILocalHTTPServer(host: "127.0.0.1", port: port) { request in @@ -129,7 +247,8 @@ extension CodexBarCLI { request, config: config, cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) } do { @@ -167,11 +286,25 @@ extension CodexBarCLI { return parsed } + static func decodeServeRequestTimeout(from values: ParsedValues) -> TimeInterval? { + let raw = values.options["requestTimeout"]?.last + let parsed: Double + if let raw { + guard let value = Double(raw) else { return nil } + parsed = value + } else { + parsed = Self.defaultServeRequestTimeout + } + guard parsed >= 0 else { return nil } + return parsed + } + private static func handleServeRequest( _ request: CLILocalHTTPRequest, config: CodexBarConfig, cache: CLIServeResponseCache, - refreshInterval: TimeInterval) async -> CLILocalHTTPResponse + refreshInterval: TimeInterval, + requestTimeout: TimeInterval) async -> CLILocalHTTPResponse { let route: CLIServeRoute do { @@ -192,7 +325,8 @@ extension CodexBarCLI { return await Self.cachedServeResponse( key: "usage:\(provider ?? "")", cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) { await Self.serveUsage(provider: provider, config: config) } @@ -200,29 +334,69 @@ extension CodexBarCLI { return await Self.cachedServeResponse( key: "cost:\(provider ?? "")", cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) { await Self.serveCost(provider: provider, config: config) } } } - private static func cachedServeResponse( + static func cachedServeResponse( key: String, cache: CLIServeResponseCache, refreshInterval: TimeInterval, - makeResponse: () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse + requestTimeout: TimeInterval = CodexBarCLI.defaultServeRequestTimeout, + makeResponse: @Sendable @escaping () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse { - let now = Date() - if let cached = await cache.response(for: key, now: now) { - return cached + switch await cache.responseOrStartFetch(for: key, now: Date()) { + case let .response(response): + return response + case .miss: + let response = await Self.serveResponseWithDeadline(seconds: requestTimeout) { + await makeResponse() + } + await cache.completeFetch( + response, + for: key, + ttl: refreshInterval, + now: Date(), + shouldCache: Self.shouldCacheServeResponse(response)) + return response } + } - let response = await makeResponse() - if Self.shouldCacheServeResponse(response) { - await cache.store(response, for: key, ttl: refreshInterval, now: now) + private static func serveResponseWithDeadline( + seconds timeout: TimeInterval, + makeResponse: @Sendable @escaping () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse + { + let clampedTimeout = min(max(timeout, 0), 86400) + guard clampedTimeout > 0 else { + return await makeResponse() + } + let nanoseconds = max(1, UInt64((clampedTimeout * 1_000_000_000).rounded(.up))) + + return await withCheckedContinuation { continuation in + let state = CLIServeDeadlineState(continuation: continuation) + let workTask = Task { + let response = await makeResponse() + state.finish(response, cancelWork: false, cancelTimeout: true) + } + state.setWorkTask(workTask) + + let timeoutTask = Task { + do { + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return + } + state.finish( + Self.serveError(status: .gatewayTimeout, message: "request timed out"), + cancelWork: true, + cancelTimeout: false) + } + state.setTimeoutTask(timeoutTask) } - return response } static func shouldCacheServeResponse(_ response: CLILocalHTTPResponse) -> Bool { diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index 84149ed5..4b21f61b 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -105,6 +105,27 @@ struct CLIServeRouterTests { positional: [], options: [:], flags: [])) == 60) + + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["soon"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["-0.5"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["0"]], + flags: [])) == 0) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["12.5"]], + flags: [])) == 12.5) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == 30) } @Test @@ -124,6 +145,139 @@ struct CLIServeRouterTests { #expect(!CodexBarCLI.shouldCacheServeResponse(routeError)) } + @Test + func `serve cache coalesces concurrent cache misses`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let responses = await withTaskGroup(of: CLILocalHTTPResponse.self) { group -> [CLILocalHTTPResponse] in + for _ in 0..<5 { + group.addTask { + await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + try? await Task.sleep(nanoseconds: 50_000_000) + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + } + } + + var responses: [CLILocalHTTPResponse] = [] + for await response in group { + responses.append(response) + } + return responses + } + + #expect(await counter.current() == 1) + #expect(Set(responses.map(Self.bodyString)).count == 1) + #expect(responses.allSatisfy { $0.status == .ok }) + #expect(responses.allSatisfy { Self.bodyString($0).contains("\"call\":1") }) + } + + @Test + func `serve cache does not cache timeouts and recovers on next success`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let timeout = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 0.01) + { + _ = await counter.increment() + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[{\"provider\":\"codex\",\"call\":1}]") + } + + #expect(timeout.status == .gatewayTimeout) + #expect(Self.bodyString(timeout).contains("request timed out")) + + let success = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + + #expect(success.status == .ok) + #expect(Self.bodyString(success).contains("\"call\":2")) + + let cached = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + + #expect(cached.status == .ok) + #expect(Self.bodyString(cached) == Self.bodyString(success)) + #expect(await counter.current() == 2) + } + + @Test + func `serve cache resumes coalesced waiters on timeout`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let responses = await withTaskGroup(of: CLILocalHTTPResponse.self) { group -> [CLILocalHTTPResponse] in + for _ in 0..<4 { + group.addTask { + await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 0.01) + { + _ = await counter.increment() + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[{\"provider\":\"codex\"}]") + } + } + } + + var responses: [CLILocalHTTPResponse] = [] + for await response in group { + responses.append(response) + } + return responses + } + + #expect(await counter.current() == 1) + #expect(responses.count == 4) + #expect(responses.allSatisfy { $0.status == .gatewayTimeout }) + #expect(responses.allSatisfy { Self.bodyString($0).contains("request timed out") }) + } + + @Test + func `serve request timeout zero disables the deadline`() async { + let cache = CLIServeResponseCache() + + let response = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0, + requestTimeout: 0) + { + try? await Task.sleep(nanoseconds: 80_000_000) + return Self.response("[{\"provider\":\"codex\",\"slow\":true}]") + } + + #expect(response.status == .ok) + #expect(Self.bodyString(response).contains("\"slow\":true")) + } + private static func parsedRequest(host: String) throws -> CLILocalHTTPRequest { let raw = "GET /usage?provider=claude HTTP/1.1\r\nHost: \(host)\r\n\r\n" return try CLILocalHTTPRequest.parse(Data(raw.utf8)).get() @@ -137,4 +291,25 @@ struct CLIServeRouterTests { #expect(error == expected) } } + + private static func response(_ body: String, status: CLIHTTPStatus = .ok) -> CLILocalHTTPResponse { + CLILocalHTTPResponse(status: status, body: Data(body.utf8)) + } + + private static func bodyString(_ response: CLILocalHTTPResponse) -> String { + String(data: response.body, encoding: .utf8) ?? "" + } +} + +private actor ServeTestCounter { + private var value = 0 + + func increment() -> Int { + self.value += 1 + return self.value + } + + func current() -> Int { + self.value + } } diff --git a/docs/cli.md b/docs/cli.md index c9a01015..9bdb386b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,6 +47,7 @@ See `docs/configuration.md` for the schema. - `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON. - `--port ` defaults to `8080`. - `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL. + - `--request-timeout ` defaults to `30` and bounds each request before returning `504 Gateway Timeout`; use `0` to keep waiting indefinitely. - v1 binds to `127.0.0.1` only and rejects non-loopback `Host` headers. It does not expose remote bind, auth, CORS, TLS, or daemon mode. - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`. - Codex usage responses include every visible Codex account, matching the menu bar switcher. @@ -120,6 +121,7 @@ codexbar cost # local cost usage (default 30-day window + to codexbar cost --days 90 # choose a 1...365 day cost window codexbar cost --provider claude --format json --pretty codexbar serve --port 8080 # localhost HTTP JSON server +codexbar serve --request-timeout 0 # disable serve request deadlines COPILOT_API_TOKEN=... codexbar --provider copilot --format json --pretty codexbar --status # include status page indicator/description codexbar --provider codex --source oauth --format json --pretty From 72716d9fd545b6a5cfefae7f9828929b3b9af6f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 02:03:10 +0200 Subject: [PATCH 07/79] fix: bound OpenAI WebKit refresh lifecycle Cancel OpenAI WebKit dashboard navigation and polling promptly, cancel stale background refresh work during invalidation, and avoid immediate background WebView retry after timeouts while keeping manual retry behavior. --- CHANGELOG.md | 3 + Sources/CodexBar/UsageStore+OpenAIWeb.swift | 27 ++++++- Sources/CodexBar/UsageStore.swift | 1 + .../OpenAIWeb/OpenAIDashboardFetcher.swift | 40 ++++++---- .../OpenAIDashboardNavigationDelegate.swift | 4 + .../OpenAIDashboardWebViewCache.swift | 57 ++++++++++++-- ...ntScopedRefreshDashboardCleanupTests.swift | 2 +- .../CodexAccountScopedRefreshTests.swift | 6 +- ...odexBackgroundRefreshCoalescingTests.swift | 2 +- .../CodexManagedOpenAIWebRefreshTests.swift | 77 +++++++++++++++++-- .../CodexManagedOpenAIWebTestSupport.swift | 2 +- .../CodexManagedOpenAIWebTests.swift | 8 +- ...enAIDashboardNavigationDelegateTests.swift | 16 ++++ 13 files changed, 204 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f5604e..50a07baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Added - Settings: add search to the Providers pane so large provider lists can be filtered by name or id (#1184). Thanks @046081-dotcom! +### Fixed +- Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). + ## 0.31.0 — 2026-05-28 ### Changed diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 3b75b446..e9b233ec 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -26,6 +26,7 @@ extension UsageStore { let expectedGuard: CodexAccountScopedRefreshGuard? let refreshTaskToken: UUID let allowCodexUsageBackfill: Bool + let force: Bool } private struct OpenAIDashboardCookieImportRequest { @@ -402,7 +403,8 @@ extension UsageStore { allowCurrentSnapshotFallback: allowCurrentSnapshotFallback, expectedGuard: expectedGuard, refreshTaskToken: taskToken, - allowCodexUsageBackfill: allowCodexUsageBackfill) + allowCodexUsageBackfill: allowCodexUsageBackfill, + force: force) let task = Task { [weak self] in guard let self else { return } await self.performOpenAIDashboardRefreshIfNeeded(context) @@ -509,6 +511,7 @@ extension UsageStore { var dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: log, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebDashboardFetchTimeout(didImportCookies: didImportCookiesForRefresh)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } @@ -524,6 +527,7 @@ extension UsageStore { dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: log, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } } @@ -573,6 +577,17 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { + if !context.force { + OpenAIDashboardFetcher.evictAllCachedWebViews() + logger("OpenAI web refresh timed out; skipping immediate background retry.") + await self.applyOpenAIDashboardFailure( + message: "OpenAI web dashboard refresh timed out. CodexBar will retry after the refresh cooldown.", + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + routingTargetEmail: context.targetEmail) + return + } + let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) @@ -599,6 +614,7 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( @@ -650,6 +666,7 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( @@ -713,6 +730,7 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( @@ -914,6 +932,9 @@ extension UsageStore { } func invalidateOpenAIDashboardRefreshTask() { + self.openAIDashboardBackgroundRefreshTask?.cancel() + self.openAIDashboardBackgroundRefreshTask = nil + self.openAIDashboardBackgroundRefreshTaskKey = nil self.openAIDashboardRefreshTask?.cancel() self.openAIDashboardRefreshTask = nil self.openAIDashboardRefreshTaskKey = nil @@ -927,15 +948,17 @@ extension UsageStore { private func loadLatestOpenAIDashboard( accountEmail: String?, logger: @escaping (String) -> Void, + allowNavigationTimeoutRetry: Bool, timeout: TimeInterval) async throws -> OpenAIDashboardSnapshot { if let override = self._test_openAIDashboardLoaderOverride { - return try await override(accountEmail, logger, timeout) + return try await override(accountEmail, logger, allowNavigationTimeoutRetry, timeout) } return try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: accountEmail, logger: logger, debugDumpHTML: timeout != Self.openAIWebPrimaryFetchTimeout, + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d2c35695..02a8ab28 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -195,6 +195,7 @@ final class UsageStore { @ObservationIgnored var _test_openAIDashboardLoaderOverride: (@MainActor ( String?, @escaping (String) -> Void, + Bool, TimeInterval) async throws -> OpenAIDashboardSnapshot)? @ObservationIgnored var _test_codexCreditsLoaderOverride: (@MainActor () async throws -> CreditsSnapshot)? @ObservationIgnored var _test_widgetSnapshotSaveOverride: (@MainActor (WidgetSnapshot) async -> Void)? diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 58bb04db..bf742735 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -198,10 +198,16 @@ public struct OpenAIDashboardFetcher { return true } + private nonisolated static func sleepForDashboardPoll(_ duration: Duration) async throws { + try? await Task.sleep(for: duration) + try Task.checkCancellation() + } + public func loadLatestDashboard( accountEmail: String?, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, + allowNavigationTimeoutRetry: Bool = true, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) @@ -209,6 +215,7 @@ public struct OpenAIDashboardFetcher { websiteDataStore: store, logger: logger, debugDumpHTML: debugDumpHTML, + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } @@ -216,19 +223,21 @@ public struct OpenAIDashboardFetcher { websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, + allowNavigationTimeoutRetry: Bool = true, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let preflight = await Self.fetchDashboardAPIPreflight( websiteDataStore: websiteDataStore, logger: { logger?($0) }) - let apiData = preflight.apiData - let verifiedSignedInEmail = preflight.verifiedSignedInEmail + try Task.checkCancellation() + let (apiData, verifiedSignedInEmail) = (preflight.apiData, preflight.verifiedSignedInEmail) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, - timeout: Self.remainingTimeout(until: deadline)) + timeout: Self.remainingTimeout(until: deadline), + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry) defer { lease.release() } let webView = lease.webView let log = lease.log @@ -244,6 +253,7 @@ public struct OpenAIDashboardFetcher { var lastUsageBreakdownError: String? var lastCreditsPurchaseURL: String? while Date() < deadline { + try Task.checkCancellation() let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody @@ -261,14 +271,14 @@ public struct OpenAIDashboardFetcher { } if scrape.workspacePicker { - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if let href = scrape.href, !Self.isUsageRoute(href) { _ = webView.load(Self.usageURLRequest(url: self.usageURL)) - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } @@ -321,7 +331,7 @@ public struct OpenAIDashboardFetcher { "rows=\(scrape.rows.count)") if scrape.didScrollToCredits { log("scrollIntoView(Credits usage history) requested; waiting…") - try? await Task.sleep(for: .milliseconds(600)) + try await Self.sleepForDashboardPoll(.milliseconds(600)) continue } @@ -338,7 +348,7 @@ public struct OpenAIDashboardFetcher { creditsHeaderInViewport: scrape.creditsHeaderInViewport, didScrollToCredits: scrape.didScrollToCredits)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } } @@ -350,7 +360,7 @@ public struct OpenAIDashboardFetcher { now: Date(), errorFirstSeenAt: usageBreakdownErrorFirstSeenAt)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } @@ -359,7 +369,7 @@ public struct OpenAIDashboardFetcher { if codeReview != nil, usageBreakdown.isEmpty { let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date()) if elapsed < 6 { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } } @@ -377,7 +387,7 @@ public struct OpenAIDashboardFetcher { accountPlan: dashboardData.accountPlan)) } - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) } if debugDumpHTML, let html = try? await self.fetchDebugHTML(webView: webView) { @@ -435,12 +445,13 @@ public struct OpenAIDashboardFetcher { var dashboardSignalSeenAt: Date? while Date() < deadline { + try Task.checkCancellation() let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody lastHref = scrape.href ?? lastHref if scrape.workspacePicker { - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } @@ -448,7 +459,7 @@ public struct OpenAIDashboardFetcher { usageRouteSeenAt = nil dashboardSignalSeenAt = nil _ = webView.load(Self.usageURLRequest(url: self.usageURL)) - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } @@ -482,7 +493,7 @@ public struct OpenAIDashboardFetcher { signedInEmail: normalizedEmail, hasDashboardSignal: hasDashboardSignal)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } @@ -630,6 +641,7 @@ public struct OpenAIDashboardFetcher { websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, timeout: TimeInterval, + allowNavigationTimeoutRetry: Bool = true, preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { try await OpenAIDashboardWebViewCache.shared.acquire( @@ -637,6 +649,7 @@ public struct OpenAIDashboardFetcher { usageURL: self.usageURL, logger: logger, navigationTimeout: timeout, + allowTimeoutRetry: allowNavigationTimeoutRetry, preserveLoadedPageOnRelease: preserveLoadedPageOnRelease) } @@ -973,6 +986,7 @@ public struct OpenAIDashboardFetcher { accountEmail _: String?, logger _: ((String) -> Void)? = nil, debugDumpHTML _: Bool = false, + allowNavigationTimeoutRetry _: Bool = true, timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { throw FetchError.noDashboardData(body: "OpenAI web dashboard fetch is only supported on macOS.") diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift index 59f3f1ef..71626669 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift @@ -29,6 +29,10 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } + func cancel() { + self.completeOnce(.failure(CancellationError())) + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { self.completeOnce(.success(())) } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 1e9a91d3..116731b3 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -28,6 +28,34 @@ final class OpenAIDashboardWebViewCache { let preserveLoadedPageOnRelease: Bool } + @MainActor + private final class NavigationCancellationState { + private weak var webView: WKWebView? + private var delegate: NavigationDelegate? + private var isCancelled = false + + func install(webView: WKWebView, delegate: NavigationDelegate) { + self.webView = webView + self.delegate = delegate + if self.isCancelled { + self.cancel() + } + } + + func cancel() { + self.isCancelled = true + guard let webView, let delegate else { return } + delegate.cancel() + if webView.codexNavigationDelegate === delegate { + webView.stopLoading() + webView.navigationDelegate = nil + webView.codexNavigationDelegate = nil + } + self.delegate = nil + self.webView = nil + } + } + private final class Entry { let webView: WKWebView let host: OffscreenWebViewHost @@ -237,6 +265,7 @@ final class OpenAIDashboardWebViewCache { usageURL: URL, logger: ((String) -> Void)?, navigationTimeout: TimeInterval = 15, + allowTimeoutRetry: Bool = true, preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { let deadline = Date().addingTimeInterval(max(navigationTimeout, 1)) @@ -246,7 +275,7 @@ final class OpenAIDashboardWebViewCache { logger: logger, deadline: deadline, options: .init( - allowTimeoutRetry: true, + allowTimeoutRetry: allowTimeoutRetry, preserveLoadedPageOnRelease: preserveLoadedPageOnRelease)) } @@ -497,14 +526,26 @@ final class OpenAIDashboardWebViewCache { Self.log.debug("OpenAI preserved page reset failed; reloading usage URL") } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - let delegate = NavigationDelegate { result in - cont.resume(with: result) + try Task.checkCancellation() + let cancellationState = NavigationCancellationState() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let delegate = NavigationDelegate { result in + cont.resume(with: result) + } + webView.navigationDelegate = delegate + webView.codexNavigationDelegate = delegate + cancellationState.install(webView: webView, delegate: delegate) + delegate.armTimeout(seconds: timeout) + _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) + if Task.isCancelled { + cancellationState.cancel() + } + } + } onCancel: { + Task { @MainActor in + cancellationState.cancel() } - webView.navigationDelegate = delegate - webView.codexNavigationDelegate = delegate - delegate.armTimeout(seconds: timeout) - _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) } } diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift index 3e8a519f..4316b0ad 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift @@ -33,7 +33,7 @@ extension CodexAccountScopedRefreshTests { defer { store._test_codexCreditsLoaderOverride = nil } var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail #expect(store.currentCodexOpenAIWebRefreshGuard().source == .liveSystem) #expect(store.currentCodexOpenAIWebRefreshGuard().identity == .unresolved) diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 6eb33679..3afa2081 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -338,7 +338,7 @@ struct CodexAccountScopedRefreshTests { let store = self.makeUsageStore(settings: settings) store.lastKnownLiveSystemCodexEmail = nil - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in self.dashboard(email: "seeded@example.com", creditsRemaining: 33, usedPercent: 12) } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -379,7 +379,7 @@ struct CodexAccountScopedRefreshTests { self.codexSnapshot(email: "trusted@example.com", usedPercent: 12), provider: .codex) store.lastSourceLabels[.codex] = "codex-cli" - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in self.dashboard(email: "trusted@example.com", creditsRemaining: 33, usedPercent: 12) } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -613,7 +613,7 @@ struct CodexAccountScopedRefreshTests { on: store, snapshot: self.codexSnapshot(email: "alpha@example.com", usedPercent: 18)) let dashboardBlocker = BlockingOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await dashboardBlocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift index bac3cd40..720cf744 100644 --- a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -158,7 +158,7 @@ struct CodexBackgroundRefreshCoalescingTests { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index a61a5c87..0114aa14 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -45,7 +45,7 @@ struct CodexManagedOpenAIWebRefreshTests { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -269,7 +269,7 @@ struct CodexManagedOpenAIWebRefreshTests { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await dashboardBlocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -341,7 +341,7 @@ struct CodexManagedOpenAIWebRefreshTests { settings: settings, startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -415,7 +415,7 @@ struct CodexManagedOpenAIWebRefreshTests { startupBehavior: .testing) store.openAIDashboardCookieImportStatus = "OpenAI cookies are for other@example.com, not managed@example.com." - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in throw ManagedDashboardTestError.networkTimeout } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -447,8 +447,10 @@ struct CodexManagedOpenAIWebRefreshTests { startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() let importTracker = OpenAIDashboardImportCallTracker() - store._test_openAIDashboardLoaderOverride = { _, _, _ in - try await blocker.awaitResult() + var allowNavigationTimeoutRetries: [Bool] = [] + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + return try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in @@ -484,10 +486,65 @@ struct CodexManagedOpenAIWebRefreshTests { await refreshTask.value #expect(await blocker.startedCount() == 2) + #expect(allowNavigationTimeoutRetries == [true, true]) #expect(store.openAIDashboard?.creditsRemaining == 25) #expect(store.lastOpenAIDashboardError == nil) } + @Test + func `background navigation timeout skips immediate WebKit retry`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-background-timeout-no-retry") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingManagedOpenAIDashboardLoader() + let importTracker = OpenAIDashboardImportCallTracker() + var allowNavigationTimeoutRetries: [Bool] = [] + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + return try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + _ = await importTracker.recordCall() + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() + let refreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: false, expectedGuard: expectedGuard) + } + await blocker.waitUntilStarted(count: 1) + + await blocker.resumeNext(with: .failure(URLError(.timedOut))) + await refreshTask.value + + #expect(await blocker.startedCount() == 1) + #expect(allowNavigationTimeoutRetries == [false]) + #expect(await importTracker.callCount() == 0) + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError?.contains("timed out") == true) + } + @Test func `reset open A I web state blocks stale in flight dashboard completion`() async throws { let settings = try self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-reset-invalidates-task") @@ -508,7 +565,7 @@ struct CodexManagedOpenAIWebRefreshTests { settings: settings, startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -560,7 +617,7 @@ struct CodexManagedOpenAIWebRefreshTests { startupBehavior: .testing) store.openAIDashboardCookieImportStatus = "OpenAI cookies are for other@example.com, not managed@example.com." - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in throw ManagedDashboardTestError.networkTimeout } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -794,6 +851,10 @@ private actor OpenAIDashboardImportCallTracker { } } + func callCount() -> Int { + self.calls + } + private func resumeReadyWaiters() { var remaining: [(count: Int, continuation: CheckedContinuation)] = [] for waiter in self.waiters { diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift index fce3d009..921cce7a 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift @@ -33,7 +33,7 @@ extension CodexManagedOpenAIWebTests { settings: settings, startupBehavior: .testing) let blocker = CoalescingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift index da934f23..51cd309c 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift @@ -304,7 +304,7 @@ struct CodexManagedOpenAIWebTests { } var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail return OpenAIDashboardSnapshot( signedInEmail: "new@example.com", @@ -365,7 +365,7 @@ struct CodexManagedOpenAIWebTests { store.lastSourceLabels[.codex] = "codex-cli" var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail return OpenAIDashboardSnapshot( signedInEmail: "usage@example.com", @@ -818,7 +818,7 @@ struct CodexManagedOpenAIWebTests { startupBehavior: .testing) var loaderCalls = 0 - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in loaderCalls += 1 throw OpenAIDashboardFetcher.FetchError.loginRequired } @@ -860,7 +860,7 @@ struct CodexManagedOpenAIWebTests { settings: settings, startupBehavior: .testing) - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in throw OpenAIDashboardFetcher.FetchError.loginRequired } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 0ee8e0e7..37db0483 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -59,6 +59,22 @@ struct OpenAIDashboardNavigationDelegateTests { } } + @MainActor + @Test + func `explicit cancel completes with cancellation error`() { + var result: Result? + let delegate = NavigationDelegate { result = $0 } + + delegate.cancel() + + switch result { + case let .failure(error)?: + #expect(error is CancellationError) + default: + #expect(Bool(false)) + } + } + @MainActor @Test func `commit completes navigation successfully after grace period`() async { From 5ce38b605ccaaa62393ae025104824cd951ec407 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 16:14:44 +0100 Subject: [PATCH 08/79] fix: retry startup status after offline launch --- CHANGELOG.md | 1 + Sources/CodexBar/UsageStore+Refresh.swift | 35 +++++ .../UsageStore+StartupConnectivityRetry.swift | 83 ++++++++++++ Sources/CodexBar/UsageStore.swift | 56 ++++---- .../UsageStoreCoverageTests.swift | 127 ++++++++++++++++++ 5 files changed, 276 insertions(+), 26 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a07baf..c5cc9448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). +- Status: retry startup refreshes a few times after transient offline/network failures so provider status can recover after macOS brings the network online (#1211). ## 0.31.0 — 2026-05-28 diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index db3c9d2e..273b11f1 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -148,6 +148,7 @@ extension UsageStore { { return } + self.recordStartupConnectivityRetryableFailure(error) if claudeCredentialsChanged { await self.clearClaudeCredentialDerivedStateForCredentialSwap() } @@ -359,6 +360,40 @@ extension UsageStore { } } + static func startupConnectivityRetryDelay(forAttempt attempt: Int) -> TimeInterval? { + let delays: [TimeInterval] = [15, 45, 120, 300] + guard attempt >= 1, attempt <= delays.count else { return nil } + return delays[attempt - 1] + } + + static func isStartupConnectivityRetryableError(_ error: Error) -> Bool { + if error is CancellationError { return false } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut, + NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorDNSLookupFailed: + return true + default: + return false + } + } + + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || + message.contains("timeout") || + message.contains("network connection was lost") || + message.contains("not connected to the internet") || + message.contains("cannot find host") || + message.contains("cannot connect to host") || + message.contains("dns lookup") + } + private static func isClaudeUsageProbeTimeout(_ error: Error) -> Bool { if case ClaudeStatusProbeError.timedOut = error { return true } return error.localizedDescription == ClaudeStatusProbeError.timedOut.localizedDescription diff --git a/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift b/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift new file mode 100644 index 00000000..aceceb48 --- /dev/null +++ b/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift @@ -0,0 +1,83 @@ +import Foundation + +extension UsageStore { + enum StartupBehavior { + case automatic + case full + case testing + + var automaticallyStartsBackgroundWork: Bool { + switch self { + case .automatic, .full: + true + case .testing: + false + } + } + + func resolved(isRunningTests: Bool) -> StartupBehavior { + switch self { + case .automatic: + isRunningTests ? .testing : .full + case .full, .testing: + self + } + } + } + + func recordStartupConnectivityRetryableFailure(_ error: Error) { + guard self.startupConnectivityRetryRefreshActive else { return } + guard Self.isStartupConnectivityRetryableError(error) else { return } + self.startupConnectivityRetryNeeded = true + } + + func completeStartupConnectivityRetryPass(currentAttempt: Int) { + guard self.startupConnectivityRetryNeeded else { + self.cancelStartupConnectivityRetry() + return + } + + let nextAttempt = currentAttempt + 1 + guard let delay = Self.startupConnectivityRetryDelay(forAttempt: nextAttempt) else { + self.cancelStartupConnectivityRetry() + return + } + + self.scheduleStartupConnectivityRetry(attempt: nextAttempt, delay: delay) + } + + private func scheduleStartupConnectivityRetry(attempt: Int, delay: TimeInterval) { + guard self.startupBehavior.automaticallyStartsBackgroundWork || + self._test_startupConnectivityRetryScheduled != nil || + self._test_startupConnectivityRetrySleepOverride != nil + else { + return + } + + self.startupConnectivityRetryTask?.cancel() + self._test_startupConnectivityRetryScheduled?(attempt, delay) + self.startupConnectivityRetryTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.sleepForStartupConnectivityRetry(delay) + guard !Task.isCancelled else { return } + await self.runRefresh(startupConnectivityRetryAttempt: attempt) + } catch { + return + } + } + } + + private func cancelStartupConnectivityRetry() { + self.startupConnectivityRetryTask?.cancel() + self.startupConnectivityRetryTask = nil + } + + private func sleepForStartupConnectivityRetry(_ delay: TimeInterval) async throws { + if let override = self._test_startupConnectivityRetrySleepOverride { + try await override(delay) + return + } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } +} diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 02a8ab28..de59e682 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -115,30 +115,6 @@ final class UsageStore { case dashboardWeb } - enum StartupBehavior { - case automatic - case full - case testing - - var automaticallyStartsBackgroundWork: Bool { - switch self { - case .automatic, .full: - true - case .testing: - false - } - } - - func resolved(isRunningTests: Bool) -> StartupBehavior { - switch self { - case .automatic: - isRunningTests ? .testing : .full - case .full, .testing: - self - } - } - } - var snapshots: [UsageProvider: UsageSnapshot] = [:] var errors: [UsageProvider: String] = [:] var lastSourceLabels: [UsageProvider: String] = [:] @@ -201,6 +177,11 @@ final class UsageStore { @ObservationIgnored var _test_widgetSnapshotSaveOverride: (@MainActor (WidgetSnapshot) async -> Void)? @ObservationIgnored var _test_providerRefreshOverride: (@MainActor (UsageProvider) async -> Void)? @ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)? + @ObservationIgnored var _test_providerStatusFetchOverride: (@MainActor ( + UsageProvider) async throws -> ProviderStatus)? + @ObservationIgnored var _test_startupConnectivityRetryScheduled: (@MainActor (Int, TimeInterval) -> Void)? + @ObservationIgnored var _test_startupConnectivityRetrySleepOverride: (@MainActor ( + TimeInterval) async throws -> Void)? @ObservationIgnored var widgetSnapshotPersistTask: Task? @ObservationIgnored let codexFetcher: UsageFetcher @@ -226,6 +207,9 @@ final class UsageStore { @ObservationIgnored private var timerTask: Task? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? + @ObservationIgnored var startupConnectivityRetryTask: Task? + @ObservationIgnored var startupConnectivityRetryNeeded = false + @ObservationIgnored var startupConnectivityRetryRefreshActive = false @ObservationIgnored var storageRefreshTask: Task? @ObservationIgnored var storageRefreshGeneration: UInt64 = 0 @ObservationIgnored var storageRefreshInFlightSignature: String? @@ -252,7 +236,7 @@ final class UsageStore { @ObservationIgnored private let providerAvailabilityCacheTTL: TimeInterval = 1 @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 - @ObservationIgnored private let startupBehavior: StartupBehavior + @ObservationIgnored let startupBehavior: StartupBehavior @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( @@ -530,9 +514,20 @@ final class UsageStore { } func refresh(forceTokenUsage: Bool = false) async { + await self.runRefresh(forceTokenUsage: forceTokenUsage, startupConnectivityRetryAttempt: nil) + } + + func runRefresh( + forceTokenUsage: Bool = false, + startupConnectivityRetryAttempt: Int?) + async + { guard !self.isRefreshing else { return } self.prepareRefreshState() let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup + let allowsStartupConnectivityRetry = refreshPhase == .startup || startupConnectivityRetryAttempt != nil + self.startupConnectivityRetryRefreshActive = allowsStartupConnectivityRetry + self.startupConnectivityRetryNeeded = false let displayEnabledProviders = self.enabledProvidersForDisplay() let enabledProviderSet = Set(displayEnabledProviders) let refreshProviders = self.enabledProvidersForBackgroundWork() @@ -544,6 +539,7 @@ final class UsageStore { defer { self.isRefreshing = false self.hasCompletedInitialRefresh = true + self.startupConnectivityRetryRefreshActive = false } self.clearDisabledProviderState(enabledProviders: enabledProviderSet) @@ -613,6 +609,10 @@ final class UsageStore { self.persistWidgetSnapshot(reason: "refresh") } + + if allowsStartupConnectivityRetry { + self.completeStartupConnectivityRetryPass(currentAttempt: startupConnectivityRetryAttempt ?? 0) + } } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. @@ -699,6 +699,7 @@ final class UsageStore { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() + self.startupConnectivityRetryTask?.cancel() self.storageRefreshTask?.cancel() self.codexPlanHistoryBackfillTask?.cancel() } @@ -890,7 +891,9 @@ final class UsageStore { do { let status: ProviderStatus - if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) { + if let override = self._test_providerStatusFetchOverride { + status = try await override(provider) + } else if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) { status = try await Self.fetchStatus(from: baseURL) } else if let productID = meta.statusWorkspaceProductID { status = try await Self.fetchWorkspaceStatus(productID: productID) @@ -899,6 +902,7 @@ final class UsageStore { } await MainActor.run { self.statuses[provider] = status } } catch { + self.recordStartupConnectivityRetryableFailure(error) // Keep the previous status to avoid flapping when the API hiccups. await MainActor.run { if self.statuses[provider] == nil { diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index f771e0bc..d17aa924 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -471,6 +471,90 @@ struct UsageStoreCoverageTests { NSError(domain: NSCocoaErrorDomain, code: 0))) } + @Test + func `startup status network failure schedules bounded retry`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-retry") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_providerStatusFetchOverride = { _ in + throw URLError(.notConnectedToInternet) + } + defer { store._test_providerStatusFetchOverride = nil } + + var scheduled: [(attempt: Int, delay: TimeInterval)] = [] + store._test_startupConnectivityRetryScheduled = { attempt, delay in + scheduled.append((attempt, delay)) + } + defer { store._test_startupConnectivityRetryScheduled = nil } + + await store.refresh() + defer { + store.startupConnectivityRetryTask?.cancel() + store.startupConnectivityRetryTask = nil + } + + #expect(scheduled.map(\.attempt) == [1]) + #expect(scheduled.map(\.delay) == [15]) + #expect(store.statuses[.codex]?.indicator == .unknown) + #expect(store.statuses[.codex]?.description?.isEmpty == false) + } + + @Test + func `startup connectivity retry refreshes status and clears retry task after recovery`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-recovery") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + + var statusAttempts = 0 + store._test_providerStatusFetchOverride = { _ in + statusAttempts += 1 + if statusAttempts == 1 { + throw URLError(.cannotFindHost) + } + return ProviderStatus(indicator: .none, description: "Operational", updatedAt: Date()) + } + defer { store._test_providerStatusFetchOverride = nil } + + let sleepGate = StartupConnectivityRetrySleepGate() + store._test_startupConnectivityRetrySleepOverride = { delay in + try await sleepGate.sleep(delay) + } + defer { store._test_startupConnectivityRetrySleepOverride = nil } + + await store.refresh() + await sleepGate.waitUntilSleeping() + let retryTask = try #require(store.startupConnectivityRetryTask) + + await sleepGate.resume() + await retryTask.value + + #expect(statusAttempts == 2) + #expect(store.statuses[.codex]?.indicator == ProviderStatusIndicator.none) + #expect(store.statuses[.codex]?.description == "Operational") + #expect(store.startupConnectivityRetryTask == nil) + } + + @Test + func `startup connectivity retry classification is bounded and excludes cancellation`() { + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 1) == 15) + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 4) == 300) + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 5) == nil) + #expect(UsageStore.isStartupConnectivityRetryableError(URLError(.timedOut))) + #expect(UsageStore.isStartupConnectivityRetryableError(URLError(.notConnectedToInternet))) + #expect(!UsageStore.isStartupConnectivityRetryableError(URLError(.cancelled))) + #expect(!UsageStore.isStartupConnectivityRetryableError(CancellationError())) + } + private static func makeSettingsStore( suite: String, zaiTokenStore: any ZaiTokenStoring = NoopZaiTokenStore(), @@ -510,6 +594,49 @@ struct UsageStoreCoverageTests { settings: settings, environmentBase: [:]) } + + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == enabledProvider) + } + } +} + +private actor StartupConnectivityRetrySleepGate { + private var continuation: CheckedContinuation? + private var waiters: [CheckedContinuation] = [] + + func sleep(_ delay: TimeInterval) async throws { + #expect(delay == 15) + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.resumeWaiters() + } + } + + func waitUntilSleeping() async { + if self.continuation != nil { return } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } + + private func resumeWaiters() { + let waiters = self.waiters + self.waiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } } private final class InMemoryZaiTokenStore: ZaiTokenStoring, @unchecked Sendable { From e6d61a8d5f2a98af512674a9a3cfa239ec816c5a Mon Sep 17 00:00:00 2001 From: Piotr Durlej Date: Fri, 29 May 2026 19:14:49 +0200 Subject: [PATCH 09/79] Harden menu bar status item placement Set stable autosave names for CodexBar status items and preflight their preferred position before creation so crowded macOS menu bars do not inherit parked Item-N placement. Add CGWindowList-based diagnostics for status item window bounds, keep cached icon renders self-healing, and make merged fallback provider selection follow enabled provider order. Cover placement preflight, window probing, stable identities, cached title repair, and provider-order fallback with tests. --- .../MenuBarStatusItemPlacementPreflight.swift | 25 ++++ .../MenuBarStatusItemWindowProbe.swift | 100 ++++++++++++++++ .../CodexBar/MenuBarVisibilityWatcher.swift | 26 ++++- Sources/CodexBar/ProviderBrandIcon.swift | 3 + .../StatusItemController+Animation.swift | 6 +- Sources/CodexBar/StatusItemController.swift | 35 +++++- .../MenuBarVisibilityWatcherTests.swift | 47 ++++++++ .../StatusItemAnimationSignatureTests.swift | 109 ++++++++++++++++++ ...tusItemControllerSplitLifecycleTests.swift | 64 ++++++++-- Tests/CodexBarTests/StatusMenuTests.swift | 8 +- 10 files changed, 401 insertions(+), 22 deletions(-) create mode 100644 Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift create mode 100644 Sources/CodexBar/MenuBarStatusItemWindowProbe.swift diff --git a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift new file mode 100644 index 00000000..9db1e2fc --- /dev/null +++ b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift @@ -0,0 +1,25 @@ +import Foundation + +enum MenuBarStatusItemPlacementPreflight { + static let preferredPositionPrefix = "NSStatusItem Preferred Position " + static let lowPreferredPosition: Double = 0 + static let suspiciousPreferredPositionThreshold: Double = 100 + + static func preferredPositionKey(autosaveName: String) -> String { + "\(self.preferredPositionPrefix)\(autosaveName)" + } + + @discardableResult + static func prepare(defaults: UserDefaults, autosaveName: String) -> Bool { + let key = self.preferredPositionKey(autosaveName: autosaveName) + guard self.shouldSetPreferredPosition(defaults.object(forKey: key)) else { return false } + defaults.set(self.lowPreferredPosition, forKey: key) + return true + } + + static func shouldSetPreferredPosition(_ value: Any?) -> Bool { + guard let value else { return true } + guard let number = value as? NSNumber else { return true } + return number.doubleValue > self.suspiciousPreferredPositionThreshold + } +} diff --git a/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift new file mode 100644 index 00000000..c479c859 --- /dev/null +++ b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift @@ -0,0 +1,100 @@ +import AppKit +import CoreGraphics +import Foundation + +struct MenuBarStatusItemWindowSnapshot: Equatable, CustomStringConvertible { + let name: String + let ownerName: String + let bounds: CGRect + let isOnscreen: Bool + let displayBounds: CGRect? + + var isWithinDisplayBounds: Bool { + guard let displayBounds else { return false } + return displayBounds.contains(self.bounds) + } + + var description: String { + let display = self.displayBounds.map { + "display=\(Int($0.minX)),\(Int($0.minY)) \(Int($0.width))x\(Int($0.height))" + } ?? "display=nil" + return "name=\(self.name),owner=\(self.ownerName),x=\(Int(self.bounds.minX))," + + "w=\(Int(self.bounds.width)),onscreen=\(self.isOnscreen)," + + "withinDisplay=\(self.isWithinDisplayBounds),\(display)" + } +} + +enum MenuBarStatusItemWindowProbe { + static func snapshots(matching names: Set) -> [MenuBarStatusItemWindowSnapshot] { + self.snapshots( + matching: names, + windowInfo: self.windowInfo(), + displayBounds: NSScreen.screens.map(\.frame)) + } + + static func snapshots( + matching names: Set, + windowInfo: [[String: Any]], + displayBounds: [CGRect]) + -> [MenuBarStatusItemWindowSnapshot] + { + guard !names.isEmpty else { return [] } + return windowInfo.compactMap { record in + self.snapshot(record: record, matching: names, displayBounds: displayBounds) + } + } + + private static func windowInfo() -> [[String: Any]] { + guard let windows = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] else { + return [] + } + return windows + } + + private static func snapshot( + record: [String: Any], + matching names: Set, + displayBounds: [CGRect]) + -> MenuBarStatusItemWindowSnapshot? + { + guard let name = record[kCGWindowName as String] as? String, + names.contains(name), + let bounds = self.bounds(record[kCGWindowBounds as String]) + else { return nil } + let ownerName = record[kCGWindowOwnerName as String] as? String ?? "unknown" + let isOnscreen = (record[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue + ?? record[kCGWindowIsOnscreen as String] as? Bool + ?? false + return MenuBarStatusItemWindowSnapshot( + name: name, + ownerName: ownerName, + bounds: bounds, + isOnscreen: isOnscreen, + displayBounds: displayBounds.first { $0.intersects(bounds) }) + } + + private static func bounds(_ value: Any?) -> CGRect? { + guard let dictionary = value as? [String: Any], + let x = self.double(dictionary["X"]), + let y = self.double(dictionary["Y"]), + let width = self.double(dictionary["Width"]), + let height = self.double(dictionary["Height"]) + else { return nil } + return CGRect(x: x, y: y, width: width, height: height) + } + + private static func double(_ value: Any?) -> Double? { + switch value { + case let number as NSNumber: + number.doubleValue + case let double as Double: + double + case let int as Int: + Double(int) + case let cgFloat as CGFloat: + Double(cgFloat) + default: + nil + } + } +} diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift index ec4055e2..36d2f383 100644 --- a/Sources/CodexBar/MenuBarVisibilityWatcher.swift +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -203,7 +203,10 @@ extension StatusItemController { self.menuLogger.error( "Status item failed to materialize; recreating status items", - metadata: ["snapshots": snapshots.map(\.description).joined(separator: " | ")]) + metadata: [ + "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) self.recreateStatusItemsForVisibilityRecovery() let recoveredSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) @@ -220,7 +223,10 @@ extension StatusItemController { self.menuLogger.error( "Status item still failed to materialize after recreation", - metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")]) + metadata: [ + "snapshots": recoveredSnapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) guard #available(macOS 26.0, *), MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now) else { @@ -272,6 +278,7 @@ extension StatusItemController { "currentScreenCount": "\(settledCurrentScreenCount)", "capturedScreenCount": "\(currentScreenCount)", "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), ]) self.recreateStatusItemsForVisibilityRecovery() self.schedulePostScreenChangeRecoveryVerification(attempt: 1) @@ -322,6 +329,7 @@ extension StatusItemController { metadata: [ "attempt": "\(attempt)", "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), ]) self.recreateStatusItemsForVisibilityRecovery() // No further async retries: a menu bar manager may park the newly recreated item in a state @@ -331,7 +339,10 @@ extension StatusItemController { guard MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(finalSnapshots) else { return } self.menuLogger.error( "Status item still blocked after display-change recovery recreation", - metadata: ["snapshots": finalSnapshots.map(\.description).joined(separator: " | ")]) + metadata: [ + "snapshots": finalSnapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) guard #available(macOS 26.0, *), MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults) else { return } @@ -341,4 +352,13 @@ extension StatusItemController { private var startupVisibilityStatusItems: [NSStatusItem] { [self.statusItem] + Array(self.statusItems.values) } + + private func statusItemWindowDiagnosticsDescription() -> String { + let names = Set(self.startupVisibilityStatusItems.compactMap { item in + item.autosaveName.isEmpty ? nil : item.autosaveName + }) + let snapshots = MenuBarStatusItemWindowProbe.snapshots(matching: names) + guard !snapshots.isEmpty else { return "none" } + return snapshots.map(\.description).joined(separator: " | ") + } } diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index cec160ad..86fc8d7b 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -6,6 +6,9 @@ enum ProviderBrandIcon { /// Lazy-loaded resource bundle for provider icons. private static let resourceBundle: Bundle? = { + guard Bundle.main.bundleURL.pathExtension == "app" else { + return Bundle.module + } // SwiftPM creates a CodexBar_CodexBar.bundle for resources in the CodexBar target. if let bundleURL = Bundle.main.url(forResource: "CodexBar_CodexBar", withExtension: "bundle"), let bundle = Bundle(url: bundleURL) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 510e3f07..a2181b83 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -10,7 +10,6 @@ extension StatusItemController { static let loadingAnimationPhaseIncrement: Double = 2.7 / StatusItemController.loadingAnimationFPS private static let loadingAnimationMaxContinuousDuration: TimeInterval = 30.0 - func needsMenuBarIconAnimation() -> Bool { if self.shouldMergeIcons { let primaryProvider = self.primaryProviderForUnifiedIcon() @@ -338,6 +337,9 @@ extension StatusItemController { "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { + // AppKit can lose button title/image-position state independently of the cached render signature. + // Keep the cheap title path self-healing even when the icon image itself can be skipped. + self.setButtonTitle(displayText, for: button) self.noteIconPerfRender(skipped: true) return true } @@ -915,7 +917,7 @@ extension StatusItemController { { return selected } - for provider in UsageProvider.allCases { + for provider in self.store.enabledProviders() { if self.store.isEnabled(provider), self.store.snapshot(for: provider) != nil { return provider } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 97ccca62..235c07ca 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -31,10 +31,19 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private nonisolated static let statusItemAccessibilityTitle = "CodexBar" private nonisolated static let statusItemAccessibilityIdentifierPrefix = "CodexBar.StatusItem" - private enum StatusItemIdentity { + enum StatusItemIdentity { case merged case provider(UsageProvider) + var autosaveName: String { + switch self { + case .merged: + return "codexbar-merged" + case let .provider(provider): + return "codexbar-\(provider.rawValue)" + } + } + var accessibilityIdentifier: String { switch self { case .merged: @@ -197,8 +206,15 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin set { self.settings.selectedMenuProvider = newValue } } - private static func makeStatusItem(statusBar: NSStatusBar, identity: StatusItemIdentity) -> NSStatusItem { + private static func makeStatusItem( + statusBar: NSStatusBar, + identity: StatusItemIdentity, + defaults: UserDefaults) + -> NSStatusItem + { + MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: identity.autosaveName) let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) + item.autosaveName = identity.autosaveName if let button = item.button { // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). button.imageScaling = .scaleNone @@ -318,7 +334,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let repairedStatusItemVisibilityKeys = MenuBarStatusItemDefaultsRepair .repairHiddenVisibilityDefaultsIfNeeded(defaults: settings.userDefaults) self.statusBar = statusBar - self.statusItem = Self.makeStatusItem(statusBar: statusBar, identity: .merged) + self.statusItem = Self.makeStatusItem( + statusBar: statusBar, + identity: .merged, + defaults: settings.userDefaults) self.lastKnownScreenCount = NSScreen.screens.count // Status items for individual providers are now created lazily in updateVisibility() super.init() @@ -683,7 +702,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if let existing = self.statusItems[provider] { return existing } - let item = Self.makeStatusItem(statusBar: self.statusBar, identity: .provider(provider)) + let item = Self.makeStatusItem( + statusBar: self.statusBar, + identity: .provider(provider), + defaults: self.settings.userDefaults) self.statusItems[provider] = item return item } @@ -694,7 +716,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #endif self.statusItem.menu = nil self.statusBar.removeStatusItem(self.statusItem) - self.statusItem = Self.makeStatusItem(statusBar: self.statusBar, identity: .merged) + self.statusItem = Self.makeStatusItem( + statusBar: self.statusBar, + identity: .merged, + defaults: self.settings.userDefaults) for provider in Array(self.statusItems.keys) { self.removeProviderStatusItem(for: provider) } diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index ce47ef1f..999893dd 100644 --- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -1,4 +1,5 @@ import Foundation +import CoreGraphics import Testing @testable import CodexBar @@ -63,6 +64,52 @@ struct MenuBarVisibilityWatcherTests { #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) } + @Test + func `window probe matches autosave name and reports display bounds`() { + let snapshots = MenuBarStatusItemWindowProbe.snapshots( + matching: ["codexbar-merged"], + windowInfo: [[ + kCGWindowName as String: "codexbar-merged", + kCGWindowOwnerName as String: "Control Center", + kCGWindowIsOnscreen as String: true, + kCGWindowBounds as String: [ + "X": 1680, + "Y": 0, + "Width": 70, + "Height": 24, + ], + ]], + displayBounds: [CGRect(x: 0, y: 0, width: 2056, height: 1329)]) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.name == "codexbar-merged") + #expect(snapshots.first?.ownerName == "Control Center") + #expect(snapshots.first?.isOnscreen == true) + #expect(snapshots.first?.isWithinDisplayBounds == true) + } + + @Test + func `window probe detects offscreen status item by bounds`() { + let snapshots = MenuBarStatusItemWindowProbe.snapshots( + matching: ["codexbar-merged"], + windowInfo: [[ + kCGWindowName as String: "codexbar-merged", + kCGWindowOwnerName as String: "Control Center", + kCGWindowIsOnscreen as String: true, + kCGWindowBounds as String: [ + "X": 2023, + "Y": 0, + "Width": 71, + "Height": 24, + ], + ]], + displayBounds: [CGRect(x: 0, y: 0, width: 2056, height: 1329)]) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.isOnscreen == true) + #expect(snapshots.first?.isWithinDisplayBounds == false) + } + @Test func `allows visible item attached to a detached screen`() { let snapshot = StatusItemVisibilitySnapshot( diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index fc064a9d..dfef3960 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -77,6 +77,115 @@ struct StatusItemAnimationSignatureTests { #expect(codexSignature?.contains("style=codex") == true) } + @Test + func `merged brand percent reapplies title when cached render is skipped`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-brand-percent-title-restore" + 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 = true + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 23, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let displayText = try #require(controller.menuBarDisplayText(for: .codex, snapshot: snapshot)) + let expectedTitle = StatusItemController.buttonTitle(displayText, hasImage: true) + controller.applyIcon(phase: nil) + let button = try #require(controller.statusItem.button) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + + button.title = "" + button.imagePosition = .imageOnly + + let skipped = controller.applyIcon(phase: nil) + + #expect(skipped) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + } + + @Test + func `merged fallback provider follows enabled provider order`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-provider-order" + 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.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + settings.setProviderOrder([.synthetic, .codex]) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .synthetic) + + controller.applyIcon(phase: nil) + + #expect(store.enabledProviders().prefix(2) == [.synthetic, .codex]) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + } + @Test func `merged icon follows overview provider order when first overview provider is loading`() { let suite = "StatusItemAnimationSignatureTests-merged-overview-provider-order" diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index 86d247d9..fa170be3 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -98,16 +98,16 @@ struct StatusItemControllerSplitLifecycleTests { } @Test - func `status items publish stable non persistent manager identity`() throws { + func `status items publish stable manager identity`() throws { let (_, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } let codexButton = try #require(controller.statusItems[.codex]?.button) let claudeButton = try #require(controller.statusItems[.claude]?.button) - #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) - #expect(controller.statusItems[.codex]?.autosaveName.hasPrefix("CodexBar.") == false) - #expect(controller.statusItems[.claude]?.autosaveName.hasPrefix("CodexBar.") == false) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") + #expect(controller.statusItems[.claude]?.autosaveName == "codexbar-claude") #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") #expect(codexButton.accessibilityIdentifier() == "CodexBar.StatusItem.codex") #expect(claudeButton.accessibilityIdentifier() == "CodexBar.StatusItem.claude") @@ -116,6 +116,54 @@ struct StatusItemControllerSplitLifecycleTests { #expect(claudeButton.accessibilityTitle() == "CodexBar") } + @Test + func `status item identity returns stable autosave names`() { + #expect(StatusItemController.StatusItemIdentity.merged.autosaveName == "codexbar-merged") + #expect(StatusItemController.StatusItemIdentity.provider(.codex).autosaveName == "codexbar-codex") + #expect(StatusItemController.StatusItemIdentity.provider(.claude).autosaveName == "codexbar-claude") + } + + @Test + func `status item placement preflight writes low position when missing`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-missing-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + #expect(defaults.double(forKey: key) == 0) + } + + @Test + func `status item placement preflight replaces suspicious high position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-high-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(11298, forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.double(forKey: key) == 0) + } + + @Test + func `status item placement preflight preserves reasonable position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(42, forKey: key) + + #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.double(forKey: key) == 42) + } + @Test func `status item defaults repair removes stale hidden Control Center keys once`() throws { let suite = "StatusItemControllerSplitLifecycleTests-repair-\(UUID().uuidString)" @@ -164,7 +212,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(newCodexItem === oldCodexItem) #expect(newClaudeItem === oldClaudeItem) #expect(newCodexItem.button === oldCodexButton) - #expect(!newCodexItem.autosaveName.hasPrefix("CodexBar.")) + #expect(newCodexItem.autosaveName == "codexbar-codex") #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") } @@ -182,7 +230,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.statusItem === oldMergedItem) #expect(controller.statusItem.button === oldMergedButton) - #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(controller.statusItem.autosaveName == "codexbar-merged") #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") } @@ -214,7 +262,7 @@ struct StatusItemControllerSplitLifecycleTests { let newCodexItem = try #require(controller.statusItems[.codex]) #expect(newCodexItem !== oldCodexItem) - #expect(!newCodexItem.autosaveName.hasPrefix("CodexBar.")) + #expect(newCodexItem.autosaveName == "codexbar-codex") #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") } @@ -232,7 +280,7 @@ struct StatusItemControllerSplitLifecycleTests { let mergedButton = try #require(controller.statusItem.button) #expect(mergedButton.image != nil) - #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(controller.statusItem.autosaveName == "codexbar-merged") #expect(mergedButton.accessibilityIdentifier() == "CodexBar.StatusItem") } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index cd4e5e91..935c72c1 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -884,8 +884,8 @@ extension StatusMenuTests { statusBar: self.makeStatusBarForTesting()) let codexItem = try #require(controller.statusItems[.codex]) - #expect(!controller.statusItem.autosaveName.hasPrefix("codexbar-")) - #expect(!codexItem.autosaveName.hasPrefix("codexbar-")) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(codexItem.autosaveName == "codexbar-codex") try settings.setProviderEnabled( provider: .gemini, @@ -894,8 +894,8 @@ extension StatusMenuTests { controller.handleProviderConfigChange(reason: "test") #expect(controller.statusItems[.codex] === codexItem) - #expect(controller.statusItems[.codex]?.autosaveName.hasPrefix("codexbar-") == false) - #expect(controller.statusItems[.gemini]?.autosaveName.hasPrefix("codexbar-") == false) + #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") + #expect(controller.statusItems[.gemini]?.autosaveName == "codexbar-gemini") } @Test From 8545c76d7aac8aed0458b682dee5d437cf4a74ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 17:07:05 +0100 Subject: [PATCH 10/79] fix: preserve menu bar placement on upgrade --- CHANGELOG.md | 1 + .../MenuBarStatusItemPlacementPreflight.swift | 48 +++++++- Sources/CodexBar/StatusItemController.swift | 28 +++-- .../MenuBarVisibilityWatcherTests.swift | 2 +- ...tusItemControllerSplitLifecycleTests.swift | 116 +++++++++++++++++- 5 files changed, 184 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cc9448..4e07b89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). +- Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! - Status: retry startup refreshes a few times after transient offline/network failures so provider status can recover after macOS brings the network online (#1211). ## 0.31.0 — 2026-05-28 diff --git a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift index 9db1e2fc..dbbf7841 100644 --- a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift +++ b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift @@ -10,9 +10,16 @@ enum MenuBarStatusItemPlacementPreflight { } @discardableResult - static func prepare(defaults: UserDefaults, autosaveName: String) -> Bool { + static func prepare(defaults: UserDefaults, autosaveName: String, legacyDefaultItemIndex: Int? = nil) -> Bool { let key = self.preferredPositionKey(autosaveName: autosaveName) - guard self.shouldSetPreferredPosition(defaults.object(forKey: key)) else { return false } + let value = defaults.object(forKey: key) + guard value != nil || !self.shouldPreserveMissingStableKey( + defaults: defaults, + legacyDefaultItemIndex: legacyDefaultItemIndex) + else { + return false + } + guard self.shouldSetPreferredPosition(value) else { return false } defaults.set(self.lowPreferredPosition, forKey: key) return true } @@ -22,4 +29,41 @@ enum MenuBarStatusItemPlacementPreflight { guard let number = value as? NSNumber else { return true } return number.doubleValue > self.suspiciousPreferredPositionThreshold } + + static func shouldPreserveMissingStableKey(defaults: UserDefaults, legacyDefaultItemIndex: Int?) -> Bool { + guard let legacyDefaultItemIndex else { return false } + return self.legacyPreferredPositions(defaults: defaults).contains { position in + position.itemIndex == legacyDefaultItemIndex && !self.shouldSetPreferredPosition(position.value) + } + } + + static func isLegacyPreferredPositionKey(_ key: String) -> Bool { + guard key.hasPrefix(self.preferredPositionPrefix) else { return false } + return self.isDefaultStatusItemName(String(key.dropFirst(self.preferredPositionPrefix.count))) + } + + private static func legacyPreferredPositions(defaults: UserDefaults) -> [LegacyPreferredPosition] { + defaults.dictionaryRepresentation().compactMap { key, value -> LegacyPreferredPosition? in + guard key.hasPrefix(self.preferredPositionPrefix) else { return nil } + let itemName = String(key.dropFirst(self.preferredPositionPrefix.count)) + guard let itemIndex = self.defaultStatusItemIndex(itemName) else { return nil } + return LegacyPreferredPosition(itemIndex: itemIndex, value: value) + } + } + + private struct LegacyPreferredPosition { + var itemIndex: Int + var value: Any + } + + private static func isDefaultStatusItemName(_ itemName: String) -> Bool { + self.defaultStatusItemIndex(itemName) != nil + } + + private static func defaultStatusItemIndex(_ itemName: String) -> Int? { + guard itemName.hasPrefix("Item-") else { return nil } + let suffix = itemName.dropFirst("Item-".count) + guard suffix.allSatisfy(\.isNumber) else { return nil } + return Int(suffix) + } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 235c07ca..564bd55c 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -30,6 +30,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin static let quotaWarningFlashDuration: TimeInterval = 60 private nonisolated static let statusItemAccessibilityTitle = "CodexBar" private nonisolated static let statusItemAccessibilityIdentifierPrefix = "CodexBar.StatusItem" + private nonisolated static let mergedLegacyDefaultItemIndex = 0 enum StatusItemIdentity { case merged @@ -38,9 +39,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var autosaveName: String { switch self { case .merged: - return "codexbar-merged" + "codexbar-merged" case let .provider(provider): - return "codexbar-\(provider.rawValue)" + "codexbar-\(provider.rawValue)" } } @@ -209,10 +210,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private static func makeStatusItem( statusBar: NSStatusBar, identity: StatusItemIdentity, - defaults: UserDefaults) + defaults: UserDefaults, + legacyDefaultItemIndex: Int?) -> NSStatusItem { - MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: identity.autosaveName) + MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: identity.autosaveName, + legacyDefaultItemIndex: legacyDefaultItemIndex) let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) item.autosaveName = identity.autosaveName if let button = item.button { @@ -337,7 +342,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.statusItem = Self.makeStatusItem( statusBar: statusBar, identity: .merged, - defaults: settings.userDefaults) + defaults: settings.userDefaults, + legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex) self.lastKnownScreenCount = NSScreen.screens.count // Status items for individual providers are now created lazily in updateVisibility() super.init() @@ -705,7 +711,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let item = Self.makeStatusItem( statusBar: self.statusBar, identity: .provider(provider), - defaults: self.settings.userDefaults) + defaults: self.settings.userDefaults, + legacyDefaultItemIndex: self.legacyDefaultItemIndex(forNewProvider: provider)) self.statusItems[provider] = item return item } @@ -719,7 +726,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.statusItem = Self.makeStatusItem( statusBar: self.statusBar, identity: .merged, - defaults: self.settings.userDefaults) + defaults: self.settings.userDefaults, + legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex) for provider in Array(self.statusItems.keys) { self.removeProviderStatusItem(for: provider) } @@ -896,6 +904,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } extension StatusItemController { + private func legacyDefaultItemIndex(forNewProvider provider: UsageProvider) -> Int? { + let visibleProviders = self.settings.orderedProviders().filter { self.isVisible($0) } + guard let providerOffset = visibleProviders.firstIndex(of: provider) else { return nil } + return Self.mergedLegacyDefaultItemIndex + 1 + providerOffset + } + func refreshExistingStatusItemsForVisibilityRecovery() { #if DEBUG guard !self.isReleasedForTesting else { return } diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index 999893dd..f86d0ee2 100644 --- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -1,5 +1,5 @@ -import Foundation import CoreGraphics +import Foundation import Testing @testable import CodexBar diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index fa170be3..491afc42 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -124,7 +124,7 @@ struct StatusItemControllerSplitLifecycleTests { } @Test - func `status item placement preflight writes low position when missing`() throws { + func `status item placement preflight writes low position on fresh install`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-missing-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -136,6 +136,120 @@ struct StatusItemControllerSplitLifecycleTests { #expect(defaults.double(forKey: key) == 0) } + @Test + func `status item placement preflight preserves missing new key when legacy item placement exists`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + } + + @Test + func `status item placement preflight repairs missing new key when legacy item placement is suspicious`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-high-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.double(forKey: key) == 0) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 11298) + } + + @Test + func `status item placement preflight preserves missing new key when mixed legacy placements exist`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-mixed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 11298) + } + + @Test + func `status item placement preflight repairs provider new key when mixed legacy placements exist`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-mixed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.double(forKey: key) == 0) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 11298) + } + + @Test + func `status item placement preflight repairs provider new key when only merged legacy placement exists`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-single-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.double(forKey: key) == 0) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + } + + @Test + func `status item placement preflight preserves provider key with matching legacy placement`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-matching-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(58, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 58) + } + @Test func `status item placement preflight replaces suspicious high position`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-high-\(UUID().uuidString)" From d7db9922b5fbd5999a31a1990d185fa59d9926c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 19:00:14 +0100 Subject: [PATCH 11/79] fix: refresh cold-start menu readiness Co-authored-by: Amr Mohamed --- CHANGELOG.md | 1 + .../StatusItemController+HostedSubmenus.swift | 75 ++- .../CodexBar/StatusItemController+Menu.swift | 18 +- ...ItemController+MenuRefreshScheduling.swift | 88 +++ .../StatusItemController+MenuTracking.swift | 20 +- ...tatusItemController+ProviderSwitcher.swift | 2 +- .../StatusItemController+Shutdown.swift | 2 + ...tatusItemController+UsageHistoryMenu.swift | 2 + Sources/CodexBar/StatusItemController.swift | 37 +- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 34 +- .../CodexBar/UsageStore+PlanUtilization.swift | 1 + Sources/CodexBar/UsageStore+TokenCost.swift | 30 + Sources/CodexBar/UsageStore.swift | 15 +- Sources/CodexBarCore/CostUsageFetcher.swift | 81 ++- .../Generated/CodexParserHash.generated.swift | 2 +- .../CodexBarCore/PiSessionCostScanner.swift | 25 + .../Vendored/CostUsage/CostUsageScanner.swift | 4 + .../CostUsageFetcherCacheSnapshotTests.swift | 286 ++++++++++ .../OpenAIWebRefreshGateTests.swift | 32 ++ .../StatusMenuHostedSubmenuRefreshTests.swift | 260 ++++++++- .../StatusMenuOpenRefreshTests.swift | 534 +++++++++++++++++- .../UsageStoreCachedTokenHydrationTests.swift | 166 ++++++ 22 files changed, 1655 insertions(+), 60 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift create mode 100644 Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e07b89e..59c05603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). +- Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! - Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! - Status: retry startup refreshes a few times after transient offline/network failures so provider status can recover after macOS brings the network online (#1211). diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 65bad39d..1f080abe 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -91,11 +91,82 @@ extension StatusItemController { } guard !didHydrate else { return } + self.appendHostedSubviewUnavailableItem(to: menu, chartID: chartID, providerRawValue: placeholder.toolTip) + } + + func refreshHostedSubviewMenu(_ menu: NSMenu) { + let width = self.renderedMenuWidth(for: menu) + guard let identity = self.hostedSubviewIdentity(for: menu) else { + self.refreshHostedSubviewHeights(in: menu) + return + } + menu.removeAllItems() + let didHydrate: Bool = switch identity.chartID { + case Self.usageBreakdownChartID: + self.appendUsageBreakdownChartItem(to: menu, width: width) + case Self.creditsHistoryChartID: + self.appendCreditsHistoryChartItem(to: menu, width: width) + case Self.costHistoryChartID: + if let provider = identity.provider { + self.appendCostHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.usageHistoryChartID: + if let provider = identity.provider { + self.appendUsageHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.storageBreakdownID: + if let provider = identity.provider { + self.appendStorageBreakdownItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.zaiHourlyUsageChartID: + if let provider = identity.provider { + self.appendZaiHourlyUsageChartItem(to: menu, provider: provider, width: width) + } else { + false + } + default: + false + } + + if didHydrate { + self.refreshHostedSubviewHeights(in: menu) + } else { + self.appendHostedSubviewUnavailableItem( + to: menu, + chartID: identity.chartID, + providerRawValue: identity.provider?.rawValue ?? identity.providerRawValue) + } + } + + private func hostedSubviewIdentity(for menu: NSMenu) + -> (chartID: String, provider: UsageProvider?, providerRawValue: String?)? { + for item in menu.items { + guard let chartID = item.representedObject as? String else { continue } + let providerRawValue = item.toolTip + return ( + chartID: chartID, + provider: providerRawValue.flatMap(UsageProvider.init(rawValue:)), + providerRawValue: providerRawValue) + } + return nil + } + + private func appendHostedSubviewUnavailableItem( + to menu: NSMenu, + chartID: String, + providerRawValue: String?) + { let unavailableItem = NSMenuItem(title: L("No data available"), action: nil, keyEquivalent: "") unavailableItem.isEnabled = false unavailableItem.representedObject = chartID - unavailableItem.toolTip = placeholder.toolTip + unavailableItem.toolTip = providerRawValue menu.addItem(unavailableItem) } @@ -167,6 +238,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -188,6 +260,7 @@ extension StatusItemController { chartItem.view = hosting chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f3926a93..7f2cf08a 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -76,10 +76,10 @@ extension StatusItemController { if self.isHostedSubviewMenu(menu) { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) - if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { + if self.isMenuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } - if Self.menuRefreshEnabled { + 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. self.openMenus[ObjectIdentifier(menu)] = menu @@ -107,13 +107,16 @@ extension StatusItemController { } } - let didRefresh = self.menuNeedsRefresh(menu) - if didRefresh { + if self.isMenuRefreshEnabled, (provider ?? self.lastMenuProvider) == .codex { + self.store.requestOpenAIDashboardRefreshIfStale(reason: "parent menu open") + } + + if self.menuNeedsRefresh(menu) { self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } - if Self.menuRefreshEnabled { + 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. self.openMenus[ObjectIdentifier(menu)] = menu @@ -127,7 +130,7 @@ extension StatusItemController { let wasHostedSubviewMenu = self.isHostedSubviewMenu(menu) self.forgetClosedMenu(menu) if wasHostedSubviewMenu { - self.refreshOpenMenusIfNeeded() + self.refreshOpenMenusAllowingParentRebuild() } } @@ -892,6 +895,7 @@ extension StatusItemController { textField.translatesAutoresizingMaskIntoConstraints = false container.addSubview(textField) + // macos-smell:disable MACOS005 NSLayoutConstraint.activate([ textField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), textField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), @@ -1123,7 +1127,7 @@ extension StatusItemController { guard let self, let menu else { return } try? await Task.sleep(for: Self.menuOpenRefreshDelay) guard !Task.isCancelled else { return } - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } #if DEBUG self.onDelayedMenuRefreshAttemptForTesting?() #endif diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 594aa483..4742e30e 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -3,6 +3,94 @@ import CodexBarCore import QuartzCore extension StatusItemController { + func didMenuAdjunctReadinessChange() -> Bool { + let signature = self.menuAdjunctReadinessSignature() + defer { self.lastMenuAdjunctReadinessSignature = signature } + return signature != self.lastMenuAdjunctReadinessSignature + } + + func menuAdjunctReadinessSignature() -> String { + let dashboard = self.store.openAIDashboard + let dashboardUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard?.usageBreakdown ?? []) + var parts = [ + "costEnabled=\(self.settings.costUsageEnabled ? "1" : "0")", + "openAIAttached=\(self.store.openAIDashboardAttachmentAuthorized ? "1" : "0")", + "openAILogin=\(self.store.openAIDashboardRequiresLogin ? "1" : "0")", + "openAIUpdated=\(Self.millisecondsSinceEpoch(dashboard?.updatedAt))", + "openAIDaily=\(Self.dashboardBreakdownReadinessSignature(dashboard?.dailyBreakdown ?? []))", + "openAIUsage=\(Self.dashboardBreakdownReadinessSignature(dashboardUsageBreakdown))", + "credits=\(self.store.credits == nil ? "0" : "1")", + "planHistoryRevision=\(self.store.planUtilizationHistoryRevision)", + ] + + for provider in self.store.enabledProvidersForDisplay() { + let tokenSignature = self.tokenSnapshotReadinessSignature(for: provider) + let usageHistoryVisible = self.store.supportsPlanUtilizationHistory(for: provider) && + !self.store.shouldHidePlanUtilizationMenuItem(for: provider) + parts.append( + [ + provider.rawValue, + "token=\(tokenSignature)", + "usageHistory=\(usageHistoryVisible ? "1" : "0")", + ].joined(separator: ":")) + } + + return parts.joined(separator: "|") + } + + private static func dashboardBreakdownReadinessSignature( + _ breakdown: [OpenAIDashboardDailyBreakdown]) -> String + { + breakdown + .map { day in + let services = day.services + .map { "\($0.service)=\(Self.formatDoubleForSignature($0.creditsUsed))" } + .joined(separator: ",") + return [ + day.day, + Self.formatDoubleForSignature(day.totalCreditsUsed), + services, + ].joined(separator: ":") + } + .joined(separator: ";") + } + + private func tokenSnapshotReadinessSignature(for provider: UsageProvider) -> String { + guard let snapshot = self.store.tokenSnapshot(for: provider) else { return "none" } + let daily = snapshot.daily + .map { entry in + [ + entry.date, + "\(entry.totalTokens ?? -1)", + Self.formatOptionalDoubleForSignature(entry.costUSD), + ].joined(separator: ",") + } + .joined(separator: ";") + return [ + "sessionTokens=\(snapshot.sessionTokens ?? -1)", + "sessionCost=\(Self.formatOptionalDoubleForSignature(snapshot.sessionCostUSD))", + "lastTokens=\(snapshot.last30DaysTokens ?? -1)", + "lastCost=\(Self.formatOptionalDoubleForSignature(snapshot.last30DaysCostUSD))", + "updated=\(Int(snapshot.updatedAt.timeIntervalSince1970 * 1000))", + "daily=\(daily)", + ].joined(separator: ",") + } + + private static func millisecondsSinceEpoch(_ date: Date?) -> Int { + guard let date else { return -1 } + return Int(date.timeIntervalSince1970 * 1000) + } + + private static func formatOptionalDoubleForSignature(_ value: Double?) -> String { + guard let value else { return "nil" } + return self.formatDoubleForSignature(value) + } + + private static func formatDoubleForSignature(_ value: Double) -> String { + String(format: "%.8f", value) + } + func performMenuMutationWithoutAnimation(_ updates: () -> Void) { CATransaction.begin() CATransaction.setDisableActions(true) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index d210bff6..59b53fa1 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -7,7 +7,7 @@ extension StatusItemController { } func refreshOpenMenusIfNeeded() { - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded(allowsParentRebuild: false) } @@ -17,11 +17,25 @@ extension StatusItemController { } func refreshOpenMenusAllowingParentRebuild() { - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded(allowsParentRebuild: true) } + func scheduleOpenMenuInvalidationRetry() { + self.openMenuInvalidationRetryTask?.cancel() + self.openMenuInvalidationRetryTask = Task { @MainActor [weak self] in + guard let self else { return } + await Task.yield() + guard !Task.isCancelled else { return } + #if DEBUG + self.onOpenMenuInvalidationRetryForTesting?() + #endif + self.refreshOpenMenusAllowingParentRebuild() + self.openMenuInvalidationRetryTask = nil + } + } + private func refreshOpenMenusIfNeeded(allowsParentRebuild: Bool) { var orphanedKeys: [ObjectIdentifier] = [] let hasOpenHostedSubviewMenu = self.hasOpenHostedSubviewMenu() @@ -44,7 +58,7 @@ extension StatusItemController { hasOpenHostedSubviewMenu: Bool) { if self.isHostedSubviewMenu(menu) { - self.refreshHostedSubviewHeights(in: menu) + self.refreshHostedSubviewMenu(menu) return } guard allowsParentRebuild else { return } diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 3b0f6873..d227a9ae 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -60,7 +60,7 @@ final class ProviderSwitcherShortcutEventMonitor { extension StatusItemController { func installProviderSwitcherShortcutMonitorIfNeeded(for menu: NSMenu) { - guard Self.menuRefreshEnabled, + guard self.isMenuRefreshEnabled, self.shouldMergeIcons, menu.items.first?.view is ProviderSwitcherView else { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 7dcfdcff..0ff330d7 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -49,6 +49,8 @@ extension StatusItemController { for task in self.openMenuRebuildTasks.values { task.cancel() } + self.openMenuInvalidationRetryTask?.cancel() + self.openMenuInvalidationRetryTask = nil } private func clearShutdownMenuState() { diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 97908cfe..2e663921 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -55,6 +55,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -73,6 +74,7 @@ extension StatusItemController { chartItem.view = hosting chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 564bd55c..7a166bf9 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -64,6 +64,20 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.menuRefreshEnabled = self.defaultMenuRefreshEnabled } #endif + + #if DEBUG + var menuRefreshEnabledOverrideForTesting: Bool? + #endif + + var isMenuRefreshEnabled: Bool { + #if DEBUG + if let menuRefreshEnabledOverrideForTesting { + return menuRefreshEnabledOverrideForTesting + } + #endif + return Self.menuRefreshEnabled + } + typealias Factory = @MainActor ( UsageStore, @@ -112,6 +126,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuContentVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] + var lastMenuAdjunctReadinessSignature = "" var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] var fallbackMenu: NSMenu? @@ -125,8 +140,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? var hasPreparedForAppShutdown = false + var openMenuInvalidationRetryTask: Task? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? + var onOpenMenuInvalidationRetryForTesting: (() -> Void)? var isReleasedForTesting = false var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? @@ -352,6 +369,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin "Repaired hidden macOS status-item visibility defaults", metadata: ["keys": repairedStatusItemVisibilityKeys.joined(separator: ",")]) } + self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() self.wireBindings() self.updateVisibility() self.updateIcons() @@ -423,7 +441,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreChanges() - self.invalidateMenus() + self.invalidateMenus(refreshOpenMenus: self.didMenuAdjunctReadinessChange()) } } } @@ -593,26 +611,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } if !self.openMenus.isEmpty { guard refreshOpenMenus else { return } self.refreshOpenMenusAllowingParentRebuild() - Task { @MainActor [weak self] in - guard let self else { return } - // AppKit can ignore menu mutations while tracking; retry on the next run loop. - await Task.yield() - self.refreshOpenMenusAllowingParentRebuild() - } + self.scheduleOpenMenuInvalidationRetry() return } - self.refreshOpenMenusIfNeeded() - Task { @MainActor [weak self] in - guard let self else { return } - // AppKit can ignore menu mutations while tracking; retry on the next run loop. - await Task.yield() - guard self.openMenus.isEmpty else { return } - self.refreshOpenMenusIfNeeded() - } } private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool { diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index e9b233ec..2aaedfd8 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -63,13 +63,30 @@ extension UsageStore { else { return } let now = Date() let refreshInterval = self.openAIWebRefreshIntervalSeconds() - let lastUpdatedAt = self.openAIDashboard?.updatedAt ?? self.lastOpenAIDashboardSnapshot?.updatedAt - if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval { return } + let dashboard = self.openAIDashboard ?? self.lastOpenAIDashboardSnapshot + let lastUpdatedAt = dashboard?.updatedAt + let needsMenuHistoryRefresh = dashboard?.dailyBreakdown.isEmpty == true && + dashboard?.usageBreakdown.isEmpty == true + if needsMenuHistoryRefresh, + Self.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: self.openAIWebAccountDidChange, + lastError: self.lastOpenAIDashboardError, + lastSnapshotAt: lastUpdatedAt, + lastAttemptAt: self.lastOpenAIDashboardAttemptAt, + now: now, + refreshInterval: refreshInterval)) + { + return + } + if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval, !needsMenuHistoryRefresh { + return + } let stamp = now.formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)") let forceRefresh = Self.forceOpenAIWebRefreshForStaleRequest( - batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) - self.openAIWebLogger.debug( + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) || needsMenuHistoryRefresh + self.openAIWebLogger.info( "OpenAI web stale refresh gate", metadata: [ "reason": reason, @@ -1330,6 +1347,15 @@ extension UsageStore { return false } + nonisolated static func shouldSkipOpenAIWebEmptyHistoryRetry(_ context: OpenAIWebRefreshGateContext) -> Bool { + if context.force || context.accountDidChange { return false } + guard let lastAttemptAt = context.lastAttemptAt, + context.now.timeIntervalSince(lastAttemptAt) < context.refreshInterval + else { return false } + guard let lastSnapshotAt = context.lastSnapshotAt else { return true } + return lastAttemptAt >= lastSnapshotAt + } + func syncOpenAIWebState() { guard self.isEnabled(.codex), self.settings.openAIWebAccessEnabled, diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 82b81708..2f87fbc5 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -115,6 +115,7 @@ extension UsageStore { providerBuckets.setHistories(updatedHistories, for: accountKey) self.planUtilizationHistory[provider] = providerBuckets + self.planUtilizationHistoryRevision &+= 1 snapshotToPersist = self.planUtilizationHistory } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 13c162ed..5e1550ce 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -14,6 +14,36 @@ extension UsageStore { self.lastTokenFetchAt[provider] } + func hydrateCachedTokenSnapshots(now: Date = Date()) { + guard self.settings.costUsageEnabled else { return } + guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { + return + } + + let scope = self.tokenCostScope(for: .codex) + let historyDays = self.settings.costUsageHistoryDays + Task { @MainActor [weak self] in + guard let self else { return } + guard self.tokenSnapshots[.codex] == nil else { return } + guard let snapshot = await self.costUsageFetcher.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: scope.codexHomePath, + historyDays: historyDays) + else { + return + } + guard self.settings.costUsageEnabled, + self.isEnabled(.codex), + self.tokenCostScope(for: .codex).signature == scope.signature, + self.tokenSnapshots[.codex] == nil + else { + return + } + self.tokenSnapshots[.codex] = snapshot + self.tokenErrors[.codex] = nil + } + } + func isTokenRefreshInFlight(for provider: UsageProvider) -> Bool { self.tokenRefreshInFlight.contains(provider) } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index de59e682..bef03360 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -24,6 +24,7 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin + _ = self.openAIDashboardAttachmentRevision _ = self.versions _ = self.isRefreshing _ = self.refreshingProviders @@ -31,6 +32,7 @@ extension UsageStore { _ = self.statuses _ = self.probeLogs _ = self.historicalPaceRevision + _ = self.planUtilizationHistoryRevision _ = self.providerStorageFootprints return 0 } @@ -140,12 +142,20 @@ final class UsageStore { var statuses: [UsageProvider: ProviderStatus] = [:] var probeLogs: [UsageProvider: String] = [:] var historicalPaceRevision: Int = 0 + var planUtilizationHistoryRevision: Int = 0 var providerStorageFootprints: [UsageProvider: ProviderStorageFootprint] = [:] @ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot? @ObservationIgnored var lastCreditsSnapshotAccountKey: String? @ObservationIgnored var lastCreditsSource: CodexCreditsSource = .none @ObservationIgnored var creditsFailureStreak: Int = 0 - @ObservationIgnored var openAIDashboardAttachmentAuthorized: Bool = false + @ObservationIgnored var openAIDashboardAttachmentAuthorized: Bool = false { + didSet { + guard self.openAIDashboardAttachmentAuthorized != oldValue else { return } + self.openAIDashboardAttachmentRevision &+= 1 + } + } + + var openAIDashboardAttachmentRevision = 0 @ObservationIgnored var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored var lastOpenAIDashboardAttachmentAuthorized: Bool = false @ObservationIgnored var lastOpenAIDashboardTargetEmail: String? @@ -186,7 +196,7 @@ final class UsageStore { @ObservationIgnored let codexFetcher: UsageFetcher @ObservationIgnored let claudeFetcher: any ClaudeUsageFetching - @ObservationIgnored private let costUsageFetcher: CostUsageFetcher + @ObservationIgnored let costUsageFetcher: CostUsageFetcher @ObservationIgnored let browserDetection: BrowserDetection @ObservationIgnored private let registry: ProviderRegistry @ObservationIgnored let settings: SettingsStore @@ -301,6 +311,7 @@ final class UsageStore { effectivePATH: PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling]), loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":")) guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } + self.hydrateCachedTokenSnapshots() self.detectVersions() self.updateProviderRuntimes() Task { @MainActor [weak self] in diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index fa30ac9d..2611e81a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -18,7 +18,27 @@ public enum CostUsageError: LocalizedError, Sendable { } public struct CostUsageFetcher: Sendable { - public init() {} + private let scannerOptions: CostUsageScanner.Options? + + public init(cacheRoot: URL? = nil) { + self.scannerOptions = cacheRoot.map { CostUsageScanner.Options(cacheRoot: $0) } + } + + init(scannerOptions: CostUsageScanner.Options) { + self.scannerOptions = scannerOptions + } + + public func loadCachedCodexTokenSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30) async -> CostUsageTokenSnapshot? + { + await Self.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + scannerOptions: self.scannerOptionsOverride()) + } public func loadTokenSnapshot( provider: UsageProvider, @@ -38,7 +58,12 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, - refreshPricingInBackground: refreshPricingInBackground) + refreshPricingInBackground: refreshPricingInBackground, + scannerOptions: self.scannerOptionsOverride()) + } + + private func scannerOptionsOverride() -> CostUsageScanner.Options? { + self.scannerOptions } static func loadTokenSnapshot( @@ -150,6 +175,58 @@ public struct CostUsageFetcher: Sendable { return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) } + static func loadCachedCodexTokenSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot? + { + if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHomePath.isEmpty + { + return nil + } + + return await Task.detached(priority: .utility) { + let clampedHistoryDays = max(1, min(365, historyDays)) + let until = now + let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let options = overrideScannerOptions ?? CostUsageScanner.Options() + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + var reports: [CostUsageDailyReport] = [] + + if !cache.days.isEmpty, + cache.roots == CostUsageScanner.codexRootsFingerprint(options: options), + !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: cache) + { + let daily = CostUsageScanner.buildCodexReportFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot) + if !daily.data.isEmpty { + reports.append(daily) + } + } + + if let piDaily = PiSessionCostScanner.loadCachedDailyReport( + provider: .codex, + since: since, + until: until, + now: now, + cacheRoot: options.cacheRoot) + { + reports.append(piDaily) + } + + guard !reports.isEmpty else { return nil } + return Self.tokenSnapshot( + from: CostUsageDailyReport.merged(reports), + now: now, + historyDays: clampedHistoryDays) + }.value + } + private static func loadBedrockDailyReport( environment: [String: String], since: Date, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index a6e62fab..d57477c7 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 = "deae0d501740801d" + static let value = "c55f8a5a2d69092d" } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index d6d9f1e8..a1aebbda 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -133,6 +133,31 @@ enum PiSessionCostScanner { pricingContext: pricingContext) } + static func loadCachedDailyReport( + provider: UsageProvider, + since: Date, + until: Date, + now: Date = Date(), + cacheRoot: URL? = nil) -> CostUsageDailyReport? + { + guard provider == .codex || provider == .claude else { return nil } + + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let cache = PiSessionCostCacheIO.load(cacheRoot: cacheRoot) + guard !cache.daysByProvider.isEmpty else { return nil } + guard !self.requestedWindowExpandsCache(range: range, cache: cache) else { return nil } + + let pricingContext = ModelsDevPricingContext( + catalog: CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: cacheRoot), + cacheRoot: cacheRoot) + let report = self.buildReport( + provider: provider, + cache: cache, + range: range, + pricingContext: pricingContext) + return report.data.isEmpty ? nil : report + } + private static func requestedWindowExpandsCache( range: CostUsageScanner.CostUsageDayRange, cache: PiSessionCostCache) -> Bool diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 09e2014e..6484fa6e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -572,6 +572,10 @@ enum CostUsageScanner { return out } + static func codexRootsFingerprint(options: Options) -> [String: Int64] { + self.codexRootsFingerprint(self.codexSessionsRoots(options: options)) + } + private static func codexPricingKey(modelsDevArtifact: ModelsDevCacheArtifact?) -> String { guard let modelsDevArtifact else { let fingerprint = CostUsagePricing.codexBuiltInPricingFingerprint() diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift new file mode 100644 index 00000000..df7761a3 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -0,0 +1,286 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherCacheSnapshotTests { + @Test + func `cached codex token snapshot loads from existing cache without rescanning`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 42) + #expect(cached?.last30DaysTokens == 42) + #expect(cached?.daily.map(\.date) == ["2026-04-08"]) + } + + @Test + func `cached codex token snapshot refuses expanded or managed scopes`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let expanded = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 7, + scannerOptions: options) + let managed = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + scannerOptions: options) + + #expect(expanded == nil) + #expect(managed == nil) + } + + @Test + func `cached codex token snapshot refuses mismatched roots fingerprint`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.roots = [env.root.appendingPathComponent("other/sessions", isDirectory: true).path: 0] + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached == nil) + } + + @Test + func `cached codex token snapshot merges cached pi sessions`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + refreshPricingInBackground: false, + scannerOptions: options, + piScannerOptions: piOptions) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 207) + #expect(cached?.last30DaysTokens == 207) + } + + @Test + func `cached codex token snapshot loads cached pi sessions without native codex cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: piOptions) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot)) + + #expect(cached?.sessionTokens == 165) + #expect(cached?.last30DaysTokens == 165) + } + + @Test + func `cached codex token snapshot still loads pi sessions when native cache roots mismatch`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + refreshPricingInBackground: false, + scannerOptions: options, + piScannerOptions: piOptions) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.roots = [env.root.appendingPathComponent("other/sessions", isDirectory: true).path: 0] + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 165) + #expect(cached?.last30DaysTokens == 165) + } + + private static func writeCodexSessionFile( + homeRoot: URL, + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int) throws + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = homeRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = dir.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + } + + private static func writePiCodexSessionFile( + env: CostUsageTestEnvironment, + day: Date, + tokens: Int) throws + { + _ = try env.writePiSessionFile( + relativePath: "nested/run-0/2026-04-08T10-00-00-000Z_test.jsonl", + contents: env.jsonl([ + [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": tokens, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": tokens, + ], + ], + ], + ])) + } +} diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index eead8bb8..1ea58b00 100644 --- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -110,4 +110,36 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == false) } + + @Test + func `Empty dashboard history retry is throttled after a recent attempt`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-120), + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test + func `Empty dashboard history retry runs once for a newer empty snapshot`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-60), + lastAttemptAt: now.addingTimeInterval(-120), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } } diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 845c2ab6..57e15561 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -7,14 +7,11 @@ import Testing @Suite(.serialized) struct StatusMenuHostedSubmenuRefreshTests { @Test - func `open parent menu defers data rebuild until next open`() throws { + func `open parent menu defers data rebuild until hosted submenu closes`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled - let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = true - StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering - StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = Self.makeSettings() @@ -37,6 +34,7 @@ struct StatusMenuHostedSubmenuRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = false let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -54,7 +52,7 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) #expect(submenu.items.first?.view == nil) - StatusItemController.setMenuRefreshEnabledForTesting(true) + controller.menuRefreshEnabledOverrideForTesting = true controller.menuWillOpen(submenu) let submenuKey = ObjectIdentifier(submenu) #expect(controller.openMenus[submenuKey] === submenu) @@ -64,16 +62,157 @@ struct StatusMenuHostedSubmenuRefreshTests { controller.menuContentVersion &+= 1 controller.refreshOpenMenusIfNeeded() #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + #expect(controller.menuVersions[parentKey] == oldParentVersion) controller.menuDidClose(submenu) #expect(controller.openMenus[submenuKey] == nil) - #expect(controller.menuVersions[parentKey] == oldParentVersion) - controller.menuDidClose(menu) - controller.menuWillOpen(menu) + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } + #expect(controller.menuVersions[parentKey] == controller.menuContentVersion) } + @Test + func `open hosted submenu rebuilds from unavailable placeholder when data arrives`() async { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.costUsageEnabled = true + Self.enableOnlyClaude(settings) + + 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() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + let submenuKey = ObjectIdentifier(submenu) + #expect(controller.openMenus[submenuKey] === submenu) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.view == nil) + #expect(submenu.items.first?.title == "No data available") + + let openedVersion = controller.menuContentVersion + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + controller.invalidateMenus(refreshOpenMenus: true) + + for _ in 0..<40 { + if controller.menuContentVersion != openedVersion, + submenu.items.first?.view != nil + { + break + } + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.view != nil) + #expect(submenu.items.first?.title != "No data available") + } + + @Test + func `open hydrated provider submenu preserves identity across refresh`() throws { + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.costHistoryChartID, + provider: .openai, + seed: Self.seedOpenAICostSnapshot) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.zaiHourlyUsageChartID, + provider: .zai, + seed: Self.seedZaiHourlyUsage) + } + + private func assertHostedSubmenuPreservesIdentity( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + Self.enableOnly(settings, provider: provider) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + seed(store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: chartID, + provider: provider, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + + let hydratedItem = try #require(submenu.items.first) + #expect(hydratedItem.representedObject as? String == chartID) + #expect(hydratedItem.toolTip == provider.rawValue) + #expect(hydratedItem.view != nil) + #expect(hydratedItem.title != "No data available") + + controller.refreshHostedSubviewMenu(submenu) + + let refreshedItem = try #require(submenu.items.first) + #expect(refreshedItem.representedObject as? String == chartID) + #expect(refreshedItem.toolTip == provider.rawValue) + #expect(refreshedItem.view != nil) + #expect(refreshedItem.title != "No data available") + } + private static func makeSettings() -> SettingsStore { let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -86,12 +225,14 @@ struct StatusMenuHostedSubmenuRefreshTests { } private static func enableOnlyClaude(_ settings: SettingsStore) { + self.enableOnly(settings, provider: .claude) + } + + private static func enableOnly(_ settings: SettingsStore, provider enabledProvider: UsageProvider) { let registry = ProviderRegistry.shared - if let codexMeta = registry.metadata[.codex] { - settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: false) - } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider) } } @@ -107,7 +248,96 @@ struct StatusMenuHostedSubmenuRefreshTests { accountOrganization: nil, loginMethod: "Team")) store._setSnapshotForTesting(snapshot, provider: .claude) - store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + } + + private static func seedOpenAICostSnapshot(in store: UsageStore) { + let day = Date(timeIntervalSince1970: 1_700_000_000) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2025-12-23", + startTime: day, + endTime: day.addingTimeInterval(86400), + costUSD: 1.23, + requests: 12, + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 40, + totalTokens: 160, + lineItems: [], + models: []), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_086_400)) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + openAIAPIUsage: apiUsage, + updatedAt: Date(timeIntervalSince1970: 1_700_086_400), + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: "openai@example.com", + accountOrganization: nil, + loginMethod: "API")) + store._setSnapshotForTesting(snapshot, provider: .openai) + } + + private static func seedPlanUtilizationHistory(in store: UsageStore) { + 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)), + ]), + ]) + } + + private static func seedStorageFootprint(in store: UsageStore) { + let root = "/Users/test/.claude" + store.providerStorageFootprints[.claude] = ProviderStorageFootprint( + provider: .claude, + totalBytes: 1024, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [.init(path: "\(root)/projects", totalBytes: 1024)], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + } + + private static func seedZaiHourlyUsage(in store: UsageStore) { + let modelUsage = ZaiModelUsageData( + xTime: ["2026-05-26 00:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [512]), + ]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + zaiUsage: ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: nil, + planName: "Pro", + modelUsage: modelUsage, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: "zai@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + store._setSnapshotForTesting(snapshot, provider: .zai) + } + + private static func makeTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, last30DaysTokens: 123, @@ -122,6 +352,6 @@ struct StatusMenuHostedSubmenuRefreshTests { modelsUsed: nil, modelBreakdowns: nil), ], - updatedAt: Date()), provider: .claude) + updatedAt: Date()) } } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index aeec4c1c..88c20fcb 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -26,8 +27,7 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let openedVersion = controller.menuVersions[key] var rebuildCount = 0 @@ -84,8 +84,7 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let openedVersion = controller.menuVersions[key] var rebuildCount = 0 @@ -126,8 +125,7 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true var rebuildCount = 0 controller._test_openMenuRebuildObserver = { _ in @@ -175,8 +173,7 @@ extension StatusMenuTests { provider: .codex) let submenuKey = ObjectIdentifier(submenu) controller.openMenus[submenuKey] = submenu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true var rebuildCount = 0 controller._test_openMenuRebuildObserver = { _ in @@ -195,4 +192,525 @@ extension StatusMenuTests { #expect(rebuildCount == 1) #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) } + + @Test + func `codex parent menu open requests stale OpenAI web refresh with battery saver enabled`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = 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.menuWillOpen(menu) + + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `codex parent menu open refreshes recent dashboard cache with no chart history`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: Date()) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = 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.menuWillOpen(menu) + + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: Date()))) + } + + @Test + func `codex parent menu open throttles recent empty dashboard retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: now.addingTimeInterval(-120)) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + store.lastOpenAIDashboardAttemptAt = now.addingTimeInterval(-60) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = 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.menuWillOpen(menu) + + try? await Task.sleep(for: .milliseconds(150)) + #expect(await blocker.startedCount() == 0) + } + + @Test + func `credits history arriving after open refreshes parent menu without explicit refresh`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: now) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCredits") == nil) + + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now.addingTimeInterval(10)) + + await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + #expect( + creditsItem.submenu?.items.first?.representedObject as? String == + StatusItemController.creditsHistoryChartID) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `fresh dashboard history with same day count refreshes parent menu`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let now = Date(timeIntervalSince1970: 100) + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: now) + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + _ = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 99), + ], + updatedAt: now.addingTimeInterval(10)) + + await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + #expect(creditsItem.submenu?.items.first?.representedObject as? String == StatusItemController + .creditsHistoryChartID) + } + + @Test + func `token cost history arriving after open refreshes parent menu without explicit refresh`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodex(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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCost") == nil) + + store._setTokenSnapshotForTesting(self.makeCodexTokenCostSnapshot(), provider: .codex) + + await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) + #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `fresh token cost history with same day count refreshes parent menu`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting( + self.makeCodexTokenCostSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 456, + last30DaysCostUSD: 1.23, + updatedAt: Date(timeIntervalSince1970: 100)), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + _ = try #require(self.menuItem(in: menu, id: "menuCardCost")) + + store._setTokenSnapshotForTesting( + self.makeCodexTokenCostSnapshot( + sessionTokens: 999, + sessionCostUSD: 0.99, + last30DaysTokens: 888, + last30DaysCostUSD: 8.88, + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) + #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + } + + @Test + func `plan utilization history arriving after open refreshes parent menu without explicit refresh`() async throws { + 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) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + let usageHistoryItem = try #require(self.menuItem(in: menu, id: "usageHistorySubmenu")) + #expect(usageHistoryItem.submenu?.items.first?.representedObject as? String == StatusItemController + .usageHistoryChartID) + let openedRevision = store.planUtilizationHistoryRevision + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: self.makeCodexPlanUtilizationSnapshot(), + now: Date()) + + await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + + #expect(store.planUtilizationHistoryRevision > openedRevision) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `dashboard attachment authorization arriving after open refreshes parent menu`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(store.openAIDashboardAttachmentRevision == 0) + + store.openAIDashboardAttachmentAuthorized = true + + await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + + #expect(store.openAIDashboardAttachmentRevision == 1) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + private func enableOnlyCodex(_ 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: provider == .codex) + } + } + + private func menuItem(in menu: NSMenu, id: String) -> NSMenuItem? { + menu.items.first { ($0.representedObject as? String) == id } + } + + private func waitUntilMenuVersionChanges( + _ controller: StatusItemController, + from version: Int?) async + { + for _ in 0..<20 where controller.menuContentVersion == version { + await Task.yield() + } + } + + private func waitUntilOpenMenuIsFresh( + _ controller: StatusItemController, + key: ObjectIdentifier, + after version: Int?) async + { + for _ in 0..<40 { + guard controller.menuContentVersion != version else { + await Task.yield() + continue + } + guard controller.menuVersions[key] == controller.menuContentVersion else { + await Task.yield() + continue + } + return + } + } + + private func makeOpenAIDashboard( + dailyBreakdown: [OpenAIDashboardDailyBreakdown], + updatedAt: Date) -> OpenAIDashboardSnapshot + { + OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: dailyBreakdown, + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: updatedAt) + } + + private func makeCodexTokenCostSnapshot( + sessionTokens: Int = 123, + sessionCostUSD: Double = 0.12, + last30DaysTokens: Int = 456, + last30DaysCostUSD: Double = 1.23, + updatedAt: Date = Date()) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-24", + inputTokens: nil, + outputTokens: nil, + totalTokens: sessionTokens, + costUSD: last30DaysCostUSD, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } + + private func makeCodexPlanUtilizationSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 35, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + } } diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift new file mode 100644 index 00000000..5b0fb659 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +@Suite(.serialized) +struct UsageStoreCachedTokenHydrationTests { + @Test + func `cached codex token hydration populates startup token snapshot`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + store.hydrateCachedTokenSnapshots(now: day) + + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42) + #expect(store.tokenSnapshot(for: .codex)?.daily.map(\.date) == ["2026-04-08"]) + #expect(store.tokenError(for: .codex) == nil) + } + + @Test + func `cached codex token hydration skips managed codex homes`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: env.codexHomeRoot.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + store.hydrateCachedTokenSnapshots(now: day) + + for _ in 0..<20 { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.tokenSnapshot(for: .codex) == nil) + } + + private static func makeCodexOnlySettings(historyDays: Int) -> SettingsStore { + let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.costUsageHistoryDays = historyDays + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + settings.providerDetectionCompleted = true + + 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) + } + return settings + } + + private static func writeCodexSessionFile( + homeRoot: URL, + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int) throws + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = homeRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = dir.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + } +} From 4a2ef3ae18f6a1af1a5eb21b68d05381ff15588e Mon Sep 17 00:00:00 2001 From: Brandon Charleson Date: Sat, 30 May 2026 11:43:40 -0700 Subject: [PATCH 12/79] fix: parse updated Augment auggie status Parse the updated Augment auggie account status output and fall back to browser cookies when CLI parsing fails. Local proof: - swift test --filter AuggieCLIProbeParseTests - swift test --filter AugmentCLIFetchStrategyFallbackTests - swift test - make check - autoreview --mode local CI: all required checks green. Co-authored-by: Brandon Charleson Co-authored-by: Cursor --- .../Augment/AugmentProviderRuntime.swift | 3 +- .../Providers/Augment/AuggieCLIProbe.swift | 44 ++++++++++++---- .../Augment/AugmentProviderDescriptor.swift | 4 +- .../Augment/AugmentSessionKeepalive.swift | 11 ++++ .../Augment/AugmentStatusProbe.swift | 5 +- .../AuggieCLIProbeParseTests.swift | 50 +++++++++++++++++++ ...AugmentCLIFetchStrategyFallbackTests.swift | 4 +- 7 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 Tests/CodexBarTests/AuggieCLIProbeParseTests.swift diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift index b2593c54..0a6bb90d 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift @@ -92,6 +92,7 @@ final class AugmentProviderRuntime: ProviderRuntime { private func forceRefresh(context: ProviderRuntimeContext) async { #if os(macOS) context.store.augmentLogger.info("Augment force refresh requested") + CookieHeaderCache.clear(provider: .augment) guard let keepalive = self.keepalive else { context.store.augmentLogger.warning("Augment keepalive not running; starting") self.startKeepalive(context: context) @@ -105,8 +106,6 @@ final class AugmentProviderRuntime: ProviderRuntime { } await keepalive.forceRefresh() - context.store.augmentLogger.info("Refreshing Augment usage after session refresh") - await context.store.refreshProvider(.augment) #endif } } diff --git a/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift b/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift index 19eb38e9..ddbb6de2 100644 --- a/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift @@ -52,11 +52,16 @@ public struct AuggieCLIProbe: Sendable { return output } - private func parse(_ output: String) throws -> AugmentStatusSnapshot { - // Parse output like: + func parse(_ output: String) throws -> AugmentStatusSnapshot { + // Legacy output: // Max Plan 450,000 credits / month // 11,657 remaining · 953,170 / 964,827 credits used // 2 days remaining in this billing cycle (ends 1/8/2026) + // + // Current output (2026+): + // 319,054 credits remaining Max Plan + // 450,000 credits / month + // 9 days remaining in this billing cycle (ends 6/9/2026) var maxCredits: Int? var remaining: Int? @@ -67,8 +72,15 @@ public struct AuggieCLIProbe: Sendable { for line in output.split(separator: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) - // Parse "Max Plan 450,000 credits / month" - if trimmed.contains("Max Plan"), trimmed.contains("credits") { + if trimmed.contains("credits / month") { + if let match = trimmed.range(of: #"([\d,]+)\s+credits\s*/\s*month"#, options: .regularExpression) { + let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " credits", with: "") + .replacingOccurrences(of: " / month", with: "") + maxCredits = Int(numberStr) + total = total ?? Int(numberStr) + } + } else if trimmed.contains("Max Plan"), trimmed.contains("credits"), !trimmed.contains("remaining") { if let match = trimmed.range(of: #"([\d,]+)\s+credits"#, options: .regularExpression) { let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") .replacingOccurrences(of: " credits", with: "") @@ -76,9 +88,17 @@ public struct AuggieCLIProbe: Sendable { } } + if trimmed.contains("credits remaining"), !trimmed.contains("billing cycle") { + if let match = trimmed.range(of: #"([\d,]+)\s+credits\s+remaining"#, options: .regularExpression) { + let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " credits", with: "") + .replacingOccurrences(of: " remaining", with: "") + remaining = Int(numberStr) + } + } + // Parse "11,657 remaining · 953,170 / 964,827 credits used" if trimmed.contains("remaining"), trimmed.contains("credits used") { - // Extract remaining if let remMatch = trimmed.range(of: #"([\d,]+)\s+remaining"#, options: .regularExpression) { let numStr = String(trimmed[remMatch]) .replacingOccurrences(of: ",", with: "") @@ -86,7 +106,6 @@ public struct AuggieCLIProbe: Sendable { remaining = Int(numStr) } - // Extract used / total if let usedMatch = trimmed.range( of: #"([\d,]+)\s*/\s*([\d,]+)\s+credits used"#, options: .regularExpression) @@ -103,15 +122,12 @@ public struct AuggieCLIProbe: Sendable { } } - // Parse "2 days remaining in this billing cycle (ends 1/8/2026)" if trimmed.contains("billing cycle"), trimmed.contains("ends") { - // Extract date from "(ends 1/8/2026)" if let dateMatch = trimmed.range(of: #"ends\s+([\d/]+)"#, options: .regularExpression) { let dateStr = String(trimmed[dateMatch]) .replacingOccurrences(of: "ends", with: "") .trimmingCharacters(in: .whitespaces) - // Parse date like "1/8/2026" let formatter = DateFormatter() formatter.dateFormat = "M/d/yyyy" formatter.locale = Locale(identifier: "en_US_POSIX") @@ -121,11 +137,19 @@ public struct AuggieCLIProbe: Sendable { } } - guard let finalRemaining = remaining, let finalUsed = used, let finalTotal = total else { + guard let finalRemaining = remaining else { Self.log.error("Failed to parse auggie output: \(output)") throw AuggieCLIError.parseError("Could not extract credits from output") } + let finalTotal = total ?? maxCredits + guard let finalTotal else { + Self.log.error("Failed to parse auggie output: \(output)") + throw AuggieCLIError.parseError("Could not extract credits from output") + } + + let finalUsed = used ?? max(0, finalTotal - finalRemaining) + return AugmentStatusSnapshot( creditsRemaining: Double(finalRemaining), creditsUsed: Double(finalUsed), diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift index 4ccb0987..7c259d4d 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift @@ -95,10 +95,8 @@ struct AugmentCLIFetchStrategy: ProviderFetchStrategy { // Fallback to web if CLI fails (not authenticated, etc.) if let cliError = error as? AuggieCLIError { switch cliError { - case .notAuthenticated, .noOutput: + case .notAuthenticated, .noOutput, .parseError: return true - case .parseError: - return false // Don't fallback on parse errors - something is wrong } } return true diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift index 40e1974a..7ffa2aea 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift @@ -212,6 +212,12 @@ public final class AugmentSessionKeepalive { try await Task.sleep(for: .seconds(1)) // Brief delay for browser to update cookies let newSession = try AugmentCookieImporter.importSession(logger: self.logger) + await AugmentSessionStore.shared.setCookies(newSession.cookies) + CookieHeaderCache.store( + provider: .augment, + cookieHeader: newSession.cookieHeader, + sourceLabel: newSession.sourceLabel) + self.log( "✅ Session refresh successful - imported \(newSession.cookies.count) cookies " + "from \(newSession.sourceLabel)") @@ -220,6 +226,11 @@ public final class AugmentSessionKeepalive { // Reset failure tracking on success self.consecutiveFailures = 0 self.hasGivenUp = false + + if let callback = self.onSessionRecovered { + self.log("🔄 Triggering usage refresh after session refresh") + await callback() + } } else { self.log("⚠️ Session refresh returned no new cookies") self.consecutiveFailures += 1 diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift index cff8957a..52ae33ae 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift @@ -15,12 +15,15 @@ public enum AugmentCookieImporter { /// NOTE: This list may not be exhaustive. If authentication fails with cookies present, /// check debug logs for cookie names and report them. private static let sessionCookieNames: Set = [ - "_session", // Legacy session cookie + "session", // Augment auth session (auth.augmentcode.com) + "_session", // Legacy session cookie (app.augmentcode.com) + "web_rpc_proxy_session", // Augment RPC proxy session "auth0", // Auth0 session "auth0.is.authenticated", // Auth0 authentication flag "a0.spajs.txs", // Auth0 SPA transaction state "__Secure-next-auth.session-token", // NextAuth secure session "next-auth.session-token", // NextAuth session + "__Secure-authjs.session-token", // AuthJS secure session "__Host-authjs.csrf-token", // AuthJS CSRF token "authjs.session-token", // AuthJS session ] diff --git a/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift b/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift new file mode 100644 index 00000000..338d8d2f --- /dev/null +++ b/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) + +struct AuggieCLIProbeParseTests { + private let probe = AuggieCLIProbe() + + @Test + func `parses current auggie account status output`() throws { + let output = """ + ╭ Account ───────────────────────────────────────────────╮ + │ │ + │ 319,054 credits remaining Max Plan │ + │ 450,000 credits / month │ + │ │ + ╰────────────────────────────────────────────────────────╯ + + 9 days remaining in this billing cycle (ends 6/9/2026) + For more detail, visit https://app.augmentcode.com/account + """ + + let snapshot = try probe.parse(output) + + #expect(snapshot.creditsRemaining == 319_054) + #expect(snapshot.creditsLimit == 450_000) + #expect(snapshot.creditsUsed == 130_946) + #expect(snapshot.accountPlan == "\(450_000.formatted()) credits/month") + #expect(snapshot.billingCycleEnd != nil) + } + + @Test + func `parses legacy auggie account status output`() throws { + let output = """ + Max Plan 450,000 credits / month + 11,657 remaining · 953,170 / 964,827 credits used + 2 days remaining in this billing cycle (ends 1/8/2026) + """ + + let snapshot = try probe.parse(output) + + #expect(snapshot.creditsRemaining == 11657) + #expect(snapshot.creditsUsed == 953_170) + #expect(snapshot.creditsLimit == 964_827) + #expect(snapshot.accountPlan == "\(450_000.formatted()) credits/month") + } +} + +#endif diff --git a/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift b/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift index 58d9b0b6..905a8e92 100644 --- a/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift +++ b/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift @@ -79,10 +79,10 @@ struct AugmentCLIFetchStrategyFallbackTests { } @Test - func `parse error does not fall back`() { + func `parse error falls back to web`() { let strategy = AugmentCLIFetchStrategy() let context = self.makeContext() - #expect(strategy.shouldFallback(on: AuggieCLIError.parseError("bad data"), context: context) == false) + #expect(strategy.shouldFallback(on: AuggieCLIError.parseError("bad data"), context: context) == true) } @Test From a509408c9ad47a0bfcc098467597e08b36995139 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 19:43:57 +0100 Subject: [PATCH 13/79] docs: update changelog for Augment parser fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c05603..a6e06e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Settings: add search to the Providers pane so large provider lists can be filtered by name or id (#1184). Thanks @046081-dotcom! ### Fixed +- Augment: parse the updated `auggie account status` output format, fall back to browser cookies when CLI parsing fails, and restore session cookie detection (#1224). Thanks @bcharleson! - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). - Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! - Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! From cdd7e347c1cf616615f18aa2ac52ba2ec9cab332 Mon Sep 17 00:00:00 2001 From: Hinotobi Date: Sun, 31 May 2026 03:23:33 +0800 Subject: [PATCH 14/79] fix: require HTTPS for provider redirect cookies Require HTTPS before manually reattaching imported browser cookies on Amp and Ollama redirects. Local proof: - swift test --filter 'AmpUsageFetcherTests|OllamaUsageFetcherTests' - make check - git diff --check origin/main...HEAD - autoreview --mode branch --base origin/main CI: all required checks green. Co-authored-by: hinotoi-agent --- .../CodexBarCore/Providers/Amp/AmpUsageFetcher.swift | 7 ++++++- .../Providers/Ollama/OllamaUsageFetcher.swift | 1 + Tests/CodexBarTests/AmpUsageFetcherTests.swift | 11 +++++++++++ Tests/CodexBarTests/OllamaUsageFetcherTests.swift | 7 +++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift index dbe39d4c..e80e48cf 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift @@ -371,13 +371,18 @@ public struct AmpUsageFetcher: Sendable { } static func shouldAttachCookie(to url: URL?) -> Bool { + guard url?.scheme?.lowercased() == "https" else { return false } + return self.isAmpHost(url) + } + + private static func isAmpHost(_ url: URL?) -> Bool { guard let host = url?.host?.lowercased() else { return false } if host == "ampcode.com" || host == "www.ampcode.com" { return true } return host.hasSuffix(".ampcode.com") } static func isLoginRedirect(_ url: URL) -> Bool { - guard self.shouldAttachCookie(to: url) else { return false } + guard self.isAmpHost(url) else { return false } let path = url.path.lowercased() let components = path.split(separator: "/").map(String.init) diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 6b58dd80..d3aaea97 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -620,6 +620,7 @@ public struct OllamaUsageFetcher: Sendable { } static func shouldAttachCookie(to url: URL?) -> Bool { + guard url?.scheme?.lowercased() == "https" else { return false } guard let host = url?.host?.lowercased() else { return false } if host == "ollama.com" || host == "www.ollama.com" { return true } return host.hasSuffix(".ollama.com") diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index 135f58ee..25963d8d 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -17,11 +17,22 @@ struct AmpUsageFetcherTests { #expect(!AmpUsageFetcher.shouldAttachCookie(to: nil)) } + @Test + func `rejects non https amp urls`() { + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://ampcode.com/settings"))) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ampcode.com"))) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ampcode.com/path"))) + } + @Test func `detects login redirects`() throws { let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(signIn)) + let downgradedSignIn = try #require(URL(string: "http://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) + #expect(AmpUsageFetcher.isLoginRedirect(downgradedSignIn)) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: downgradedSignIn)) + let sso = try #require(URL(string: "https://ampcode.com/auth/sso?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(sso)) diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index e7dde89f..e25283f5 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -17,6 +17,13 @@ struct OllamaUsageFetcherTests { #expect(!OllamaUsageFetcher.shouldAttachCookie(to: nil)) } + @Test + func `rejects non https ollama urls`() { + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://ollama.com/settings"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ollama.com"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ollama.com/path"))) + } + @Test func `manual mode without valid header throws no session cookie`() { do { From 190d8837d34ddab2823a3c9fe306cb6929051613 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 20:23:52 +0100 Subject: [PATCH 15/79] docs: update changelog for redirect cookie fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e06e8b..29a880be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Augment: parse the updated `auggie account status` output format, fall back to browser cookies when CLI parsing fails, and restore session cookie detection (#1224). Thanks @bcharleson! +- Amp/Ollama: require HTTPS before reattaching imported browser cookies on provider redirects to avoid cleartext cookie exposure (#1226). Thanks @Hinotoi-agent! - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). - Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! - Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! From 1cc54a0fe1abc8351c57a7b4d4a30075916f7285 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Sun, 31 May 2026 03:53:39 +0800 Subject: [PATCH 16/79] fix: preserve Claude web usage on auth flakes Preserve the last good Claude Web usage snapshot across the first gated Unauthorized refresh failure, while still surfacing repeated Unauthorized errors and preserving first-run failure behavior. Maintainer proof: - swift test --filter ClaudeWebRefreshResilienceTests - make check - git diff --check origin/main...HEAD - autoreview --mode branch --base origin/main CI: all required checks green. Live Claude auth proof was intentionally not run: repo policy forbids routine validation that can touch real provider auth or trigger Keychain/browser-cookie prompts. Source-level stub coverage exercises the failure gate and prior/no-prior snapshot behavior. Co-authored-by: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> --- Sources/CodexBar/UsageStore+Refresh.swift | 17 +- .../ClaudeWebRefreshResilienceTests.swift | 175 ++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 273b11f1..22c5b19f 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -300,6 +300,16 @@ extension UsageStore { let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + let preservesClaudeWebSessionFailure = + provider == .claude && + hadPriorData && + Self.isClaudeWebSessionRefreshFailure(error) + if preservesClaudeWebSessionFailure, + !shouldSurface + { + self.errors[provider] = nil + return + } if provider == .claude, preservesPriorData, Self.isClaudeUsageProbeTimeout(error) @@ -313,7 +323,7 @@ extension UsageStore { } if shouldSurface { self.errors[provider] = error.localizedDescription - if !preservesPriorData { + if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider) } } else { @@ -399,6 +409,11 @@ extension UsageStore { return error.localizedDescription == ClaudeStatusProbeError.timedOut.localizedDescription } + private static func isClaudeWebSessionRefreshFailure(_ error: Error) -> Bool { + if case ClaudeWebAPIFetcher.FetchError.unauthorized = error { return true } + return error.localizedDescription == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription + } + nonisolated static func isPermissionPromptWaiting(_ error: Error) -> Bool { let message = error.localizedDescription.lowercased() return (message.contains("prompt") && message.contains("waiting")) || diff --git a/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift b/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift new file mode 100644 index 00000000..cc2cec5e --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift @@ -0,0 +1,175 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeWebRefreshResilienceTests { + @Test + func `web unauthorized respects failure gate while keeping prior Claude snapshot`() async throws { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let prior = Self.makePriorSnapshot() + let store = try await MainActor.run { + try Self.makeStore( + suite: "ClaudeWebRefreshResilienceTests-web-unauthorized", + prior: prior) + } + + await store.refreshProvider(.claude) + let firstResult = await MainActor.run { + ( + updatedAt: store.snapshot(for: .claude)?.updatedAt, + hasError: store.error(for: .claude) != nil) + } + + #expect(firstResult.updatedAt == prior.updatedAt) + #expect(!firstResult.hasError) + + await store.refreshProvider(.claude) + let secondResult = await MainActor.run { + ( + updatedAt: store.snapshot(for: .claude)?.updatedAt, + error: store.error(for: .claude)) + } + + #expect(secondResult.updatedAt == prior.updatedAt) + #expect(secondResult.error == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription) + } + } + } + + @Test + func `web unauthorized without prior Claude snapshot still surfaces failure`() async throws { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let store = try await MainActor.run { + try Self.makeStore( + suite: "ClaudeWebRefreshResilienceTests-web-unauthorized-no-prior", + prior: nil) + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + error: store.error(for: .claude)) + } + + #expect(!result.hasSnapshot) + #expect(result.error == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription) + } + } + } + + @MainActor + private static func makeStore(suite: String, prior: UsageSnapshot?) throws -> UsageStore { + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .web + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == .claude) + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + if let prior { + store._setSnapshotForTesting(prior, provider: .claude) + } + + let baseSpec = try #require(store.providerSpecs[.claude]) + let descriptor = ProviderDescriptor( + id: .claude, + metadata: baseSpec.descriptor.metadata, + branding: baseSpec.descriptor.branding, + tokenCost: baseSpec.descriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.web], + pipeline: ProviderFetchPipeline { _ in [ClaudeWebUnauthorizedFetchStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + private static func makePriorSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 34, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + } + + @MainActor + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings + } +} + +private struct ClaudeWebUnauthorizedFetchStrategy: ProviderFetchStrategy { + let id = "test.claude-web-unauthorized" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeWebAPIFetcher.FetchError.unauthorized + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} From 3631312d4b62ebe52e300787c1bdf4635ef59cbb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 20:53:56 +0100 Subject: [PATCH 17/79] docs: update changelog for Claude web resilience --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a880be..88c11b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Augment: parse the updated `auggie account status` output format, fall back to browser cookies when CLI parsing fails, and restore session cookie detection (#1224). Thanks @bcharleson! - Amp/Ollama: require HTTPS before reattaching imported browser cookies on provider redirects to avoid cleartext cookie exposure (#1226). Thanks @Hinotoi-agent! +- Claude: preserve the last good Claude Web usage snapshot across transient Unauthorized refresh failures while still surfacing repeated auth failures (#1220). Thanks @LeoLin990405! - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). - Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! - Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! From 5dec44e72c028862382d5489b29d71f33f791df1 Mon Sep 17 00:00:00 2001 From: Kwon GuHyeon <127847761+guhyun9454@users.noreply.github.com> Date: Sun, 31 May 2026 05:41:38 +0900 Subject: [PATCH 18/79] fix: filter noisy Antigravity model quotas Filter Antigravity remote OAuth per-model quota output to the intended visible projection while preserving complete local IDE quota lists. Remote quota rows now hide unconsumed lite/autocomplete/image/internal/unknown models, keep consumed noisy rows as detail-only extras, and order visible model families consistently. Summary bars use only selectable text-family models for remote snapshots so image/lite/autocomplete/internal rows cannot drive primary/secondary/tertiary summaries. Proof: - swift test --filter AntigravityStatusProbeTests - swift test --filter MenuCardAntigravityTests - make check - autoreview clean after maintainer fixups - CI 26693620400 green --- .../AntigravityRemoteUsageFetcher.swift | 3 +- .../Antigravity/AntigravityStatusProbe.swift | 160 +++++- .../AntigravityStatusProbeTests.swift | 484 +++++++++++++++++- .../MenuCardAntigravityTests.swift | 3 +- 4 files changed, 623 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift index cb1dadad..84524b18 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -146,7 +146,8 @@ public struct AntigravityRemoteUsageFetcher: Sendable { return AntigravityStatusSnapshot( modelQuotas: models, accountEmail: claims.email, - accountPlan: Self.resolvePlan(response: codeAssist, claims: claims)) + accountPlan: Self.resolvePlan(response: codeAssist, claims: claims), + source: .remote) } private static func shouldRefresh(expiryDate: Date?, now: Date) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index cd84afef..e1d3bbb7 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -37,25 +37,48 @@ private enum AntigravityModelFamily { case unknown } +private struct AntigravityModelVersion: Comparable { + let major: Int + let minor: Int + + static func < (lhs: AntigravityModelVersion, rhs: AntigravityModelVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + return lhs.minor < rhs.minor + } +} + private struct AntigravityNormalizedModel { let quota: AntigravityModelQuota let family: AntigravityModelFamily let selectionPriority: Int? + let isImage: Bool + let isLite: Bool + let isAutocomplete: Bool + let version: AntigravityModelVersion? + let tier: Int +} + +public enum AntigravityModelQuotaSource: Sendable { + case local + case remote } public struct AntigravityStatusSnapshot: Sendable { public let modelQuotas: [AntigravityModelQuota] public let accountEmail: String? public let accountPlan: String? + public let source: AntigravityModelQuotaSource public init( modelQuotas: [AntigravityModelQuota], accountEmail: String?, - accountPlan: String?) + accountPlan: String?, + source: AntigravityModelQuotaSource = .remote) { self.modelQuotas = modelQuotas self.accountEmail = accountEmail self.accountPlan = accountPlan + self.source = source } public func toUsageSnapshot() throws -> UsageSnapshot { @@ -64,13 +87,19 @@ public struct AntigravityStatusSnapshot: Sendable { } let normalized = Self.normalizedModels(self.modelQuotas) - let primaryQuota = Self.representative(for: .claude, in: normalized) - let secondaryQuota = Self.representative(for: .geminiPro, in: normalized) - let tertiaryQuota = Self.representative(for: .geminiFlash, in: normalized) + let summaryModels: [AntigravityNormalizedModel] = switch self.source { + case .local: + normalized + case .remote: + normalized.filter(Self.isRemoteSummaryCandidate) + } + let primaryQuota = Self.representative(for: .claude, in: summaryModels) + let secondaryQuota = Self.representative(for: .geminiPro, in: summaryModels) + let tertiaryQuota = Self.representative(for: .geminiFlash, in: summaryModels) let fallbackQuota: AntigravityModelQuota? = if primaryQuota == nil, secondaryQuota == nil, tertiaryQuota == nil { - Self.fallbackRepresentative(in: normalized) + Self.fallbackRepresentative(in: summaryModels) } else { nil } @@ -80,12 +109,21 @@ public struct AntigravityStatusSnapshot: Sendable { let tertiary = tertiaryQuota.map(Self.rateWindow(for:)) // primary/secondary/tertiary keep the 3-family summary for back-compat. - // extraRateWindows carries every model quota loss-free, including families - // with no dedicated slot and variants the representative selection collapses. - let extraWindows = self.modelQuotas - .sorted(by: Self.modelQuotaSortPrecedes) - .map { quota in - NamedRateWindow(id: quota.modelId, title: quota.label, window: Self.rateWindow(for: quota)) + // extraRateWindows carries a source-aware set: the full curated list for + // .local (verified junk-free), and a filtered list for .remote (catalog noise + // hidden, consumed quota always kept). Sorted by family→version→tier. + let shownModels: [AntigravityNormalizedModel] = switch self.source { + case .local: + normalized + case .remote: + normalized.filter { m in + Self.isRemoteSummaryCandidate(m) || (m.quota.remainingFraction ?? 1.0) < 0.999 + } + } + let extraWindows = shownModels + .sorted(by: Self.modelOrderPrecedes) + .map { m in + NamedRateWindow(id: m.quota.modelId, title: m.quota.label, window: Self.rateWindow(for: m.quota)) } let identity = ProviderIdentitySnapshot( @@ -110,12 +148,51 @@ public struct AntigravityStatusSnapshot: Sendable { resetDescription: quota.resetDescription) } - private static func modelQuotaSortPrecedes(_ lhs: AntigravityModelQuota, _ rhs: AntigravityModelQuota) -> Bool { - let labelOrder = lhs.label.caseInsensitiveCompare(rhs.label) - if labelOrder != .orderedSame { - return labelOrder == .orderedAscending + private static func modelOrderPrecedes( + _ lhs: AntigravityNormalizedModel, + _ rhs: AntigravityNormalizedModel) -> Bool + { + // 1. Family rank: claude=0, geminiPro=1, geminiFlash=2, unknown=3 + let lhsFamilyRank = Self.familyRank(lhs.family) + let rhsFamilyRank = Self.familyRank(rhs.family) + if lhsFamilyRank != rhsFamilyRank { + return lhsFamilyRank < rhsFamilyRank + } + + // 2. Version descending (newer first); nil version sorts after non-nil + switch (lhs.version, rhs.version) { + case let (.some(lv), .some(rv)): + if lv != rv { + return lv > rv + } + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + break + } + + // 3. Tier ascending: High(0) < Medium(1) < Low(2) + if lhs.tier != rhs.tier { + return lhs.tier < rhs.tier + } + + // 4. Label tiebreaker + return lhs.quota.label.localizedCaseInsensitiveCompare(rhs.quota.label) == .orderedAscending + } + + private static func familyRank(_ family: AntigravityModelFamily) -> Int { + switch family { + case .claude: 0 + case .geminiPro: 1 + case .geminiFlash: 2 + case .unknown: 3 } - return lhs.modelId.caseInsensitiveCompare(rhs.modelId) == .orderedAscending + } + + private static func isRemoteSummaryCandidate(_ model: AntigravityNormalizedModel) -> Bool { + model.family != .unknown && !model.isLite && !model.isAutocomplete && !model.isImage } private static func normalizedModels(_ models: [AntigravityModelQuota]) -> [AntigravityNormalizedModel] { @@ -130,6 +207,8 @@ public struct AntigravityStatusSnapshot: Sendable { let isLite = modelId.contains("lite") || label.contains("lite") let isAutocomplete = modelId.contains("autocomplete") || label.contains("autocomplete") || modelId .hasPrefix("tab_") + let isImage = modelId.contains("image") || label.contains("image") + let isSelectableTextModel = !isLite && !isAutocomplete && !isImage let isLowPriorityGeminiPro = modelId.contains("pro-low") || (label.contains("pro") && label.contains("low")) @@ -137,23 +216,59 @@ public struct AntigravityStatusSnapshot: Sendable { case .claude: 0 case .geminiPro: - if isLowPriorityGeminiPro { + if isLowPriorityGeminiPro, isSelectableTextModel { 0 - } else if !isLite, !isAutocomplete { + } else if isSelectableTextModel { 1 } else { nil } case .geminiFlash: - (!isLite && !isAutocomplete) ? 0 : nil + isSelectableTextModel ? 0 : nil case .unknown: nil } + let version = Self.parseVersion(from: label) + let tier = Self.parseTier(from: label, modelId: modelId) + return AntigravityNormalizedModel( quota: quota, family: family, - selectionPriority: selectionPriority) + selectionPriority: selectionPriority, + isImage: isImage, + isLite: isLite, + isAutocomplete: isAutocomplete, + version: version, + tier: tier) + } + + private static func parseVersion(from label: String) -> AntigravityModelVersion? { + // Accept either "." or "-" between major and minor so a raw model id used as the + // label when displayName is missing (e.g. "gemini-3-1-pro-low") still parses 3.1. + guard let regex = try? NSRegularExpression(pattern: #"(\d+)(?:[.\-](\d+))?"#) else { return nil } + let nsLabel = label as NSString + let range = NSRange(location: 0, length: nsLabel.length) + guard let match = regex.firstMatch(in: label, options: [], range: range) else { return nil } + let majorRange = Range(match.range(at: 1), in: label) + guard let majorRange, let major = Int(label[majorRange]) else { return nil } + let minor: Int = if match.range(at: 2).location != NSNotFound, + let minorRange = Range(match.range(at: 2), in: label), + let parsed = Int(label[minorRange]) + { + parsed + } else { + 0 + } + return AntigravityModelVersion(major: major, minor: minor) + } + + private static func parseTier(from label: String, modelId: String) -> Int { + let combined = label + " " + modelId + if combined.contains("high") { return 0 } + if combined.contains("medium") { return 1 } + if combined.contains("low") { return 2 } + return 1 } private static func representative( @@ -382,7 +497,8 @@ public struct AntigravityStatusProbe: Sendable { return AntigravityStatusSnapshot( modelQuotas: models, accountEmail: email, - accountPlan: planName) + accountPlan: planName, + source: .local) } static func parsePlanInfoSummary(_ data: Data) throws -> AntigravityPlanInfoSummary? { @@ -411,7 +527,7 @@ public struct AntigravityStatusProbe: Sendable { } let modelConfigs = response.clientModelConfigs ?? [] let models = modelConfigs.compactMap(Self.quotaFromConfig(_:)) - return AntigravityStatusSnapshot(modelQuotas: models, accountEmail: nil, accountPlan: nil) + return AntigravityStatusSnapshot(modelQuotas: models, accountEmail: nil, accountPlan: nil, source: .local) } private static func quotaFromConfig(_ config: ModelConfig) -> AntigravityModelQuota? { diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index f9f0a1cf..fe35ba60 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -805,7 +805,7 @@ struct AntigravityStatusProbeTests { extension AntigravityStatusProbeTests { @Test - func `extra rate windows preserve all model quotas in stable label order`() throws { + func `extra rate windows preserve all model quotas in stable family order`() throws { let resetTime = Date(timeIntervalSince1970: 1_775_000_000) let snapshot = AntigravityStatusSnapshot( modelQuotas: [ @@ -835,11 +835,13 @@ extension AntigravityStatusProbeTests { resetDescription: nil), ], accountEmail: nil, - accountPlan: nil) + accountPlan: nil, + source: .local) let usage = try snapshot.toUsageSnapshot() let extraWindows = try #require(usage.extraRateWindows) + // Local source shows all models. Order: Claude → Gemini Pro (High before Low) → GPT-OSS (unknown, last) #expect(extraWindows.map(\.id) == [ "MODEL_PLACEHOLDER_M50", "MODEL_PLACEHOLDER_M52", @@ -907,7 +909,8 @@ extension AntigravityStatusProbeTests { resetDescription: nil), ], accountEmail: "test@example.com", - accountPlan: "Pro") + accountPlan: "Pro", + source: .local) let usage = try snapshot.toUsageSnapshot() #expect(usage.primary?.remainingPercent.rounded() == 20) @@ -917,6 +920,481 @@ extension AntigravityStatusProbeTests { #expect(usage.loginMethod(for: .antigravity) == "Pro") } + // MARK: - Source-aware filter + sort tests + + @Test + func `local source shows all models including gpt oss at full remaining fraction`() throws { + // Fixture A: 8 opaque-ID models, source .local → all shown (show-all path) + let resetTime = Date(timeIntervalSince1970: 1_775_000_000) + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M60", + remainingFraction: 0.8, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M61", + remainingFraction: 0.7, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M62", + remainingFraction: 0.9, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M63", + remainingFraction: 0.4, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (High)", + modelId: "MODEL_PLACEHOLDER_M64", + remainingFraction: 0.6, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (Low)", + modelId: "MODEL_PLACEHOLDER_M65", + remainingFraction: 0.3, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (Medium)", + modelId: "MODEL_PLACEHOLDER_M66", + remainingFraction: 0.5, + resetTime: resetTime, + resetDescription: nil), + // GPT-OSS pinned at remainingFraction == 1.0 — shown by local show-all + AntigravityModelQuota( + label: "GPT-OSS 120B (Medium)", + modelId: "MODEL_PLACEHOLDER_M55", + remainingFraction: 1.0, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // All 8 models present + #expect(ids.count == 8) + // GPT-OSS shown despite remainingFraction == 1.0 (local show-all regression guard) + #expect(ids.contains("MODEL_PLACEHOLDER_M55")) + + // Order: Claude (version 4.6 → both at same version, Opus vs Sonnet by label) + // → Gemini Pro 3.1 (High before Low) + // → Gemini Flash 3.5 (High, Medium, Low by tier) + // → GPT-OSS (unknown bucket, last) + let titles = extraWindows.map(\.title) + // Claude family first + let claudeRange = titles.prefix(2) + #expect(claudeRange.allSatisfy { $0.lowercased().contains("claude") }) + // Gemini Pro next + let geminiProRange = titles.dropFirst(2).prefix(2) + #expect(geminiProRange.allSatisfy { $0.lowercased().contains("gemini") && $0.lowercased().contains("pro") }) + // Gemini Flash next + let geminiFlashRange = titles.dropFirst(4).prefix(3) + #expect(geminiFlashRange.allSatisfy { $0.lowercased().contains("gemini") && $0.lowercased().contains("flash") }) + // GPT-OSS last + #expect(titles.last == "GPT-OSS 120B (Medium)") + + // Within Gemini Pro 3.1: High before Low + let proTitles = Array(geminiProRange) + #expect(proTitles[0].contains("High")) + #expect(proTitles[1].contains("Low")) + + // Within Gemini Flash 3.5: High(0) → Medium(1) → Low(2) + let flashTitles = Array(geminiFlashRange) + #expect(flashTitles[0].contains("High")) + #expect(flashTitles[1].contains("Medium")) + #expect(flashTitles[2].contains("Low")) + } + + @Test + func `remote source filters junk models and keeps family recognized ones`() throws { + // Fixture B: verified 13 remote models; 6 junk hidden, 7 survivors present + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + // junk: image + AntigravityModelQuota( + label: "Gemini 2.5 Flash Image", + modelId: "gemini-2-5-flash-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: tab autocomplete + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 2.5 Pro", + modelId: "gemini-2-5-pro", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "gemini-3-pro-high", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: lite + AntigravityModelQuota( + label: "Gemini 2.5 Flash Lite", + modelId: "gemini-2-5-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: image + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: lite + AntigravityModelQuota( + label: "Gemini 3.1 Flash Lite", + modelId: "gemini-3-1-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "gemini-3-1-pro-low", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3.1 Pro (High)", + modelId: "gemini-3-1-pro-high", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: tab autocomplete + AntigravityModelQuota( + label: "Tab Jump Flash Lite Vertex", + modelId: "tab_jump_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "gemini-3-pro-low", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 2.5 Flash", + modelId: "gemini-2-5-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // 6 junk IDs must be absent + #expect(!ids.contains("gemini-2-5-flash-image")) + #expect(!ids.contains("tab_flash_lite_vertex")) + #expect(!ids.contains("gemini-2-5-flash-lite")) + #expect(!ids.contains("gemini-3-pro-image")) + #expect(!ids.contains("gemini-3-1-flash-lite")) + #expect(!ids.contains("tab_jump_flash_lite_vertex")) + + // 7 survivors must be present by ID + #expect(ids.contains("gemini-2-5-pro")) + #expect(ids.contains("gemini-3-pro-high")) + #expect(ids.contains("gemini-3-flash")) + #expect(ids.contains("gemini-3-1-pro-low")) + #expect(ids.contains("gemini-3-1-pro-high")) + #expect(ids.contains("gemini-3-pro-low")) + #expect(ids.contains("gemini-2-5-flash")) + + // Version-descending within Gemini Pro: 3.1 before 3 before 2.5 + let proIds = ids.filter { $0.contains("pro") && !$0.contains("image") } + let proIndexOf: (String) -> Int = { id in proIds.firstIndex(of: id) ?? Int.max } + #expect(proIndexOf("gemini-3-1-pro-high") < proIndexOf("gemini-3-pro-high")) + #expect(proIndexOf("gemini-3-pro-high") < proIndexOf("gemini-2-5-pro")) + + // Within same version, High before Low + #expect(proIndexOf("gemini-3-1-pro-high") < proIndexOf("gemini-3-1-pro-low")) + #expect(proIndexOf("gemini-3-pro-high") < proIndexOf("gemini-3-pro-low")) + } + + @Test + func `remote source shows consumed junk models despite filter`() throws { + // Fixture C: junk models with remainingFraction < 0.999 must be shown + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + // consumed tab — should be shown + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + // consumed image — should be shown + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + // unconsumed sibling tab (0.9995 >= 0.999) — should be hidden + AntigravityModelQuota( + label: "Tab Jump Flash Lite Vertex", + modelId: "tab_jump_flash_lite_vertex", + remainingFraction: 0.9995, + resetTime: nil, + resetDescription: nil), + // a clean survivor for non-empty guard + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // Consumed junk models shown despite being junk type + #expect(ids.contains("tab_flash_lite_vertex")) + #expect(ids.contains("gemini-3-pro-image")) + + // Unconsumed sibling stays hidden + #expect(!ids.contains("tab_jump_flash_lite_vertex")) + } + + @Test + func `remote source image models do not drive family summary bars`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 0.2, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "gemini-3-pro-high", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash Image", + modelId: "gemini-3-flash-image", + remainingFraction: 0.1, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 0.8, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + + #expect(usage.secondary?.usedPercent == 10) + #expect(usage.tertiary?.usedPercent == 20) + #expect(usage.extraRateWindows?.map(\.id).contains("gemini-3-pro-image") == true) + #expect(usage.extraRateWindows?.map(\.id).contains("gemini-3-flash-image") == true) + } + + @Test + func `remote source yields nil extra windows when all models are unconsumed junk`() throws { + // Fixture D: all-junk-unconsumed → extraRateWindows nil + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 2.5 Flash Lite", + modelId: "gemini-2-5-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Unknown Model X", + modelId: "unknown-model-x", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows == nil) + } + + @Test + func `ordering edge cases with unparseable version and equal version differing tier`() throws { + // Fixture F: local source; unparseable-version Gemini Pro lands last in pro group; + // same-version High precedes Low + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M70", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M71", + remainingFraction: 0.8, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini Pro Experimental", + modelId: "MODEL_PLACEHOLDER_M72", + remainingFraction: 0.3, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet 4", + modelId: "MODEL_PLACEHOLDER_M73", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let titles = extraWindows.map(\.title) + + // Claude first + #expect(titles[0] == "Claude Sonnet 4") + // Within Gemini Pro: version-3 models before unparseable-version model + // High before Low at same version + let proIndex: (String) -> Int = { t in titles.firstIndex(of: t) ?? Int.max } + #expect(proIndex("Gemini 3 Pro (High)") < proIndex("Gemini 3 Pro (Low)")) + #expect(proIndex("Gemini 3 Pro (High)") < proIndex("Gemini Pro Experimental")) + #expect(proIndex("Gemini 3 Pro (Low)") < proIndex("Gemini Pro Experimental")) + } + + @Test + func `nil version unknown family models sort deterministically by label`() throws { + // Strict-weak-ordering guard: two .unknown models with unparseable versions + // should sort by label without trapping + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Zebra Unknown Model", + modelId: "MODEL_PLACEHOLDER_MA", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Alpha Unknown Model", + modelId: "MODEL_PLACEHOLDER_MB", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let titles = extraWindows.map(\.title) + + // Deterministic: label tiebreaker → Alpha before Zebra + #expect(titles == ["Alpha Unknown Model", "Zebra Unknown Model"]) + } + + @Test + func `hyphenated raw model ids without display name parse minor version`() throws { + // When the remote catalog omits displayName/label, the raw hyphenated model id + // becomes the label. The newer 3.1 entry must still sort before the 3.0 entry. + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "gemini-3-pro-high", + modelId: "gemini-3-pro-high", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "gemini-3-1-pro-low", + modelId: "gemini-3-1-pro-low", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let titles = try #require(usage.extraRateWindows).map(\.title) + + // 3.1 parses from the hyphenated id and sorts newest-first, ahead of 3.0. + #expect(titles == ["gemini-3-1-pro-low", "gemini-3-pro-high"]) + } + @Test func `http probe errors still count as reachable`() { #expect( diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift index 3f7037c0..f2947a61 100644 --- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -143,7 +143,8 @@ struct MenuCardAntigravityTests { resetDescription: nil), ], accountEmail: nil, - accountPlan: "Pro") + accountPlan: "Pro", + source: .local) let snapshot = try antigravitySnapshot.toUsageSnapshot() let metadata = try #require(ProviderDefaults.metadata[.antigravity]) From d2d1fc32011a22a12e70c1e259bbace9cff84dcd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 21:41:57 +0100 Subject: [PATCH 19/79] docs: update changelog for Antigravity quota filtering --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c11b25..84ac184f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Augment: parse the updated `auggie account status` output format, fall back to browser cookies when CLI parsing fails, and restore session cookie detection (#1224). Thanks @bcharleson! - Amp/Ollama: require HTTPS before reattaching imported browser cookies on provider redirects to avoid cleartext cookie exposure (#1226). Thanks @Hinotoi-agent! +- Antigravity: filter noisy remote OAuth per-model quota rows, keep consumed noisy rows detail-only, and prevent image/lite/autocomplete/internal rows from driving summary bars (#1209). Thanks @guhyun9454! - Claude: preserve the last good Claude Web usage snapshot across transient Unauthorized refresh failures while still surfacing repeated auth failures (#1220). Thanks @LeoLin990405! - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). - Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! From dbc944d46cd4cf7877d1ca47c44556fe573b46e8 Mon Sep 17 00:00:00 2001 From: Hinotobi Date: Sun, 31 May 2026 05:19:54 +0800 Subject: [PATCH 20/79] fix: harden CLI installer privilege boundary Remove the same-user mutable temporary script from the macOS CLI installer privilege boundary. The installer now passes the bundled helper path to AppleScript as an argument, quotes it inside AppleScript, and builds the privileged symlink command in memory before invoking `bash -c` with administrator privileges. Existing symlink targets are preserved: `/usr/local/bin/codexbar` and `/opt/homebrew/bin/codexbar`. Proof: - bash -n bin/install-codexbar-cli.sh - extracted AppleScript heredoc compiles with osacompile - static checks confirmed mktemp/install_script/bash temp-file handoff removed - git diff --check - make check - autoreview clean - CI green on PR head 6b329567 --- bin/install-codexbar-cli.sh | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/bin/install-codexbar-cli.sh b/bin/install-codexbar-cli.sh index 50fb9533..742a974e 100755 --- a/bin/install-codexbar-cli.sh +++ b/bin/install-codexbar-cli.sh @@ -10,23 +10,20 @@ if [[ ! -x "$HELPER" ]]; then exit 1 fi -install_script=$(mktemp) -cat > "$install_script" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -HELPER="__HELPER__" -TARGETS=("/usr/local/bin/codexbar" "/opt/homebrew/bin/codexbar") - -for t in "${TARGETS[@]}"; do - mkdir -p "$(dirname "$t")" - ln -sf "$HELPER" "$t" - echo "Linked $t -> $HELPER" -done -EOF - -perl -pi -e "s#__HELPER__#$HELPER#g" "$install_script" +osascript - "$HELPER" <<'APPLESCRIPT' +on run argv + set helperPath to item 1 of argv + set installCommand to "set -euo pipefail" & linefeed & ¬ + "HELPER=" & quoted form of helperPath & linefeed & ¬ + "TARGETS=(\"/usr/local/bin/codexbar\" \"/opt/homebrew/bin/codexbar\")" & linefeed & ¬ + "for t in \"${TARGETS[@]}\"; do" & linefeed & ¬ + " mkdir -p \"$(dirname \"$t\")\"" & linefeed & ¬ + " ln -sf \"$HELPER\" \"$t\"" & linefeed & ¬ + " echo \"Linked $t -> $HELPER\"" & linefeed & ¬ + "done" -osascript -e "do shell script \"bash '$install_script'\" with administrator privileges" -rm -f "$install_script" + do shell script "bash -c " & quoted form of installCommand with administrator privileges +end run +APPLESCRIPT echo "CodexBar CLI installed. Try: codexbar usage" From c56619743ab3f2953f23792cf40cf2126185db4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 22:20:05 +0100 Subject: [PATCH 21/79] docs: update changelog for CLI installer hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ac184f..e3f802bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Amp/Ollama: require HTTPS before reattaching imported browser cookies on provider redirects to avoid cleartext cookie exposure (#1226). Thanks @Hinotoi-agent! - Antigravity: filter noisy remote OAuth per-model quota rows, keep consumed noisy rows detail-only, and prevent image/lite/autocomplete/internal rows from driving summary bars (#1209). Thanks @guhyun9454! - Claude: preserve the last good Claude Web usage snapshot across transient Unauthorized refresh failures while still surfacing repeated auth failures (#1220). Thanks @LeoLin990405! +- CLI: avoid executing a same-user mutable temporary installer script across the macOS administrator privilege boundary (#1222). Thanks @Hinotoi-agent! - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). - Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! - Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! From e7d932616508cee43ea9bcc63c269b14698de655 Mon Sep 17 00:00:00 2001 From: Hinotobi Date: Sun, 31 May 2026 06:09:32 +0800 Subject: [PATCH 22/79] fix: isolate notarization temp files Security hardening for the release notarization path. Keeps notarization API key material and upload ZIPs inside a per-run private temporary directory, sets restrictive permissions, and cleans it up on exit. Final release artifact paths remain unchanged. Proof: - bash -n Scripts/sign-and-notarize.sh - static grep for removed predictable /tmp paths - stubbed release harness covering 0700 temp dir, 0600 API key, private notarization ZIP, cleanup, unchanged final artifacts - make check - autoreview clean - CI green --- Scripts/sign-and-notarize.sh | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Scripts/sign-and-notarize.sh b/Scripts/sign-and-notarize.sh index df9c97f9..5c0816ed 100755 --- a/Scripts/sign-and-notarize.sh +++ b/Scripts/sign-and-notarize.sh @@ -17,8 +17,18 @@ if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:- echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2 exit 1 fi -echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/codexbar-api-key.p8 -trap 'rm -f /tmp/codexbar-api-key.p8 /tmp/${APP_NAME}Notarize.zip' EXIT + +NOTARIZATION_TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-notarize.XXXXXX") +chmod 700 "$NOTARIZATION_TEMP_DIR" +API_KEY_PATH="$NOTARIZATION_TEMP_DIR/codexbar-api-key.p8" +NOTARIZATION_ZIP="$NOTARIZATION_TEMP_DIR/${APP_NAME}Notarize.zip" +trap 'rm -rf "$NOTARIZATION_TEMP_DIR"' EXIT + +( + umask 077 + printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_PATH" +) +chmod 600 "$API_KEY_PATH" ARCH_LIST=( ${ARCHES_VALUE} ) for ARCH in "${ARCH_LIST[@]}"; do @@ -52,11 +62,11 @@ codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ "$APP_BUNDLE" DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto} -"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "/tmp/${APP_NAME}Notarize.zip" +"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$NOTARIZATION_ZIP" echo "Submitting for notarization" -xcrun notarytool submit "/tmp/${APP_NAME}Notarize.zip" \ - --key /tmp/codexbar-api-key.p8 \ +xcrun notarytool submit "$NOTARIZATION_ZIP" \ + --key "$API_KEY_PATH" \ --key-id "$APP_STORE_CONNECT_KEY_ID" \ --issuer "$APP_STORE_CONNECT_ISSUER_ID" \ --wait From c28e3bb01536b1f1151f04d4a54a955b4f291585 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 23:09:51 +0100 Subject: [PATCH 23/79] docs: update changelog for notarization temp hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f802bb..24edc490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). - Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! - Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! +- Release: isolate notarization API keys and upload ZIPs in a private per-run temporary directory instead of predictable shared /tmp paths (#1228). Thanks @Hinotoi-agent! - Status: retry startup refreshes a few times after transient offline/network failures so provider status can recover after macOS brings the network online (#1211). ## 0.31.0 — 2026-05-28 From 482f1da5adbb6d93c59e86984d61490cc19c6749 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 00:21:03 +0100 Subject: [PATCH 24/79] Fix menu tracking background rebuild stalls (#1233) * fix: defer tracked menu background rebuilds * fix: preserve explicit hosted menu refreshes --- CHANGELOG.md | 1 + .../CodexBar/StatusItemController+Menu.swift | 34 +---- .../StatusItemController+MenuTracking.swift | 105 ++++++++++++++- .../StatusItemController+Shutdown.swift | 1 + Sources/CodexBar/StatusItemController.swift | 20 +-- .../StatusMenuHostedSubmenuRefreshTests.swift | 25 +++- .../StatusMenuOpenRefreshTests.swift | 125 +++++++++++++++--- 7 files changed, 234 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24edc490..faa1887d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - CLI: avoid executing a same-user mutable temporary installer script across the macOS administrator privilege boundary (#1222). Thanks @Hinotoi-agent! - Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). - Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! +- Menu bar: defer background parent-menu rebuilds until AppKit menu tracking ends so late-arriving usage data cannot stall dropdown hover on macOS 26.5 (#1227). - Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! - Release: isolate notarization API keys and upload ZIPs in a private per-run temporary directory instead of predictable shared /tmp paths (#1228). Thanks @Hinotoi-agent! - Status: retry startup refreshes a few times after transient offline/network failures so provider status can recover after macOS brings the network online (#1211). diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 7f2cf08a..204f5065 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -130,7 +130,7 @@ extension StatusItemController { let wasHostedSubviewMenu = self.isHostedSubviewMenu(menu) self.forgetClosedMenu(menu) if wasHostedSubviewMenu { - self.refreshOpenMenusAllowingParentRebuild() + self.refreshOpenMenusAfterHostedSubviewClose() } } @@ -156,7 +156,10 @@ extension StatusItemController { if !isPersistentMenu { self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) + } else if self.menuNeedsRefresh(menu) { + self.rebuildClosedMenuIfNeeded(menu) } + self.parentMenuRebuildsDeferredDuringTracking.remove(key) } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { @@ -1073,16 +1076,6 @@ extension StatusItemController { return .provider(self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex) } - func menuNeedsRefresh(_ menu: NSMenu) -> Bool { - let key = ObjectIdentifier(menu) - return self.menuVersions[key] != self.menuContentVersion - } - - func markMenuFresh(_ menu: NSMenu) { - let key = ObjectIdentifier(menu) - self.menuVersions[key] = self.menuContentVersion - } - func menuProvider(for menu: NSMenu) -> UsageProvider? { if self.shouldMergeIcons { return self.resolvedMenuProvider() @@ -1096,25 +1089,6 @@ extension StatusItemController { return self.store.enabledProvidersForDisplay().first ?? .codex } - func hasOpenHostedSubviewMenu() -> Bool { - self.openMenus.values.contains { self.isHostedSubviewMenu($0) } - } - - func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) - } - - func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) - #if DEBUG - self._test_openMenuRebuildObserver?(menu) - #endif - } - private func scheduleOpenMenuRefresh(for menu: NSMenu) { // Kick off a refresh on open (non-forced) and re-check after a delay. // NEVER block menu opening with network requests. diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 59b53fa1..625dbdf4 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -1,11 +1,74 @@ import AppKit +import CodexBarCore extension StatusItemController { + func invalidateMenus( + refreshOpenMenus: Bool = false, + deferOpenParentMenuRebuild: Bool = false) + { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + self.menuContentVersion &+= 1 + guard self.isMenuRefreshEnabled else { return } + if !self.openMenus.isEmpty { + guard refreshOpenMenus else { return } + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) + self.scheduleOpenMenuInvalidationRetry( + deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) + return + } + } + func renderedMenuWidth(for menu: NSMenu) -> CGFloat { let measuredWidth = ceil(menu.size.width) return max(measuredWidth, Self.menuCardBaseWidth) } + func rebuildClosedMenuIfNeeded(_ menu: NSMenu) { + guard !self.hasPreparedForAppShutdown else { return } + let provider = self.menuProvider(for: menu) + Task { @MainActor [weak self, weak menu] in + await Task.yield() + guard let self, let menu else { return } + guard !self.hasPreparedForAppShutdown else { return } + guard self.openMenus[ObjectIdentifier(menu)] == nil else { return } + guard self.menuNeedsRefresh(menu) else { return } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } + } + + func menuNeedsRefresh(_ menu: NSMenu) -> Bool { + let key = ObjectIdentifier(menu) + return self.menuVersions[key] != self.menuContentVersion + } + + func markMenuFresh(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + self.menuVersions[key] = self.menuContentVersion + } + + func hasOpenHostedSubviewMenu() -> Bool { + self.openMenus.values.contains { self.isHostedSubviewMenu($0) } + } + + func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + } + + func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.applyIcon(phase: nil) + #if DEBUG + self._test_openMenuRebuildObserver?(menu) + #endif + } + func refreshOpenMenusIfNeeded() { guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } @@ -16,13 +79,23 @@ extension StatusItemController { self.refreshOpenMenusAllowingParentRebuild() } - func refreshOpenMenusAllowingParentRebuild() { + func refreshOpenMenusAfterHostedSubviewClose() { guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } - self.refreshOpenMenusIfNeeded(allowsParentRebuild: true) + self.refreshOpenMenusIfNeeded( + allowsParentRebuild: true, + respectsParentRebuildDeferral: true) } - func scheduleOpenMenuInvalidationRetry() { + func refreshOpenMenusAllowingParentRebuild(deferParentRebuildDuringTracking: Bool = false) { + guard self.isMenuRefreshEnabled else { return } + guard !self.openMenus.isEmpty else { return } + self.refreshOpenMenusIfNeeded( + allowsParentRebuild: true, + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking) + } + + func scheduleOpenMenuInvalidationRetry(deferParentRebuildDuringTracking: Bool = false) { self.openMenuInvalidationRetryTask?.cancel() self.openMenuInvalidationRetryTask = Task { @MainActor [weak self] in guard let self else { return } @@ -31,12 +104,17 @@ extension StatusItemController { #if DEBUG self.onOpenMenuInvalidationRetryForTesting?() #endif - self.refreshOpenMenusAllowingParentRebuild() + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking) self.openMenuInvalidationRetryTask = nil } } - private func refreshOpenMenusIfNeeded(allowsParentRebuild: Bool) { + private func refreshOpenMenusIfNeeded( + allowsParentRebuild: Bool, + deferParentRebuildDuringTracking: Bool = false, + respectsParentRebuildDeferral: Bool = false) + { var orphanedKeys: [ObjectIdentifier] = [] let hasOpenHostedSubviewMenu = self.hasOpenHostedSubviewMenu() for (key, menu) in self.openMenus { @@ -47,6 +125,8 @@ extension StatusItemController { self.refreshOpenMenuIfNeeded( menu, allowsParentRebuild: allowsParentRebuild, + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking, + respectsParentRebuildDeferral: respectsParentRebuildDeferral, hasOpenHostedSubviewMenu: hasOpenHostedSubviewMenu) } self.removeOrphanedOpenMenuEntries(orphanedKeys) @@ -55,6 +135,8 @@ extension StatusItemController { private func refreshOpenMenuIfNeeded( _ menu: NSMenu, allowsParentRebuild: Bool, + deferParentRebuildDuringTracking: Bool, + respectsParentRebuildDeferral: Bool, hasOpenHostedSubviewMenu: Bool) { if self.isHostedSubviewMenu(menu) { @@ -62,8 +144,18 @@ extension StatusItemController { return } guard allowsParentRebuild else { return } - guard !hasOpenHostedSubviewMenu else { return } guard self.menuNeedsRefresh(menu) else { return } + let key = ObjectIdentifier(menu) + + if deferParentRebuildDuringTracking { + self.parentMenuRebuildsDeferredDuringTracking.insert(key) + return + } + if respectsParentRebuildDeferral, self.parentMenuRebuildsDeferredDuringTracking.contains(key) { + return + } + self.parentMenuRebuildsDeferredDuringTracking.remove(key) + guard !hasOpenHostedSubviewMenu else { return } let provider = self.menuProvider(for: menu) self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) @@ -75,6 +167,7 @@ extension StatusItemController { self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) + self.parentMenuRebuildsDeferredDuringTracking.remove(key) } } } diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 0ff330d7..4d50a9a0 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -59,6 +59,7 @@ extension StatusItemController { self.openMenuRebuildTasks.removeAll(keepingCapacity: false) self.openMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) + self.parentMenuRebuildsDeferredDuringTracking.removeAll(keepingCapacity: false) self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) self.menuProviders.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 7a166bf9..62cb58c7 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -136,6 +136,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 var openMenuRebuildsClosingHostedSubviewMenus: Set = [] + var parentMenuRebuildsDeferredDuringTracking: Set = [] var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? @@ -441,7 +442,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreChanges() - self.invalidateMenus(refreshOpenMenus: self.didMenuAdjunctReadinessChange()) + self.invalidateMenus( + refreshOpenMenus: self.didMenuAdjunctReadinessChange(), + deferOpenParentMenuRebuild: true) } } } @@ -606,20 +609,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - func invalidateMenus(refreshOpenMenus: Bool = false) { - #if DEBUG - guard !self.isReleasedForTesting else { return } - #endif - self.menuContentVersion &+= 1 - guard self.isMenuRefreshEnabled else { return } - if !self.openMenus.isEmpty { - guard refreshOpenMenus else { return } - self.refreshOpenMenusAllowingParentRebuild() - self.scheduleOpenMenuInvalidationRetry() - return - } - } - private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool { var shouldRefresh = false let revision = self.settings.configRevision @@ -864,6 +853,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.openMenuRebuildTasks.removeValue(forKey: menuID)?.cancel() self.openMenuRebuildTokens.removeValue(forKey: menuID) self.openMenuRebuildsClosingHostedSubviewMenus.remove(menuID) + self.parentMenuRebuildsDeferredDuringTracking.remove(menuID) self.highlightedMenuItems.removeValue(forKey: menuID) } diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 57e15561..a528c5c5 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -7,7 +7,7 @@ import Testing @Suite(.serialized) struct StatusMenuHostedSubmenuRefreshTests { @Test - func `open parent menu defers data rebuild until hosted submenu closes`() async throws { + func `open parent menu defers data rebuild until parent tracking ends`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = true defer { @@ -59,20 +59,33 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(submenu.items.first?.view != nil) let oldParentVersion = try #require(controller.menuVersions[parentKey]) - controller.menuContentVersion &+= 1 - controller.refreshOpenMenusIfNeeded() + controller.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) #expect(controller.menuVersions[parentKey] == oldParentVersion) - controller.menuContentVersion &+= 1 - controller.refreshOpenMenusIfNeeded() + controller.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) #expect(controller.menuVersions[parentKey] == oldParentVersion) controller.menuDidClose(submenu) #expect(controller.openMenus[submenuKey] == nil) - for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + for _ in 0..<40 where controller.menuVersions[parentKey] != oldParentVersion { await Task.yield() } + #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } + if controller.menuVersions[parentKey] != controller.menuContentVersion { + controller.menuWillOpen(menu) + } + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } #expect(controller.menuVersions[parentKey] == controller.menuContentVersion) } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 88c20fcb..f991eca1 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -145,6 +145,59 @@ extension StatusMenuTests { #expect(controller.menuVersions[key] == controller.menuContentVersion) } + @Test + func `explicit refresh rebuilds stale parent after hosted submenu closes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + 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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = controller.menuVersions[menuKey] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + for _ in 0..<20 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + #expect(controller.menuVersions[menuKey] == openedVersion) + + controller.menuDidClose(submenu) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + } + @Test func `plain open menu refresh preserves pending switcher hosted submenu cleanup`() async { self.disableMenuCardsForTesting() @@ -333,7 +386,7 @@ extension StatusMenuTests { } @Test - func `credits history arriving after open refreshes parent menu without explicit refresh`() async throws { + func `credits history arriving after open rebuilds parent menu after tracking ends`() async throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -370,10 +423,12 @@ extension StatusMenuTests { ], updatedAt: now.addingTimeInterval(10)) - await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) #expect(controller.menuContentVersion != openedVersion) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) #expect( @@ -383,7 +438,7 @@ extension StatusMenuTests { } @Test - func `fresh dashboard history with same day count refreshes parent menu`() async throws { + func `fresh dashboard history with same day count rebuilds parent menu after tracking ends`() async throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -424,17 +479,20 @@ extension StatusMenuTests { ], updatedAt: now.addingTimeInterval(10)) - await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) #expect(controller.menuContentVersion != openedVersion) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) #expect(creditsItem.submenu?.items.first?.representedObject as? String == StatusItemController .creditsHistoryChartID) } @Test - func `token cost history arriving after open refreshes parent menu without explicit refresh`() async throws { + func `token cost history arriving after open rebuilds parent menu after tracking ends`() async throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -464,10 +522,12 @@ extension StatusMenuTests { store._setTokenSnapshotForTesting(self.makeCodexTokenCostSnapshot(), provider: .codex) - await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) #expect(controller.menuContentVersion != openedVersion) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) @@ -475,7 +535,7 @@ extension StatusMenuTests { } @Test - func `fresh token cost history with same day count refreshes parent menu`() async throws { + func `fresh token cost history with same day count rebuilds parent menu after tracking ends`() async throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -520,16 +580,19 @@ extension StatusMenuTests { updatedAt: Date(timeIntervalSince1970: 200)), provider: .codex) - await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) #expect(controller.menuContentVersion != openedVersion) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) } @Test - func `plan utilization history arriving after open refreshes parent menu without explicit refresh`() async throws { + func `plan utilization history arriving after open rebuilds parent menu after tracking ends`() async throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -564,15 +627,17 @@ extension StatusMenuTests { snapshot: self.makeCodexPlanUtilizationSnapshot(), now: Date()) - await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) #expect(store.planUtilizationHistoryRevision > openedRevision) #expect(controller.menuContentVersion != openedVersion) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) } @Test - func `dashboard attachment authorization arriving after open refreshes parent menu`() async throws { + func `dashboard attachment authorization arriving after open rebuilds parent menu after close`() async throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -609,11 +674,13 @@ extension StatusMenuTests { store.openAIDashboardAttachmentAuthorized = true - await self.waitUntilOpenMenuIsFresh(controller, key: key, after: openedVersion) + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) #expect(store.openAIDashboardAttachmentRevision == 1) #expect(controller.menuContentVersion != openedVersion) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) } private func enableOnlyCodex(_ settings: SettingsStore) { @@ -637,7 +704,7 @@ extension StatusMenuTests { } } - private func waitUntilOpenMenuIsFresh( + private func waitUntilOpenMenuStaysStale( _ controller: StatusItemController, key: ObjectIdentifier, after version: Int?) async @@ -647,7 +714,7 @@ extension StatusMenuTests { await Task.yield() continue } - guard controller.menuVersions[key] == controller.menuContentVersion else { + guard controller.menuVersions[key] == version else { await Task.yield() continue } @@ -655,6 +722,24 @@ extension StatusMenuTests { } } + private func closeMenuAndWaitUntilFresh( + _ controller: StatusItemController, + menu: NSMenu, + key: ObjectIdentifier) async + { + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + if controller.menuVersions[key] != controller.menuContentVersion { + controller.menuWillOpen(menu) + } + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + private func makeOpenAIDashboard( dailyBreakdown: [OpenAIDashboardDailyBreakdown], updatedAt: Date) -> OpenAIDashboardSnapshot From ddb805463101c20c63e6bc6433fb6beaa8304ccf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 00:59:16 +0100 Subject: [PATCH 25/79] chore: prepare 0.32.0 release --- CHANGELOG.md | 2 +- version.env | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faa1887d..dcd68440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.31.1 — Unreleased +## 0.32.0 — 2026-05-31 ### Added - Settings: add search to the Providers pane so large provider lists can be filtered by name or id (#1184). Thanks @046081-dotcom! diff --git a/version.env b/version.env index 511f4225..7dc01f33 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.31.1 -BUILD_NUMBER=74 +MARKETING_VERSION=0.32.0 +BUILD_NUMBER=75 From d7a8b38de417b412808fcc3c2fd8ab81ae96d8bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 01:06:24 +0100 Subject: [PATCH 26/79] style: apply SwiftFormat to CLI server --- Sources/CodexBarCLI/CLILocalHTTPServer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCLI/CLILocalHTTPServer.swift b/Sources/CodexBarCLI/CLILocalHTTPServer.swift index 31dc8b62..701ef09a 100644 --- a/Sources/CodexBarCLI/CLILocalHTTPServer.swift +++ b/Sources/CodexBarCLI/CLILocalHTTPServer.swift @@ -127,7 +127,7 @@ enum CLILocalHTTPRequestParseError: Error, Equatable { case disallowedHost } -enum CLIHTTPStatus: Sendable { +enum CLIHTTPStatus { case ok case badRequest case forbidden @@ -160,7 +160,7 @@ enum CLIHTTPStatus: Sendable { } } -struct CLILocalHTTPResponse: Sendable { +struct CLILocalHTTPResponse { let status: CLIHTTPStatus let body: Data let contentType: String From 041bf4af9b412118f834ffe85487dde7e001fe5f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 01:06:24 +0100 Subject: [PATCH 27/79] test: stabilize release precheck --- .../Factory/FactoryStatusProbe.swift | 19 ++++++++++++------- .../FactoryStatusProbeFetchTests.swift | 17 +++++++++++++++-- Tests/CodexBarTests/TTYIntegrationTests.swift | 4 ++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index e3b1fb23..62897187 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -816,15 +816,18 @@ public struct FactoryStatusProbe: Sendable { } private let browserDetection: BrowserDetection + private let transport: any ProviderHTTPTransport public init( baseURL: URL = URL(string: "https://app.factory.ai")!, timeout: TimeInterval = 15.0, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { self.baseURL = baseURL self.timeout = timeout self.browserDetection = browserDetection + self.transport = transport } /// Fetch Factory usage using browser cookies with fallback to stored session. @@ -1266,7 +1269,7 @@ public struct FactoryStatusProbe: Sendable { let data: Data let response: URLResponse do { - (data, response) = try await ProviderHTTPClient.shared.data(for: request) + (data, response) = try await self.transport.data(for: request) } catch { return nil } @@ -1303,7 +1306,7 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1368,7 +1371,7 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1502,7 +1505,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1572,7 +1575,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1715,11 +1718,13 @@ public struct FactoryStatusProbe: Sendable { public init( baseURL: URL = URL(string: "https://app.factory.ai")!, timeout: TimeInterval = 15.0, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { _ = baseURL _ = timeout _ = browserDetection + _ = transport } public func fetch( diff --git a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift index c1d8e2cf..5c01dd2d 100644 --- a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift +++ b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift @@ -88,7 +88,8 @@ struct FactoryStatusProbeFetchTests { homeDirectory: "/tmp/codexbar-empty-browser-home", cacheTTL: 0, fileExists: { _ in false }, - directoryContents: { _ in nil })) + directoryContents: { _ in nil }), + transport: FactoryStubTransport()) let snapshot = try await probe.fetch() @@ -205,7 +206,8 @@ struct FactoryStatusProbeFetchTests { homeDirectory: "/tmp/codexbar-empty-browser-home", cacheTTL: 0, fileExists: { _ in false }, - directoryContents: { _ in nil })) + directoryContents: { _ in nil }), + transport: FactoryStubTransport()) let snapshot = try await probe.fetch() @@ -788,3 +790,14 @@ final class FactoryStubURLProtocol: URLProtocol { override func stopLoading() {} } + +private struct FactoryStubTransport: ProviderHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let handler = FactoryStubURLProtocol.handler else { + throw URLError(.badServerResponse) + } + FactoryStubURLProtocol.requests.append(request) + let (response, data) = try handler(request) + return (data, response) + } +} diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index dca1536f..a17e3c3f 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -65,7 +65,7 @@ struct TTYIntegrationTests { defer { Task { await ClaudeCLISession.shared.reset() } } let snapshot = try await ClaudeCLISession.withIsolatedSessionForTesting { - try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 8).fetch() + try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 10).fetch() } #expect(snapshot.sessionPercentLeft == 93) @@ -101,7 +101,7 @@ struct TTYIntegrationTests { *"/usage"*) printf '%s\\n' 'Settings Status Config Usage' printf '%s\\n' 'Current session' - sleep 4 + sleep 2 printf '%s\\n' '93% left' printf '%s\\n' 'Current week (all models)' printf '%s\\n' '79% left' From 44e4d161719eecce9301e280ee5081b230aa3585 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 01:35:53 +0100 Subject: [PATCH 28/79] chore: normalize widget project package name --- .../CodexBarWidgetExtension.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index 83a8c7f5..195b28ba 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 = ( - 9FA0A78FB7CA1D877E7BA54B /* codexbar */, + EFBE36CB6481E7133E2A5CF3 /* CodexBar */, ); name = Packages; sourceTree = ""; From 1351961f4d6a286df8dc26cb6e021ab40e7b552d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 01:50:26 +0100 Subject: [PATCH 29/79] docs: update appcast for 0.32.0 --- appcast.xml | 68 +++++++++++++++++++++++------------------------------ 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/appcast.xml b/appcast.xml index c0df2ebb..56ab2827 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,36 @@ CodexBar + + 0.32.0 + Sun, 31 May 2026 01:50:25 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 75 + 0.32.0 + 14.0 + CodexBar 0.32.0 +

Added

+
    +
  • Settings: add search to the Providers pane so large provider lists can be filtered by name or id (#1184). Thanks @046081-dotcom!
  • +
+

Fixed

+
    +
  • Augment: parse the updated auggie account status output format, fall back to browser cookies when CLI parsing fails, and restore session cookie detection (#1224). Thanks @bcharleson!
  • +
  • Amp/Ollama: require HTTPS before reattaching imported browser cookies on provider redirects to avoid cleartext cookie exposure (#1226). Thanks @Hinotoi-agent!
  • +
  • Antigravity: filter noisy remote OAuth per-model quota rows, keep consumed noisy rows detail-only, and prevent image/lite/autocomplete/internal rows from driving summary bars (#1209). Thanks @guhyun9454!
  • +
  • Claude: preserve the last good Claude Web usage snapshot across transient Unauthorized refresh failures while still surfacing repeated auth failures (#1220). Thanks @LeoLin990405!
  • +
  • CLI: avoid executing a same-user mutable temporary installer script across the macOS administrator privilege boundary (#1222). Thanks @Hinotoi-agent!
  • +
  • Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217).
  • +
  • Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad!
  • +
  • Menu bar: defer background parent-menu rebuilds until AppKit menu tracking ends so late-arriving usage data cannot stall dropdown hover on macOS 26.5 (#1227).
  • +
  • Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej!
  • +
  • Release: isolate notarization API keys and upload ZIPs in a private per-run temporary directory instead of predictable shared /tmp paths (#1228). Thanks @Hinotoi-agent!
  • +
  • Status: retry startup refreshes a few times after transient offline/network failures so provider status can recover after macOS brings the network online (#1211).
  • +
+

View full changelog

+]]>
+ +
0.31.0 Thu, 28 May 2026 23:11:46 +0100 @@ -57,44 +87,6 @@ ]]> - - 0.30.0 - Wed, 27 May 2026 07:04:18 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 71 - 0.30.0 - 14.0 - CodexBar 0.30.0 -

Added

-
    -
  • MiniMax: add a redacted diagnostic CLI export for safe issue reports (#1128). Thanks @Yuxin-Qiao!
  • -
  • Antigravity: show the complete per-model quota breakdown alongside the existing summary lanes (#1139). Thanks @guhyun9454!
  • -
  • Widget: show tertiary usage rows for providers that expose a third quota lane (#1160). Thanks @LeoLin990405!
  • -
  • DeepSeek: show optional web-session usage and cost summaries alongside the balance card (#1166). Thanks @Yuxin-Qiao!
  • -
  • OpenAI: scope Admin API usage to the configured project and keep token accounts from inheriting stale project filters (#1168). Thanks @mstallone!
  • -
-

Fixed

-
    -
  • App shutdown: detach status items, close tracked menus, and cancel menu tasks before quit so Dock autohide stays responsive on macOS 26.5 (#1174). Thanks @jskoiz!
  • -
  • Widgets: package the macOS widget as a real Xcode app-extension target so WidgetKit descriptors load on macOS 26.5 (#1095). Thanks @jamesjlopez!
  • -
  • Menu: render quota-warning markers as subtle inset ticks instead of full-height bars (#1149).
  • -
  • Codex: show sign-in guidance when the Codex CLI is logged out instead of reporting a temporary usage outage (#1171, fixes #1170). Thanks @jskoiz!
  • -
  • Menu bar: clear stale hidden macOS status-item visibility defaults once before creating CodexBar items (#1169).
  • -
  • StepFun: refresh expired Oasis tokens and persist recovered manual sessions. Thanks @LeoLin990405!
  • -
  • Release: prevent manual CLI artifact builds from publishing or clobbering release assets (#1154). Thanks @jskoiz!
  • -
  • Cost history: route OpenAI and Mistral API spend through the shared cost-history cards, including OpenAI request counts (#1163). Thanks @LeoLin990405!
  • -
  • Menu: keep provider switcher Cmd-number and arrow shortcuts working while the open menu is tracking events (#1157, fixes #1156 and #1144). Thanks @anirudhvee!
  • -
  • Codex: prevent fork token replay from overcounting corrected cumulative session totals (#1164). Thanks @xx205!
  • -
  • Alibaba Token Plan: update usage refreshes to the Bailian subscription-summary endpoint (#1142). Thanks @YanxinXue!
  • -
  • Ollama: show pace projections for documented 5-hour session and 7-day weekly usage windows (#1136). Thanks @bdamokos!
  • -
  • Localization: polish Simplified Chinese wording and add notification strings (#1165). Thanks @fanfanci!
  • -
  • Localization: improve Traditional Chinese wording and localize notification copy (#1158). Thanks @jack24254029!
  • -
  • Localization: improve Simplified Chinese visible menu, dashboard, and usage labels (#1145). Thanks @Yuxin-Qiao!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 6106ca01b1d70f1f901b547fdc37aea94e35c96c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 01:56:56 +0100 Subject: [PATCH 30/79] chore: open 0.32.1 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd68440..9b9c50e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.32.1 — Unreleased + ## 0.32.0 — 2026-05-31 ### Added diff --git a/version.env b/version.env index 7dc01f33..e5ff061b 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.32.0 -BUILD_NUMBER=75 +MARKETING_VERSION=0.32.1 +BUILD_NUMBER=76 From d5a5796a9844c302bb97494c39f3c6c6f7c4a6d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 10:21:52 +0100 Subject: [PATCH 31/79] fix: defer menu refresh until close --- CHANGELOG.md | 3 + .../CodexBar/StatusItemController+Menu.swift | 31 +++++-- ...temController+MenuInteractionRefresh.swift | 80 +++++++++++++++++++ Sources/CodexBar/StatusItemController.swift | 3 + .../StatusMenuOpenRefreshTests.swift | 48 +++++++++++ 5 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9c50e6..c581070b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.1 — Unreleased +### Fixed +- Menu bar: defer automatic provider refreshes until after AppKit menu tracking ends so opening the dropdown no longer starts work that can freeze focus and keyboard input. + ## 0.32.0 — 2026-05-31 ### Added diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 204f5065..dc5bd349 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -73,6 +73,17 @@ extension StatusItemController { } func menuWillOpen(_ menu: NSMenu) { + let menuOpenStartedAt = CACurrentMediaTime() + defer { + self.logMenuOperationDurationIfSlow( + "menuWillOpen", + startedAt: menuOpenStartedAt, + menu: menu, + provider: self.menuProvider(for: menu)) + } + + self.cancelDeferredMenuInteractionRefreshTask() + if self.isHostedSubviewMenu(menu) { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) @@ -132,6 +143,7 @@ extension StatusItemController { if wasHostedSubviewMenu { self.refreshOpenMenusAfterHostedSubviewClose() } + self.scheduleDeferredMenuInteractionRefreshIfNeeded() } func forgetClosedMenu(_ menu: NSMenu) { @@ -180,6 +192,15 @@ extension StatusItemController { } func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { + let populateStartedAt = CACurrentMediaTime() + defer { + self.logMenuOperationDurationIfSlow( + "populateMenu", + startedAt: populateStartedAt, + menu: menu, + provider: provider) + } + let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -1090,11 +1111,9 @@ extension StatusItemController { } private func scheduleOpenMenuRefresh(for menu: NSMenu) { - // Kick off a refresh on open (non-forced) and re-check after a delay. - // NEVER block menu opening with network requests. - if !self.store.isRefreshing { - self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) - } + // Queue refresh work until the menu closes. AppKit menu tracking is modal; starting provider refreshes + // while it is active can make the menu feel frozen and can block keyboard focus from returning. + self.deferMenuInteractionRefreshIfNeeded() let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() self.menuRefreshTasks[key] = Task { @MainActor [weak self, weak menu] in @@ -1112,7 +1131,7 @@ extension StatusItemController { let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 guard willRetryRefresh else { return } - self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) + self.deferMenuInteractionRefreshIfNeeded() } } diff --git a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift new file mode 100644 index 00000000..4097acfe --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift @@ -0,0 +1,80 @@ +import AppKit +import CodexBarCore +import QuartzCore + +extension StatusItemController { + private static let defaultDeferredMenuInteractionRefreshDelay: Duration = .milliseconds(250) + private static let slowMenuOperationThreshold: TimeInterval = 0.15 + + #if DEBUG + private static var deferredMenuInteractionRefreshDelayForTesting: Duration = .milliseconds(250) + + static func setDeferredMenuInteractionRefreshDelayForTesting(_ delay: Duration) { + self.deferredMenuInteractionRefreshDelayForTesting = delay + } + + static func resetDeferredMenuInteractionRefreshDelayForTesting() { + self.deferredMenuInteractionRefreshDelayForTesting = self.defaultDeferredMenuInteractionRefreshDelay + } + #endif + + private static var deferredMenuInteractionRefreshDelay: Duration { + #if DEBUG + deferredMenuInteractionRefreshDelayForTesting + #else + defaultDeferredMenuInteractionRefreshDelay + #endif + } + + func logMenuOperationDurationIfSlow( + _ operation: String, + startedAt: CFTimeInterval, + menu: NSMenu, + provider: UsageProvider?) + { + let elapsed = CACurrentMediaTime() - startedAt + guard elapsed >= Self.slowMenuOperationThreshold else { return } + self.menuLogger.warning( + "slow menu operation", + metadata: [ + "operation": operation, + "durationMs": String(format: "%.1f", elapsed * 1000), + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + "openMenus": "\(self.openMenus.count)", + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + } + + func deferMenuInteractionRefreshIfNeeded() { + guard !self.store.isRefreshing else { return } + self.deferredMenuInteractionRefreshPending = true + } + + func cancelDeferredMenuInteractionRefreshTask() { + self.deferredMenuInteractionRefreshTask?.cancel() + self.deferredMenuInteractionRefreshTask = nil + } + + func scheduleDeferredMenuInteractionRefreshIfNeeded() { + guard self.openMenus.isEmpty else { return } + guard self.deferredMenuInteractionRefreshPending else { return } + guard !self.hasPreparedForAppShutdown else { return } + + self.cancelDeferredMenuInteractionRefreshTask() + self.deferredMenuInteractionRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: Self.deferredMenuInteractionRefreshDelay) + guard let self, !Task.isCancelled else { return } + defer { self.deferredMenuInteractionRefreshTask = nil } + guard self.openMenus.isEmpty else { return } + guard self.deferredMenuInteractionRefreshPending else { return } + guard !self.hasPreparedForAppShutdown else { return } + guard !self.store.isRefreshing else { return } + self.deferredMenuInteractionRefreshPending = false + #if DEBUG + self.onDeferredMenuInteractionRefreshForTesting?() + #endif + self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) + } + } +} diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 62cb58c7..5bffe248 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -137,6 +137,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenuRebuildTokenCounter = 0 var openMenuRebuildsClosingHostedSubviewMenus: Set = [] var parentMenuRebuildsDeferredDuringTracking: Set = [] + var deferredMenuInteractionRefreshPending = false + var deferredMenuInteractionRefreshTask: Task? var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? @@ -144,6 +146,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenuInvalidationRetryTask: Task? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? + var onDeferredMenuInteractionRefreshForTesting: (() -> Void)? var onOpenMenuInvalidationRetryForTesting: (() -> Void)? var isReleasedForTesting = false var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index f991eca1..7e24bcdb 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -5,6 +5,54 @@ import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `menu open 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) + 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 + 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(!controller.deferredMenuInteractionRefreshPending) + } + @Test func `store observation marks open menu stale without rebuilding during tracking`() async { self.disableMenuCardsForTesting() From e7a96dc5ef933d22924605be6933889148d0af44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 11:03:52 +0100 Subject: [PATCH 32/79] fix: cache codex account reconciliation --- CHANGELOG.md | 1 + Sources/CodexBar/CodexbarApp.swift | 2 +- .../Providers/Codex/CodexSettingsStore.swift | 75 ++++++++++++++-- Sources/CodexBar/SettingsStore.swift | 13 +++ .../CodexAccountReconciliationTests.swift | 87 +++++++++++++++++++ 5 files changed, 168 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c581070b..d396afdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.32.1 — Unreleased ### Fixed +- Menu bar: reuse short-lived Codex account reconciliation snapshots so repeated menu rebuilds do not reread local auth state on every open. - Menu bar: defer automatic provider refreshes until after AppKit menu tracking ends so opening the dropdown no longer starts work that can freeze focus and keyboard input. ## 0.32.0 — 2026-05-31 diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 260abc83..bd1c5661 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -47,7 +47,7 @@ struct CodexBarApp: App { configureUsageFormatterLocalizationProvider() let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator() managedCodexAccountCoordinator.onManagedAccountsDidChange = { - _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() + _ = settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() } _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() let fetcher = UsageFetcher() diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 8bfb331f..592da489 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -128,6 +128,7 @@ extension SettingsStore { self.codexPersistedActiveSource } set { + self.invalidateCodexAccountReconciliationSnapshotCache() self.updateProviderConfig(provider: .codex) { entry in entry.codexActiveSource = newValue } @@ -150,6 +151,12 @@ extension SettingsStore { return true } + @discardableResult + func refreshCodexAccountReconciliationAfterManagedAccountsDidChange() -> Bool { + self.invalidateCodexAccountReconciliationSnapshotCache() + return self.persistResolvedCodexActiveSourceCorrectionIfNeeded() + } + var codexCookieHeader: String { get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedCookieHeader ?? "" } set { @@ -180,6 +187,19 @@ extension SettingsStore { } extension SettingsStore { + private static var codexAccountReconciliationSnapshotCacheInterval: TimeInterval { + #if DEBUG + if let codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting { + return codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting + } + #endif + return self.isRunningTests ? 0 : self.productionCodexAccountReconciliationSnapshotCacheInterval + } + + func invalidateCodexAccountReconciliationSnapshotCache() { + self.cachedCodexAccountReconciliationSnapshot = nil + } + var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot { self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) } @@ -187,9 +207,25 @@ extension SettingsStore { func codexAccountReconciliationSnapshot( activeSourceOverride: CodexActiveSource?) -> CodexAccountReconciliationSnapshot { - self.codexAccountReconciler( - activeSource: activeSourceOverride ?? self.codexPersistedActiveSource) - .loadSnapshot() + let activeSource = activeSourceOverride ?? self.codexPersistedActiveSource + let cacheInterval = Self.codexAccountReconciliationSnapshotCacheInterval + let now = Date() + if cacheInterval > 0, + let cached = self.cachedCodexAccountReconciliationSnapshot, + cached.activeSource == activeSource, + now.timeIntervalSince(cached.loadedAt) < cacheInterval + { + return cached.snapshot + } + + let snapshot = self.codexAccountReconciler(activeSource: activeSource).loadSnapshot() + if cacheInterval > 0 { + self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: activeSource, + loadedAt: now, + snapshot: snapshot) + } + return snapshot } var codexVisibleAccountProjection: CodexVisibleAccountProjection { @@ -203,6 +239,7 @@ extension SettingsStore { @discardableResult func selectCodexVisibleAccount(id: String) -> Bool { guard let source = self.codexSource(forVisibleAccountID: id) else { return false } + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = source return true } @@ -212,6 +249,7 @@ extension SettingsStore { return } // An open menu can preserve a previously rendered account row while the live projection is briefly incomplete. + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = account.selectionSource } @@ -224,6 +262,7 @@ extension SettingsStore { return } + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = .managedAccount(id: account.id) _ = self.persistResolvedCodexActiveSourceCorrectionIfNeeded() } @@ -469,32 +508,50 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb extension SettingsStore { var _test_activeManagedCodexRemoteHomePath: String? { get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) + } } var _test_activeManagedCodexAccount: ManagedCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.account(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) + } } var _test_unreadableManagedCodexAccountStore: Bool { get { CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) + } } var _test_managedCodexAccountStoreURL: URL? { get { CodexManagedRemoteHomeTestingOverride.managedStoreURL(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) + } } var _test_liveSystemCodexAccount: ObservedSystemCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) + } } var _test_codexReconciliationEnvironment: [String: String]? { get { CodexManagedRemoteHomeTestingOverride.reconciliationEnvironment(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) + } } } #endif diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 379441f9..a38c5ba5 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -108,11 +108,18 @@ enum MultiAccountMenuLayout: String, CaseIterable, Identifiable { } } +struct CachedCodexAccountReconciliationSnapshot { + let activeSource: CodexActiveSource + let loadedAt: Date + let snapshot: CodexAccountReconciliationSnapshot +} + @MainActor @Observable final class SettingsStore { static let sharedDefaults = AppGroupSupport.sharedDefaults() static let mergedOverviewProviderLimit = 3 + static let productionCodexAccountReconciliationSnapshotCacheInterval: TimeInterval = 2 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } @@ -121,12 +128,18 @@ final class SettingsStore { return NSClassFromString("XCTestCase") != nil }() + #if DEBUG + static var codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting: TimeInterval? + #endif + @ObservationIgnored let userDefaults: UserDefaults @ObservationIgnored let configStore: CodexBarConfigStore @ObservationIgnored var config: CodexBarConfig @ObservationIgnored var configPersistTask: Task? @ObservationIgnored var configLoading = false @ObservationIgnored var tokenAccountsLoaded = false + @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: + CachedCodexAccountReconciliationSnapshot? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 var providerOrder: [UsageProvider] = [] diff --git a/Tests/CodexBarTests/CodexAccountReconciliationTests.swift b/Tests/CodexBarTests/CodexAccountReconciliationTests.swift index 438ae652..cf53f30e 100644 --- a/Tests/CodexBarTests/CodexAccountReconciliationTests.swift +++ b/Tests/CodexBarTests/CodexAccountReconciliationTests.swift @@ -124,6 +124,93 @@ struct CodexAccountReconciliationTests { #expect(projection.liveVisibleAccountID == "ambient@example.com") } + @Test + @MainActor + func `settings store can reuse short lived codex reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-short-lived-cache" + let settings = try Self.makeSettings(suite: suite) + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "cached@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 first = settings.codexAccountReconciliationSnapshot + try FileManager.default.removeItem(at: ambientHome) + let cached = settings.codexAccountReconciliationSnapshot + settings.invalidateCodexAccountReconciliationSnapshotCache() + let refreshed = settings.codexAccountReconciliationSnapshot + + #expect(first.liveSystemAccount?.email == "cached@example.com") + #expect(cached.liveSystemAccount?.email == "cached@example.com") + #expect(refreshed.liveSystemAccount == nil) + } + + @Test + @MainActor + func `codex active source write invalidates short lived reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-active-source-cache-invalidation" + 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) + } + + #expect(settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email == "before@example.com") + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "after@example.com", plan: "pro") + settings.codexActiveSource = .liveSystem + + #expect(settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email == "after@example.com") + } + + @Test + @MainActor + func `managed account changes invalidate short lived reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-managed-change-cache-invalidation" + let settings = try Self.makeSettings(suite: suite) + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-store-\(UUID().uuidString).json") + try Self.writeManagedCodexStore( + ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: []), + to: storeURL) + let stored = ManagedCodexAccount( + id: UUID(), + email: "stored@example.com", + managedHomePath: "/tmp/stored-managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: stored.id) + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + + #expect(settings.codexAccountReconciliationSnapshot.storedAccounts.isEmpty) + try Self.writeManagedCodexStore( + ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [stored]), + to: storeURL) + settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() + + #expect(settings.codexAccountReconciliationSnapshot.storedAccounts.map(\.id) == [stored.id]) + } + @Test @MainActor func `settings store home path override also keeps reconciliation hermetic`() throws { From 3488587856c724bbd007bce435697ba64a11535c Mon Sep 17 00:00:00 2001 From: Rajvardhan Patil Date: Sun, 31 May 2026 20:13:13 +0530 Subject: [PATCH 33/79] fix: preserve Claude CLI token ownership Preserve Claude CLI OAuth refresh token ownership so CodexBar delegates refresh when Claude Code storage is present. Adds regression coverage for mixed cache/file/keychain ownership and the CodexBar-only fallback path. Co-authored-by: Rajvardhan Patil <243567420+RajvardhanPatil07@users.noreply.github.com> --- CHANGELOG.md | 1 + .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 98 +++++-- ...entialsStoreCLIStorageOwnershipTests.swift | 254 ++++++++++++++++++ 3 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d396afdc..7ad6c681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.32.1 — Unreleased ### Fixed +- Claude: keep Claude CLI-owned OAuth refresh tokens delegated to Claude Code when CLI storage is present, preventing CodexBar from consuming rotating refresh tokens and forcing re-login (#1161, #1239). Thanks @RajvardhanPatil07! - Menu bar: reuse short-lived Codex account reconciliation snapshots so repeated menu rebuilds do not reread local auth state on every open. - Menu bar: defer automatic provider refreshes until after AppKit menu tracking ends so opening the dropdown no longer starts work that can freeze focus and keyboard input. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 798fb98d..5f790ee9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -192,17 +192,19 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { - if recovery.shouldAttemptFreshnessSyncFromClaudeKeychain(cached: cachedRecord), + let owner = self.resolvedCacheOwner(cachedRecord.owner) + let record = ClaudeOAuthCredentialRecord( + credentials: cachedRecord.credentials, + owner: owner, + source: .memoryCache) + if recovery.shouldAttemptFreshnessSyncFromClaudeKeychain(cached: record), let synced = recovery.syncWithClaudeKeychainIfChanged( - cached: cachedRecord, + cached: record, respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) { return synced } - return ClaudeOAuthCredentialRecord( - credentials: cachedRecord.credentials, - owner: cachedRecord.owner, - source: .memoryCache) + return record } var lastError: Error? @@ -212,7 +214,7 @@ public enum ClaudeOAuthCredentialsStore { switch KeychainCacheStore.load(key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) { case let .found(entry): if let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) { - let owner = entry.owner ?? .claudeCLI + let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI) let record = ClaudeOAuthCredentialRecord( credentials: creds, owner: owner, @@ -326,9 +328,10 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { + let owner = self.resolvedCacheOwner(cachedRecord.owner) return ClaudeOAuthCredentialRecord( credentials: cachedRecord.credentials, - owner: cachedRecord.owner, + owner: owner, source: .memoryCache) } if case let .found(entry) = KeychainCacheStore.load( @@ -337,9 +340,10 @@ public enum ClaudeOAuthCredentialsStore { let creds = try? ClaudeOAuthCredentials.parse(data: entry.data), !creds.isExpired { + let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI) return ClaudeOAuthCredentialRecord( credentials: creds, - owner: entry.owner ?? .claudeCLI, + owner: owner, source: .cacheKeychain) } @@ -429,6 +433,18 @@ public enum ClaudeOAuthCredentialsStore { return nil } + private func resolvedCacheOwner(_ owner: ClaudeOAuthCredentialOwner) -> ClaudeOAuthCredentialOwner { + guard owner == .codexbar else { return owner } + guard self.hasClaudeCLIStorageWithoutPrompt() else { return owner } + // Claude Code rotates refresh tokens; when its storage exists, it owns the refresh lifecycle. + return .claudeCLI + } + + private func hasClaudeCLIStorageWithoutPrompt() -> Bool { + if ClaudeOAuthCredentialsStore.currentFileFingerprint() != nil { return true } + return ClaudeOAuthCredentialsStore.hasClaudeKeychainItemWithoutPrompt() + } + @discardableResult func invalidateCacheIfCredentialsFileChanged() -> Bool { self.context.run { @@ -1289,6 +1305,40 @@ public enum ClaudeOAuthCredentialsStore { Repository(context: self.currentCollaboratorContext()).hasClaudeKeychainCredentialsWithoutPrompt() } + private static func hasClaudeKeychainItemWithoutPrompt() -> Bool { + #if DEBUG + if let store = self.taskClaudeKeychainOverrideStore { + if let data = store.data, !data.isEmpty { return true } + if store.fingerprint != nil { return true } + } + if let data = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride, + !data.isEmpty + { + return true + } + if self.taskClaudeKeychainFingerprintOverride ?? self.claudeKeychainFingerprintOverride != nil { + return true + } + #endif + + #if os(macOS) + switch self.claudeKeychainCandidatesProbeWithoutPrompt(enforcePromptPolicy: false) { + case let .value(candidates) where !candidates.isEmpty: + return true + case .value, .unavailable: + break + } + switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(enforcePromptPolicy: false) { + case let .value(candidate): + return candidate != nil + case .unavailable: + return false + } + #else + return false + #endif + } + private static func shouldCheckClaudeKeychainChange(now: Date = Date()) -> Bool { #if DEBUG // Unit tests can supply TaskLocal overrides for the Claude keychain data/fingerprint. Those tests often run @@ -1561,12 +1611,17 @@ public enum ClaudeOAuthCredentialsStore { private static func claudeKeychainCandidatesProbeWithoutPrompt( promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference - .current()) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]> + .current(), + enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]> { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } - if self.isPromptPolicyApplicable, - ProviderInteractionContext.current == .background, - !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + if enforcePromptPolicy { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + } else { + guard self.keychainAccessAllowed else { return .unavailable } + } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.claudeKeychainService, @@ -1617,12 +1672,17 @@ public enum ClaudeOAuthCredentialsStore { private static func claudeKeychainLegacyCandidateProbeWithoutPrompt( promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference - .current()) -> ClaudeKeychainProbe + .current(), + enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } - if self.isPromptPolicyApplicable, - ProviderInteractionContext.current == .background, - !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + if enforcePromptPolicy { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + } else { + guard self.keychainAccessAllowed else { return .unavailable } + } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.claudeKeychainService, diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift new file mode 100644 index 00000000..65dcba04 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -0,0 +1,254 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let refreshField: String = { + guard let refreshToken else { return "" } + return ",\n \"refreshToken\": \"\(refreshToken)\"" + }() + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"]\(refreshField) + } + } + """ + return Data(json.utf8) + } + + @Test + func `load record treats codexbar cache as claude CLI owned when credentials file exists`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let fileData = self.makeCredentialsData( + accessToken: "claude-cli-file", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cli-refresh-token") + try fileData.write(to: fileURL) + + let cachedData = self.makeCredentialsData( + accessToken: "codexbar-cache", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + + #expect(record.credentials.accessToken == "codexbar-cache") + #expect(record.owner == .claudeCLI) + #expect(record.source == .cacheKeychain) + } + } + } + } + } + } + + @Test + func `load with auto refresh delegates expired codexbar cache when credentials file exists`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + try Data("not valid credentials".utf8).write(to: fileURL) + + let expiredData = self.makeCredentialsData( + accessToken: "expired-codexbar-with-file", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected delegated refresh error when Claude CLI file is present") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + + @Test + func `load with auto refresh keeps codexbar cache ownership without Claude CLI storage`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let expiredData = self.makeCredentialsData( + accessToken: "expired-codexbar-only", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected direct CodexBar refresh failure") + } catch let error as ClaudeOAuthCredentialsError { + guard case let .refreshFailed(message) = error else { + Issue.record("Expected .refreshFailed, got \(error)") + return + } + #expect(message.contains("suppressed") || message.contains("backed off")) + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + + @Test + func `load record treats codexbar cache as claude CLI owned when Claude keychain item exists`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let cachedData = self.makeCredentialsData( + accessToken: "codexbar-cache", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(), + owner: .codexbar)) + + let keychainData = self.makeCredentialsData( + accessToken: "claude-keychain", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "keychain-refresh-token") + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + } + } + + #expect(record.credentials.accessToken == "codexbar-cache") + #expect(record.owner == .claudeCLI) + #expect(record.source == .cacheKeychain) + } + } + } + } + } +} From 07ed3facdd4eea40d24e0aef56ffef94e3fb57ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 18:27:08 +0100 Subject: [PATCH 34/79] fix: reduce CodexBar menu refresh work --- CHANGELOG.md | 1 + Sources/CodexBar/MemoryPressureRelief.swift | 7 + .../StatusItemController+Actions.swift | 33 ++- .../CodexBar/StatusItemController+Menu.swift | 6 +- ...temController+MenuInteractionRefresh.swift | 62 +++- Sources/CodexBar/StatusItemController.swift | 1 + .../CodexBar/UsageStore+MemoryPressure.swift | 19 ++ Sources/CodexBar/UsageStore+OpenAIWeb.swift | 15 + Sources/CodexBar/UsageStore.swift | 16 +- Sources/CodexBarCore/KeychainCacheStore.swift | 36 ++- .../CodexBarTests/BrowserDetectionTests.swift | 65 +++- ...CodexAccountScopedRefreshTestSupport.swift | 34 +++ .../CodexManagedOpenAIWebRefreshTests.swift | 67 +++-- .../KeychainCacheStoreTests.swift | 16 + .../OpenAIWebRefreshGateTests.swift | 49 ++- .../StatusMenuOpenRefreshTests.swift | 280 +++++++++++++++++- 16 files changed, 631 insertions(+), 76 deletions(-) create mode 100644 Sources/CodexBar/MemoryPressureRelief.swift create mode 100644 Sources/CodexBar/UsageStore+MemoryPressure.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ad6c681..a1b682d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Claude: keep Claude CLI-owned OAuth refresh tokens delegated to Claude Code when CLI storage is present, preventing CodexBar from consuming rotating refresh tokens and forcing re-login (#1161, #1239). Thanks @RajvardhanPatil07! - Menu bar: reuse short-lived Codex account reconciliation snapshots so repeated menu rebuilds do not reread local auth state on every open. - Menu bar: defer automatic provider refreshes until after AppKit menu tracking ends so opening the dropdown no longer starts work that can freeze focus and keyboard input. +- Menu bar: suppress background keychain and OpenAI dashboard work during startup/menu tracking so the dropdown stays clickable without macOS keychain prompts or WebKit memory spikes. ## 0.32.0 — 2026-05-31 diff --git a/Sources/CodexBar/MemoryPressureRelief.swift b/Sources/CodexBar/MemoryPressureRelief.swift new file mode 100644 index 00000000..7a2162e3 --- /dev/null +++ b/Sources/CodexBar/MemoryPressureRelief.swift @@ -0,0 +1,7 @@ +import Darwin + +enum MemoryPressureRelief { + static func releaseFreeMallocPages() { + _ = malloc_zone_pressure_relief(nil, 0) + } +} diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index fbb4ebcd..6a3c7b41 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -12,16 +12,31 @@ enum LoginNotificationLogic { extension StatusItemController: StatusItemMenuPersistentActionDelegate { // MARK: - Actions reachable from menus - func refreshStore(forceTokenUsage: Bool, refreshOpenMenusWhenComplete: Bool = true) { + func refreshStore( + forceTokenUsage: Bool, + refreshOpenMenusWhenComplete: Bool = true, + interaction: ProviderInteraction = .userInitiated) + { Task { - await ProviderInteractionContext.$current.withValue(.userInitiated) { - await self.store.refresh(forceTokenUsage: forceTokenUsage) - self.store.scheduleStorageFootprintRefreshForOverview(force: true) - if refreshOpenMenusWhenComplete { - self.refreshOpenMenusAfterExplicitStoreAction() - } else { - self.invalidateMenus() - } + await self.performStoreRefresh( + forceTokenUsage: forceTokenUsage, + refreshOpenMenusWhenComplete: refreshOpenMenusWhenComplete, + interaction: interaction) + } + } + + func performStoreRefresh( + forceTokenUsage: Bool, + refreshOpenMenusWhenComplete: Bool, + interaction: ProviderInteraction) async + { + await ProviderInteractionContext.$current.withValue(interaction) { + await self.store.refresh(forceTokenUsage: forceTokenUsage) + self.store.scheduleStorageFootprintRefreshForOverview(force: true) + if refreshOpenMenusWhenComplete { + self.refreshOpenMenusAfterExplicitStoreAction() + } else { + self.invalidateMenus() } } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index dc5bd349..90182040 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -88,7 +88,7 @@ extension StatusItemController { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) if self.isMenuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { - self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "submenu open") } if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). @@ -119,7 +119,7 @@ extension StatusItemController { } if self.isMenuRefreshEnabled, (provider ?? self.lastMenuProvider) == .codex { - self.store.requestOpenAIDashboardRefreshIfStale(reason: "parent menu open") + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") } if self.menuNeedsRefresh(menu) { @@ -143,7 +143,6 @@ extension StatusItemController { if wasHostedSubviewMenu { self.refreshOpenMenusAfterHostedSubviewClose() } - self.scheduleDeferredMenuInteractionRefreshIfNeeded() } func forgetClosedMenu(_ menu: NSMenu) { @@ -172,6 +171,7 @@ extension StatusItemController { self.rebuildClosedMenuIfNeeded(menu) } self.parentMenuRebuildsDeferredDuringTracking.remove(key) + self.scheduleDeferredMenuInteractionRefreshIfNeeded() } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { diff --git a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift index 4097acfe..d5d0ff1c 100644 --- a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift +++ b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift @@ -51,30 +51,74 @@ extension StatusItemController { self.deferredMenuInteractionRefreshPending = true } + func deferOpenAIDashboardRefreshUntilMenuCloses(reason: String) { + if let existingReason = self.deferredOpenAIDashboardRefreshReason { + self.deferredOpenAIDashboardRefreshReason = "\(existingReason), \(reason)" + } else { + self.deferredOpenAIDashboardRefreshReason = reason + } + } + func cancelDeferredMenuInteractionRefreshTask() { self.deferredMenuInteractionRefreshTask?.cancel() self.deferredMenuInteractionRefreshTask = nil } - func scheduleDeferredMenuInteractionRefreshIfNeeded() { + func scheduleDeferredMenuInteractionRefreshIfNeeded(delay: Duration? = nil) { guard self.openMenus.isEmpty else { return } - guard self.deferredMenuInteractionRefreshPending else { return } + guard self.deferredMenuInteractionRefreshPending || self.deferredOpenAIDashboardRefreshReason != nil else { + return + } guard !self.hasPreparedForAppShutdown else { return } self.cancelDeferredMenuInteractionRefreshTask() + let delay = delay ?? Self.deferredMenuInteractionRefreshDelay self.deferredMenuInteractionRefreshTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: Self.deferredMenuInteractionRefreshDelay) + try? await Task.sleep(for: delay) guard let self, !Task.isCancelled else { return } - defer { self.deferredMenuInteractionRefreshTask = nil } - guard self.openMenus.isEmpty else { return } - guard self.deferredMenuInteractionRefreshPending else { return } - guard !self.hasPreparedForAppShutdown else { return } - guard !self.store.isRefreshing else { return } + guard self.openMenus.isEmpty else { + self.deferredMenuInteractionRefreshTask = nil + return + } + let shouldRefreshStore = self.deferredMenuInteractionRefreshPending + let openAIDashboardRefreshReason = self.deferredOpenAIDashboardRefreshReason + guard shouldRefreshStore || openAIDashboardRefreshReason != nil else { + self.deferredMenuInteractionRefreshTask = nil + return + } + guard !self.hasPreparedForAppShutdown else { + self.deferredMenuInteractionRefreshTask = nil + return + } + guard !self.store.isRefreshing else { + self.deferredMenuInteractionRefreshTask = nil + self + .scheduleDeferredMenuInteractionRefreshIfNeeded(delay: Self + .defaultDeferredMenuInteractionRefreshDelay) + return + } + self.deferredMenuInteractionRefreshTask = nil self.deferredMenuInteractionRefreshPending = false + self.deferredOpenAIDashboardRefreshReason = nil #if DEBUG self.onDeferredMenuInteractionRefreshForTesting?() #endif - self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) + if shouldRefreshStore { + await self.performStoreRefresh( + forceTokenUsage: false, + refreshOpenMenusWhenComplete: false, + interaction: .background) + guard !Task.isCancelled else { return } + } + if let openAIDashboardRefreshReason { + guard self.openMenus.isEmpty else { + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: openAIDashboardRefreshReason) + return + } + // Keep menu-originated automatic dashboard refreshes non-interactive: + // opening a menu is not consent to show macOS Keychain prompts. + self.store.requestOpenAIDashboardRefreshIfStale(reason: openAIDashboardRefreshReason) + } } } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 5bffe248..b8f4df3d 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -138,6 +138,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenuRebuildsClosingHostedSubviewMenus: Set = [] var parentMenuRebuildsDeferredDuringTracking: Set = [] var deferredMenuInteractionRefreshPending = false + var deferredOpenAIDashboardRefreshReason: String? var deferredMenuInteractionRefreshTask: Task? var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? diff --git a/Sources/CodexBar/UsageStore+MemoryPressure.swift b/Sources/CodexBar/UsageStore+MemoryPressure.swift new file mode 100644 index 00000000..97e3d12b --- /dev/null +++ b/Sources/CodexBar/UsageStore+MemoryPressure.swift @@ -0,0 +1,19 @@ +import Foundation + +@MainActor +extension UsageStore { + func scheduleMemoryPressureRelief() { + guard self.memoryPressureReliefTask == nil else { return } + + self.memoryPressureReliefTask = Task.detached(priority: .utility) { [weak self] in + for delay in [Duration.seconds(2), .seconds(8), .seconds(20)] { + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + MemoryPressureRelief.releaseFreeMallocPages() + } + await MainActor.run { [weak self] in + self?.memoryPressureReliefTask = nil + } + } + } +} diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 2aaedfd8..8effa6c3 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -15,6 +15,7 @@ struct OpenAIWebRefreshPolicyContext { let accessEnabled: Bool let batterySaverEnabled: Bool let force: Bool + let refreshPhase: ProviderRefreshPhase } // MARK: - OpenAI web lifecycle @@ -51,6 +52,19 @@ extension UsageStore { afterCookieImport ? self.openAIWebPostImportFetchTimeout : self.openAIWebRetryFetchTimeout } + nonisolated static func refreshPhase( + hasCompletedInitialRefresh: Bool) -> ProviderRefreshPhase + { + hasCompletedInitialRefresh ? .regular : .startup + } + + nonisolated static func openAIWebRefreshPhase( + providerRefreshPhase: ProviderRefreshPhase, + startupConnectivityRetryAttempt: Int?) -> ProviderRefreshPhase + { + startupConnectivityRetryAttempt == nil ? providerRefreshPhase : .startup + } + private func openAIWebRefreshIntervalSeconds() -> TimeInterval { let base = max(self.settings.refreshFrequency.seconds ?? 0, 120) return base * Self.openAIWebRefreshMultiplier @@ -1324,6 +1338,7 @@ extension UsageStore { extension UsageStore { nonisolated static func shouldRunOpenAIWebRefresh(_ context: OpenAIWebRefreshPolicyContext) -> Bool { guard context.accessEnabled else { return false } + guard context.force || context.refreshPhase != .startup else { return false } return context.force || !context.batterySaverEnabled } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index bef03360..40173e71 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -217,6 +217,7 @@ final class UsageStore { @ObservationIgnored private var timerTask: Task? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? + @ObservationIgnored var memoryPressureReliefTask: Task? @ObservationIgnored var startupConnectivityRetryTask: Task? @ObservationIgnored var startupConnectivityRetryNeeded = false @ObservationIgnored var startupConnectivityRetryRefreshActive = false @@ -535,7 +536,10 @@ final class UsageStore { { guard !self.isRefreshing else { return } self.prepareRefreshState() - let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup + let refreshPhase = Self.refreshPhase(hasCompletedInitialRefresh: self.hasCompletedInitialRefresh) + let openAIWebRefreshPhase = Self.openAIWebRefreshPhase( + providerRefreshPhase: refreshPhase, + startupConnectivityRetryAttempt: startupConnectivityRetryAttempt) let allowsStartupConnectivityRetry = refreshPhase == .startup || startupConnectivityRetryAttempt != nil self.startupConnectivityRetryRefreshActive = allowsStartupConnectivityRetry self.startupConnectivityRetryNeeded = false @@ -590,7 +594,8 @@ final class UsageStore { self.settings.openAIWebAccessEnabled && self.settings.codexCookieSource.isEnabled, batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, - force: forceTokenUsage) + force: forceTokenUsage, + refreshPhase: openAIWebRefreshPhase) let shouldRefreshOpenAIWeb = Self.shouldRunOpenAIWebRefresh(refreshPolicy) self.openAIWebLogger.debug( "OpenAI web refresh gate", @@ -600,7 +605,7 @@ final class UsageStore { "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", "force": refreshPolicy.force ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", - "phase": refreshPhase == .startup ? "startup" : "regular", + "phase": openAIWebRefreshPhase == .startup ? "startup" : "regular", ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() @@ -624,6 +629,9 @@ final class UsageStore { if allowsStartupConnectivityRetry { self.completeStartupConnectivityRetryPass(currentAttempt: startupConnectivityRetryAttempt ?? 0) } + if refreshPhase == .startup { + self.scheduleMemoryPressureRelief() + } } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. @@ -704,12 +712,14 @@ final class UsageStore { if Task.isCancelled { break } await self.refreshTokenUsage(provider, force: force) } + self.scheduleMemoryPressureRelief() } deinit { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() + self.memoryPressureReliefTask?.cancel() self.startupConnectivityRetryTask?.cancel() self.storageRefreshTask?.cancel() self.codexPlanHistoryBackfillTask?.cancel() diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 405199c0..a9a935b4 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -101,11 +101,12 @@ public enum KeychainCacheStore { return } - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] + KeychainNoUIQuery.apply(to: &query) let updateStatus = SecItemUpdate( query as CFDictionary, @@ -140,20 +141,16 @@ public enum KeychainCacheStore { } guard self.canUseRealKeychain else { return false } #if os(macOS) - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] - let status = SecItemDelete(query as CFDictionary) - if status == errSecSuccess { - return true - } - if status != errSecItemNotFound { - self.log.error("Keychain cache delete failed (\(key.account)): \(status)") - } - #endif + KeychainNoUIQuery.apply(to: &query) + return self.clearResultForKeychainDeleteStatus(SecItemDelete(query as CFDictionary), key: key) + #else return false + #endif } public static func keys(category: String) -> [Key] { @@ -232,6 +229,10 @@ public enum KeychainCacheStore { self.canUseRealKeychain } + static var canEnumerateOrDeleteRealKeychainForTesting: Bool { + self.canUseRealKeychain + } + #if DEBUG && os(macOS) public static func withLoadFailureStatusOverrideForTesting( _ status: OSStatus?, @@ -314,6 +315,21 @@ public enum KeychainCacheStore { } } + static func clearResultForKeychainDeleteStatus(_ status: OSStatus, key: Key) -> Bool { + switch status { + case errSecSuccess: + return true + case errSecItemNotFound: + return false + case errSecInteractionNotAllowed: + self.log.info("Keychain cache delete temporarily unavailable (\(key.account))") + return false + default: + self.log.error("Keychain cache delete failed (\(key.account)): \(status)") + return false + } + } + static func trustedApplicationPathsForCacheAccess( bundleURL: URL = Bundle.main.bundleURL, executableURL: URL? = Bundle.main.executableURL, diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 4ce346e2..f6a16d50 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -5,6 +5,7 @@ import Testing #if os(macOS) import SweetCookieKit +@Suite(.serialized) struct BrowserDetectionTests { @Test func `safari always installed`() { @@ -103,27 +104,73 @@ struct BrowserDetectionTests { let start = Date(timeIntervalSince1970: 1000) var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.$current.withValue(.userInitiated) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .interactionRequired + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == false) + } + + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .allowed + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) + #expect( + BrowserCookieAccessGate.shouldAttempt( + .chrome, + now: start.addingTimeInterval((60 * 60 * 6) + 1)) == true) + } + } + } + + #expect(preflightCount == 2) + } + + @Test + func `background cookie import allows authorized chromium keychain sources`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 - return .interactionRequired + return .allowed } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == false) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true) + } } + } + + #expect(preflightCount == 1) + } + + @Test + func `background cookie import suppresses chromium keychain sources requiring interaction`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 - return .allowed + return .interactionRequired } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) - #expect( - BrowserCookieAccessGate.shouldAttempt( - .chrome, - now: start.addingTimeInterval((60 * 60 * 6) + 1)) == true) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true) + } } } - #expect(preflightCount == 2) + #expect(preflightCount == 1) } @Test diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift index e2ee1d4a..8e0fc39c 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift @@ -429,6 +429,17 @@ actor BlockingWidgetSnapshotSaver { } } + func waitUntilStartedWithin(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.snapshots.count < count { + if startedAt.duration(to: .now) >= timeout { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + func startedCount() -> Int { self.snapshots.count } @@ -443,3 +454,26 @@ actor BlockingWidgetSnapshotSaver { self.snapshots } } + +actor RecordingWidgetSnapshotSaver { + private var snapshots: [WidgetSnapshot] = [] + + func save(_ snapshot: WidgetSnapshot) { + self.snapshots.append(snapshot) + } + + func waitUntilSavedWithin(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.snapshots.count < count { + if startedAt.duration(to: .now) >= timeout { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + + func savedSnapshots() -> [WidgetSnapshot] { + self.snapshots + } +} diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 0114aa14..a5ed210f 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -30,6 +30,7 @@ struct CodexManagedOpenAIWebRefreshTests { lastAuthenticatedAt: 1) settings._test_activeManagedCodexAccount = managedAccount settings.codexActiveSource = .managedAccount(id: managedAccount.id) + settings.openAIWebAccessEnabled = false defer { settings._test_activeManagedCodexAccount = nil } let store = UsageStore( @@ -45,6 +46,10 @@ struct CodexManagedOpenAIWebRefreshTests { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refresh(forceTokenUsage: false) + settings.openAIWebAccessEnabled = true + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } @@ -212,6 +217,7 @@ struct CodexManagedOpenAIWebRefreshTests { await saver.resumeNext() let backgroundTask = try #require(store.creditsRefreshTask) + await creditsBlocker.waitUntilStarted(count: 1) await creditsBlocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) await backgroundTask.value await saver.waitUntilStarted(count: 2) @@ -249,6 +255,7 @@ struct CodexManagedOpenAIWebRefreshTests { lastAuthenticatedAt: 1) settings._test_activeManagedCodexAccount = managedAccount settings.codexActiveSource = .managedAccount(id: managedAccount.id) + settings.openAIWebAccessEnabled = false defer { settings._test_activeManagedCodexAccount = nil } let store = UsageStore( @@ -256,19 +263,24 @@ struct CodexManagedOpenAIWebRefreshTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, startupBehavior: .testing) - store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) - store.creditsRefreshTask = Task {} - store.creditsRefreshTaskKey = store.codexCreditsRefreshKey( - expectedGuard: store.currentCodexAccountScopedRefreshGuard()) let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() - let saver = BlockingWidgetSnapshotSaver() + let saver = RecordingWidgetSnapshotSaver() store._test_providerRefreshOverride = { _ in } defer { store._test_providerRefreshOverride = nil } store._test_codexCreditsLoaderOverride = { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refresh(forceTokenUsage: false) + await store.widgetSnapshotPersistTask?.value + settings.openAIWebAccessEnabled = true + store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) + store.creditsRefreshTask = Task {} + store.creditsRefreshTaskKey = store.codexCreditsRefreshKey( + expectedGuard: store.currentCodexAccountScopedRefreshGuard()) + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await dashboardBlocker.awaitResult() } @@ -283,34 +295,33 @@ struct CodexManagedOpenAIWebRefreshTests { } await refreshTask.value - await saver.waitUntilStarted(count: 1) + let didPersistInitialRefreshSnapshot = await saver.waitUntilSavedWithin(count: 1) + #expect(didPersistInitialRefreshSnapshot) let firstSnapshots = await saver.savedSnapshots() - let firstCodexEntry = try #require(firstSnapshots.first?.entries.first { $0.provider == .codex }) - #expect(firstCodexEntry.codeReviewRemainingPercent == nil) + #expect(firstSnapshots.first?.entries.first { $0.provider == .codex }?.codeReviewRemainingPercent == nil) - await saver.resumeNext() let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) - await dashboardBlocker.resumeNext(with: .success(OpenAIDashboardSnapshot( - signedInEmail: managedAccount.email, - codeReviewRemainingPercent: 95, - creditEvents: [], - dailyBreakdown: [], - usageBreakdown: [], - creditsPurchaseURL: nil, - creditsRemaining: 25, - accountPlan: "Pro", - updatedAt: Date()))) - await backgroundTask.value - await saver.waitUntilStarted(count: 2) - - #expect(await saver.startedCount() == 2) + let didStartDashboardRefresh = await dashboardBlocker.waitUntilStartedWithin(count: 1) + #expect(didStartDashboardRefresh) + if didStartDashboardRefresh { + await dashboardBlocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + await backgroundTask.value + } + let didPersistDashboardSnapshot = await saver.waitUntilSavedWithin(count: 2) + + #expect(didPersistDashboardSnapshot) let secondSnapshots = await saver.savedSnapshots() - let secondCodexEntry = try #require(secondSnapshots.last?.entries.first { $0.provider == .codex }) - #expect(secondCodexEntry.codeReviewRemainingPercent == 95) - - await saver.resumeNext() - await store.widgetSnapshotPersistTask?.value + #expect(secondSnapshots.count >= 2) } @Test diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 0e415239..de474a86 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -28,6 +28,16 @@ struct KeychainCacheStoreTests { } } + @Test + func `background interaction keeps real keychain cache available for no UI reads writes and deletes`() { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.$current.withValue(.background) { + #expect(KeychainCacheStore.canUseRealKeychainForTesting == true) + #expect(KeychainCacheStore.canEnumerateOrDeleteRealKeychainForTesting == true) + } + } + } + @Test func `stores and loads entry`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -146,6 +156,12 @@ struct KeychainCacheStoreTests { } } + @Test + func `delete interaction not allowed is non fatal`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + #expect(KeychainCacheStore.clearResultForKeychainDeleteStatus(errSecInteractionNotAllowed, key: key) == false) + } + @Test func `load failure override bypasses test store without affecting store or clear`() { KeychainCacheStore.setTestStoreForTesting(true) diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index 1ea58b00..e9a016db 100644 --- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -8,7 +8,8 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, - force: false)) + force: false, + refreshPhase: .regular)) #expect(shouldRun == false) } @@ -18,7 +19,8 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: false, - force: false)) + force: false, + refreshPhase: .regular)) #expect(shouldRun == true) } @@ -28,7 +30,48 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, - force: true)) + force: true, + refreshPhase: .regular)) + + #expect(shouldRun == true) + } + + @Test + func `Startup skips automatic OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: .startup)) + + #expect(shouldRun == false) + } + + @Test + func `Startup connectivity retry remains startup only for OpenAI web refresh gate`() { + let providerPhase = UsageStore.refreshPhase( + hasCompletedInitialRefresh: true) + let openAIWebPhase = UsageStore.openAIWebRefreshPhase( + providerRefreshPhase: providerPhase, + startupConnectivityRetryAttempt: 1) + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: openAIWebPhase)) + + #expect(providerPhase == .regular) + #expect(openAIWebPhase == .startup) + #expect(shouldRun == false) + } + + @Test + func `Manual startup refresh still forces OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: true, + refreshPhase: .startup)) #expect(shouldRun == true) } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 7e24bcdb..b00caa72 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -16,8 +16,10 @@ extension StatusMenuTests { 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 } @@ -50,6 +52,7 @@ extension StatusMenuTests { } #expect(providerRefreshCount == 1) + #expect(refreshInteractions == [.background]) #expect(!controller.deferredMenuInteractionRefreshPending) } @@ -295,7 +298,7 @@ extension StatusMenuTests { } @Test - func `codex parent menu open requests stale OpenAI web refresh with battery saver enabled`() async { + func `codex parent menu open defers stale OpenAI web refresh until tracking ends`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -316,6 +319,65 @@ extension StatusMenuTests { } defer { store._test_codexCreditsLoaderOverride = nil } let blocker = BlockingManagedOpenAIDashboardLoader() + var refreshInteractions: [ProviderInteraction] = [] + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + refreshInteractions.append(ProviderInteractionContext.current) + return try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = 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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(await blocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.menuDidClose(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + #expect(refreshInteractions == [.background]) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `programmatic parent menu close schedules deferred OpenAI web refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } @@ -331,10 +393,64 @@ extension StatusMenuTests { defer { controller.releaseStatusItemsForTesting() } controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.forgetClosedMenu(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `deferred OpenAI web refresh retries after active store refresh completes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store.isRefreshing = true + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + controller.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") + controller.scheduleDeferredMenuInteractionRefreshIfNeeded() + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await blocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + store.isRefreshing = false await blocker.waitUntilStarted(count: 1) #expect(await blocker.startedCount() == 1) @@ -344,7 +460,124 @@ extension StatusMenuTests { } @Test - func `codex parent menu open refreshes recent dashboard cache with no chart history`() async { + func `deferred OpenAI web refresh waits for deferred store refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + let providerBlocker = BlockingStatusMenuProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await providerBlocker.awaitRelease() + } + defer { store._test_providerRefreshOverride = nil } + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = 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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + await providerBlocker.waitUntilStarted() + #expect(await dashboardBlocker.startedCount() == 0) + + await providerBlocker.resumeNext() + await dashboardBlocker.waitUntilStarted(count: 1) + #expect(await dashboardBlocker.startedCount() == 1) + + await dashboardBlocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `reopened menu keeps dashboard refresh deferred after store refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + let providerBlocker = BlockingStatusMenuProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await providerBlocker.awaitRelease() + } + defer { store._test_providerRefreshOverride = nil } + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = 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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + await providerBlocker.waitUntilStarted() + + let reopenedMenu = controller.makeMenu() + controller.menuWillOpen(reopenedMenu) + await providerBlocker.resumeNext() + try? await Task.sleep(for: .milliseconds(50)) + #expect(await dashboardBlocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.menuDidClose(reopenedMenu) + await dashboardBlocker.waitUntilStarted(count: 1) + #expect(await dashboardBlocker.startedCount() == 1) + + await dashboardBlocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `codex parent menu close refreshes recent dashboard cache with no chart history`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -376,10 +609,18 @@ extension StatusMenuTests { defer { controller.releaseStatusItemsForTesting() } controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) + for _ in 0..<20 { + await Task.yield() + } + #expect(await blocker.startedCount() == 0) + + controller.menuDidClose(menu) await blocker.waitUntilStarted(count: 1) #expect(await blocker.startedCount() == 1) @@ -428,6 +669,7 @@ extension StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) + controller.menuDidClose(menu) try? await Task.sleep(for: .milliseconds(150)) #expect(await blocker.startedCount() == 0) @@ -847,3 +1089,37 @@ extension StatusMenuTests { loginMethod: "Plus Plan")) } } + +private actor BlockingStatusMenuProviderRefresh { + private var continuations: [CheckedContinuation] = [] + private var startWaiters: [CheckedContinuation] = [] + private var started = 0 + + func awaitRelease() async { + self.started += 1 + self.resumeStartWaiters() + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func waitUntilStarted() async { + if self.started > 0 { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func resumeNext() { + guard !self.continuations.isEmpty else { return } + self.continuations.removeFirst().resume() + } + + private func resumeStartWaiters() { + let waiters = self.startWaiters + self.startWaiters = [] + for waiter in waiters { + waiter.resume() + } + } +} From 07ee69b6d19a0a050ef836a34402c6c9454ec597 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 18:29:46 +0100 Subject: [PATCH 35/79] chore: finalize 0.32.1 changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b682d1..4a2391dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.32.1 — Unreleased +## 0.32.1 — 2026-05-31 ### Fixed - Claude: keep Claude CLI-owned OAuth refresh tokens delegated to Claude Code when CLI storage is present, preventing CodexBar from consuming rotating refresh tokens and forcing re-login (#1161, #1239). Thanks @RajvardhanPatil07! From 37bc49f756ba228897d5d6ff358c71e18fb55339 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 19:10:19 +0100 Subject: [PATCH 36/79] docs: update appcast for 0.32.1 --- appcast.xml | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/appcast.xml b/appcast.xml index 56ab2827..d90d3bb3 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,25 @@ CodexBar + + 0.32.1 + Sun, 31 May 2026 19:10:18 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 76 + 0.32.1 + 14.0 + CodexBar 0.32.1 +

Fixed

+
    +
  • Claude: keep Claude CLI-owned OAuth refresh tokens delegated to Claude Code when CLI storage is present, preventing CodexBar from consuming rotating refresh tokens and forcing re-login (#1161, #1239). Thanks @RajvardhanPatil07!
  • +
  • Menu bar: reuse short-lived Codex account reconciliation snapshots so repeated menu rebuilds do not reread local auth state on every open.
  • +
  • Menu bar: defer automatic provider refreshes until after AppKit menu tracking ends so opening the dropdown no longer starts work that can freeze focus and keyboard input.
  • +
  • Menu bar: suppress background keychain and OpenAI dashboard work during startup/menu tracking so the dropdown stays clickable without macOS keychain prompts or WebKit memory spikes.
  • +
+

View full changelog

+]]>
+ +
0.32.0 Sun, 31 May 2026 01:50:25 +0100 @@ -65,28 +84,6 @@ ]]> - - 0.30.1 - Thu, 28 May 2026 07:56:49 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 72 - 0.30.1 - 14.0 - CodexBar 0.30.1 -

Changed

-
    -
  • CLI: make codexbar diagnose use a generic safe provider diagnostic export for all providers, with MiniMax details attached only as provider-specific metadata.
  • -
-

Fixed

-
    -
  • Settings: add trailing breathing room to provider-sidebar controls (#1183). Thanks @Yuxin-Qiao!
  • -
  • Claude: treat OAuth usage HTTP 429s as rate limits, preserve cached credentials, and back off background retries while still allowing manual refresh (#1179). Thanks @LeoLin990405!
  • -
  • Menu bar: stop repeated display-change status-item recreation from corrupting Control Center or confusing menu bar managers (#1176, fixes #1175). Thanks @diazdesandi!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From af37ac80e121b5f020db204e99b0ab1a9e050b72 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 19:17:39 +0100 Subject: [PATCH 37/79] chore: start 0.32.2 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2391dd..49136f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.32.2 — Unreleased + ## 0.32.1 — 2026-05-31 ### Fixed diff --git a/version.env b/version.env index e5ff061b..e048223e 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.32.1 -BUILD_NUMBER=76 +MARKETING_VERSION=0.32.2 +BUILD_NUMBER=77 From 460975a5ceffc850f29afea4dc6e6053ff9f0f63 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 20:03:36 +0100 Subject: [PATCH 38/79] fix: improve compact menu card padding --- CHANGELOG.md | 3 ++ Sources/CodexBar/MenuCardView.swift | 52 ++++++++++++++----- .../StatusItemController+MenuTypes.swift | 2 +- .../CodexBar/StorageBreakdownMenuView.swift | 2 +- .../UsageMenuCardLayoutTests.swift | 43 +++++++++++++++ 5 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 Tests/CodexBarTests/UsageMenuCardLayoutTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 49136f1f..ea07f498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.2 — Unreleased +### 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. + ## 0.32.1 — 2026-05-31 ### Fixed diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3c16bb85..7601233d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -2,6 +2,15 @@ import AppKit import CodexBarCore import SwiftUI +enum UsageMenuCardLayout { + static let horizontalPadding: CGFloat = 20 + static let headerOnlyVerticalPadding: CGFloat = 7 + static let sectionTopPadding: CGFloat = 6 + static let sectionBottomPadding: CGFloat = 6 + static let headerLineSpacing: CGFloat = 4 + static let headerColumnSpacing: CGFloat = 12 +} + /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels. struct UsageMenuCardView: View { struct Model { @@ -220,9 +229,17 @@ struct UsageMenuCardView: View { .padding(.bottom, self.model.creditsText == nil ? 6 : 0) } } - .padding(.horizontal, 16) - .padding(.top, 2) - .padding(.bottom, 2) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding( + .top, + self.hasDetails + ? UsageMenuCardLayout.sectionTopPadding + : UsageMenuCardLayout.headerOnlyVerticalPadding) + .padding( + .bottom, + self.hasDetails + ? UsageMenuCardLayout.sectionBottomPadding + : UsageMenuCardLayout.headerOnlyVerticalPadding) .frame(width: self.width, alignment: .leading) } @@ -238,8 +255,8 @@ private struct UsageMenuCardHeaderView: View { @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { - VStack(alignment: .leading, spacing: 3) { - HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: UsageMenuCardLayout.headerLineSpacing) { + HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) { Text(self.model.providerName).font(.headline) .fontWeight(.semibold) .lineLimit(1).truncationMode(.tail).layoutPriority(1) @@ -249,7 +266,7 @@ private struct UsageMenuCardHeaderView: View { .lineLimit(1).truncationMode(.middle) } let subtitleAlignment: VerticalAlignment = self.model.subtitleStyle == .error ? .top : .firstTextBaseline - HStack(alignment: subtitleAlignment) { + HStack(alignment: subtitleAlignment, spacing: UsageMenuCardLayout.headerColumnSpacing) { Text(self.model.subtitleText) .font(.footnote) .foregroundStyle(self.subtitleColor) @@ -465,11 +482,20 @@ struct UsageMenuCardHeaderSectionView: View { Divider() } } - .padding(.horizontal, 16) - .padding(.top, 2) - .padding(.bottom, self.model.subtitleStyle == .error ? 2 : 0) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.top, UsageMenuCardLayout.headerOnlyVerticalPadding) + .padding(.bottom, self.headerBottomPadding) .frame(width: self.width, alignment: .leading) } + + private var headerBottomPadding: CGFloat { + if self.model.subtitleStyle == .error { + return UsageMenuCardLayout.sectionBottomPadding + } + return self.showDivider + ? UsageMenuCardLayout.sectionBottomPadding + : UsageMenuCardLayout.headerOnlyVerticalPadding + } } struct UsageMenuCardUsageSectionView: View { @@ -508,7 +534,7 @@ struct UsageMenuCardUsageSectionView: View { Divider() } } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, 10) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) @@ -535,7 +561,7 @@ struct UsageMenuCardCreditsSectionView: View { Divider() } } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) @@ -641,7 +667,7 @@ struct UsageMenuCardCostSectionView: View { } } } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) @@ -662,7 +688,7 @@ struct UsageMenuCardExtraUsageSectionView: View { ProviderCostContent( section: providerCost, progressColor: self.model.progressColor) - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 7c0f3b1b..03fc1785 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -43,7 +43,7 @@ struct OverviewMenuCardRowView: View { .lineLimit(1) Spacer() } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.hasUsageBlock ? 0 : 8) .padding(.bottom, 6) .frame(width: self.width, alignment: .leading) diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 276a5365..6e26c985 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -16,7 +16,7 @@ struct StorageMenuCardSectionView: View { Text(self.storageText) .font(.caption) } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift new file mode 100644 index 00000000..c51e1fd2 --- /dev/null +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -0,0 +1,43 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct UsageMenuCardLayoutTests { + @Test + func `header only menu card keeps comfortable padding`() { + let model = UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "steipete@gmail.com", + subtitleText: "Not fetched yet", + subtitleStyle: .info, + planText: "Pro 20x", + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: nil, + progressColor: .blue) + let width: CGFloat = 296 + + let headerSize = NSHostingController(rootView: UsageMenuCardHeaderSectionView( + model: model, + showDivider: false, + width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + let cardSize = NSHostingController(rootView: UsageMenuCardView(model: model, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + + #expect(headerSize.height >= 46) + #expect(cardSize.height >= 46) + } +} From db6eb87c0c9b2821ac8a64a5a04cbcb7a698055b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 00:09:58 +0100 Subject: [PATCH 39/79] chore: add live QA skill --- .agents/skills/qa-test/SKILL.md | 117 ++++++++++++++++ .agents/skills/qa-test/agents/openai.yaml | 4 + .../skills/qa-test/references/api-specs.md | 10 ++ .../qa-test/scripts/live_provider_matrix.sh | 125 ++++++++++++++++++ CHANGELOG.md | 3 + 5 files changed, 259 insertions(+) create mode 100644 .agents/skills/qa-test/SKILL.md create mode 100644 .agents/skills/qa-test/agents/openai.yaml create mode 100644 .agents/skills/qa-test/references/api-specs.md create mode 100755 .agents/skills/qa-test/scripts/live_provider_matrix.sh diff --git a/.agents/skills/qa-test/SKILL.md b/.agents/skills/qa-test/SKILL.md new file mode 100644 index 00000000..f76cd06d --- /dev/null +++ b/.agents/skills/qa-test/SKILL.md @@ -0,0 +1,117 @@ +--- +name: qa-test +description: "CodexBar live QA/e2e testing: run provider usage matrix checks, validate real app config, use Peekaboo for menu proof, use Browser Use/official docs for API spec or logged-in dashboard checks, and handle 1Password credentials safely." +--- + +# CodexBar Live QA + +Use for live provider testing, release smoke tests, menu verification, or debugging “provider works/fails” reports. + +## Rules + +- Work from the CodexBar repo checkout. +- Use the packaged CLI first: `CodexBar.app/Contents/Helpers/CodexBarCLI`. +- Do not use `CodexBar.app/Contents/MacOS/codexbar`; that is the app binary and may appear to hang as a CLI. +- Never run broad `env`, `set`, or secret regex dumps. +- Use `$one-password` for secrets: all `op` commands inside one persistent tmux session, service account first, no raw secret output. +- Treat browser-cookie/keychain flows as prompt-risky. Prefer CLI/API-token checks and `KeychainNoUIQuery`-safe tests unless the user explicitly requested live UI. +- For current API behavior, browse official provider docs only. + +## CLI Matrix + +Run the bundled script: + +```bash +.agents/skills/qa-test/scripts/live_provider_matrix.sh --enabled +``` + +Useful modes: + +```bash +.agents/skills/qa-test/scripts/live_provider_matrix.sh --provider all +.agents/skills/qa-test/scripts/live_provider_matrix.sh --providers openai,zai,deepseek +.agents/skills/qa-test/scripts/live_provider_matrix.sh --default +``` + +Interpretation: + +- `--enabled` reads `~/.codexbar/config.json` and tests only enabled providers one by one. +- `--default` runs the app-facing default command with no provider override. +- `--provider all` forces every registered provider and is expected to fail for providers without sessions/keys. +- A green app config needs `--enabled` and `--default` clean; `--provider all` is a discovery/triage tool. + +## Config QA + +Validate config: + +```bash +CodexBar.app/Contents/Helpers/CodexBarCLI config validate +stat -f '%Lp %N' "$HOME/.codexbar/config.json" +``` + +Redact config shape: + +```bash +jq '(.providers // []) |= map(.apiKey = (if .apiKey then "" else .apiKey end) | + .secretKey = (if .secretKey then "" else .secretKey end) | + .cookieHeader = (if .cookieHeader then "" else .cookieHeader end) | + .tokenAccounts = (if .tokenAccounts then (.tokenAccounts | .accounts = (.accounts | map(.token = ""))) else .tokenAccounts end))' \ + "$HOME/.codexbar/config.json" +``` + +Before editing config, make a backup: + +```bash +cp "$HOME/.codexbar/config.json" "$HOME/.codexbar/config.pre-qa-$(date +%Y%m%d%H%M%S).json" +chmod 600 "$HOME/.codexbar"/config.pre-qa-*.json +``` + +## Live Menu QA + +Use Peekaboo after CLI checks: + +```bash +pkill -x CodexBar || pkill -f 'CodexBar.app/Contents/MacOS/CodexBar' || true +open -n "$PWD/CodexBar.app" +peekaboo menu list-all --json | rg -i 'codexbar' +peekaboo menu click-extra --title codexbar-merged --json +screencapture -x /tmp/codexbar-live-menu.png +``` + +Crop top-right menu if needed: + +```bash +sips --cropToHeightWidth 900 340 --cropOffset 20 2650 /tmp/codexbar-live-menu.png \ + --out /tmp/codexbar-live-menu-crop.png >/dev/null +``` + +Verify visually with `view_image`. Confirm provider tabs/rows match enabled config and no failing provider dominates the first screen. + +## Browser Use + +Use `$browser-use` only when a logged-in dashboard, API key page, or provider docs need browser/profile state. + +Existing Chrome path: + +```bash +mcporter call chrome-devtools.list_pages --args '{}' --output text +mcporter call chrome-devtools.navigate_page --args '{"url":"https://provider.example"}' --output text +mcporter call chrome-devtools.take_snapshot --args '{}' --output text +``` + +If Browser Use is unavailable, say so and use web search for public official docs; do not substitute isolated Playwright for login/profile-dependent pages. + +## Fix Triage + +- Missing auth/session: configure key/session if available; otherwise leave provider disabled or report blocked auth. +- Wrong provider API/spec: inspect official docs, then patch fetcher/settings/tests. +- Provider key exists but live API rejects it: keep key stored if useful, disable provider if the menu would show a persistent error. +- User-facing behavior changes need `CHANGELOG.md`. +- Code fixes need focused tests, `make check`, `$autoreview`, and live CLI proof before landing. + +## Known CodexBar QA Notes + +- OpenAI Admin API key is the useful usage provider key. Project `OPENAI_API_KEY` values can fail legacy credit-balance fallback with 403. +- Deepgram usage requires a key/project with Management API permissions; transcription-only keys can return 403. +- Groq usage uses the Prometheus metrics API, not ordinary inference endpoints. +- MiniMax pay-as-you-go API keys and Token Plan/Coding Plan keys are different; wrong key kind can leave usage unavailable. diff --git a/.agents/skills/qa-test/agents/openai.yaml b/.agents/skills/qa-test/agents/openai.yaml new file mode 100644 index 00000000..3bf7a7b2 --- /dev/null +++ b/.agents/skills/qa-test/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CodexBar QA Test" + short_description: "Run live CodexBar CLI and menu QA safely." + default_prompt: "Run CodexBar live QA with CLI, Peekaboo, browser docs, and 1Password-safe credential checks." diff --git a/.agents/skills/qa-test/references/api-specs.md b/.agents/skills/qa-test/references/api-specs.md new file mode 100644 index 00000000..8bb7298c --- /dev/null +++ b/.agents/skills/qa-test/references/api-specs.md @@ -0,0 +1,10 @@ +# API Spec Pointers + +Use current official docs for provider API behavior. Prefer these searches/pages before patching fetchers: + +- MiniMax: `https://platform.minimax.io/docs/llms.txt`; key types differ between pay-as-you-go API keys and Token Plan/Coding Plan keys. +- Deepgram: `https://developers.deepgram.com/llms.txt`; usage/project APIs require Management permissions and project-scoped keys. +- Groq: `https://console.groq.com/docs/prometheus-metrics`; usage metrics use `https://api.groq.com/v1/metrics/prometheus`. +- LLM Proxy/LiteLLM: `https://docs.litellm.ai/`; CodexBar expects an LLM-API-Key-Proxy compatible `/v1/quota-stats` endpoint plus base URL. + +When citing docs in a user-facing answer, browse the current page and include source links. diff --git a/.agents/skills/qa-test/scripts/live_provider_matrix.sh b/.agents/skills/qa-test/scripts/live_provider_matrix.sh new file mode 100755 index 00000000..f43bb0ee --- /dev/null +++ b/.agents/skills/qa-test/scripts/live_provider_matrix.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}" +TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v gtimeout || command -v timeout || true)}" +WEB_TIMEOUT="${CODEXBAR_QA_WEB_TIMEOUT:-12}" +CASE_TIMEOUT="${CODEXBAR_QA_CASE_TIMEOUT:-60}" + +usage() { + cat <<'USAGE' +Usage: + live_provider_matrix.sh --enabled + live_provider_matrix.sh --default + live_provider_matrix.sh --provider all + live_provider_matrix.sh --providers openai,zai,deepseek + +Environment: + CODEXBAR_CLI=/path/to/CodexBarCLI + CODEXBAR_QA_WEB_TIMEOUT=12 + CODEXBAR_QA_CASE_TIMEOUT=60 +USAGE +} + +if [[ ! -x "$CLI" ]]; then + echo "missing CodexBarCLI at $CLI" >&2 + exit 2 +fi +if [[ -z "$TIMEOUT_BIN" ]]; then + echo "missing timeout command (install coreutils for gtimeout)" >&2 + exit 2 +fi + +mode="${1:-}" +shift || true + +providers=() +case "$mode" in + --enabled) + if [[ ! -f "$HOME/.codexbar/config.json" ]]; then + echo "missing ~/.codexbar/config.json" >&2 + exit 2 + fi + mapfile -t providers < <(jq -r '.providers[] | select(.enabled == true) | .id' "$HOME/.codexbar/config.json") + ;; + --default) + providers=("__default__") + ;; + --provider) + providers=("${1:-}") + ;; + --providers) + IFS=',' read -r -a providers <<< "${1:-}" + ;; + -h|--help|"") + usage + exit 0 + ;; + *) + echo "unknown mode: $mode" >&2 + usage >&2 + exit 2 + ;; +esac + +redact_node=' +const redact = s => String(s || "") + .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/g, "") + .replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-REDACTED") + .replace(/gsk_[A-Za-z0-9_-]{12,}/g, "gsk_REDACTED") + .replace(/[A-Za-z0-9_-]{32,}/g, m => /[A-Za-z]/.test(m) && /[0-9]/.test(m) ? "" : m); +' + +run_one() { + local name="$1" + shift + local out err start end elapsed st + out="$(mktemp)" + err="$(mktemp)" + start="$(date +%s)" + "$TIMEOUT_BIN" "$CASE_TIMEOUT" "$CLI" usage "$@" --format json --json-only --web-timeout "$WEB_TIMEOUT" >"$out" 2>"$err" + st=$? + end="$(date +%s)" + elapsed=$((end - start)) + node - "$name" "$st" "$elapsed" "$out" "$err" < Date: Mon, 1 Jun 2026 00:18:25 +0100 Subject: [PATCH 40/79] fix: harden live QA skill --- .agents/skills/qa-test/SKILL.md | 1 + .../qa-test/scripts/live_provider_matrix.sh | 33 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.agents/skills/qa-test/SKILL.md b/.agents/skills/qa-test/SKILL.md index f76cd06d..2c0ce465 100644 --- a/.agents/skills/qa-test/SKILL.md +++ b/.agents/skills/qa-test/SKILL.md @@ -55,6 +55,7 @@ Redact config shape: jq '(.providers // []) |= map(.apiKey = (if .apiKey then "" else .apiKey end) | .secretKey = (if .secretKey then "" else .secretKey end) | .cookieHeader = (if .cookieHeader then "" else .cookieHeader end) | + (if .id == "stepfun" and has("region") then .region = "" else . end) | .tokenAccounts = (if .tokenAccounts then (.tokenAccounts | .accounts = (.accounts | map(.token = ""))) else .tokenAccounts end))' \ "$HOME/.codexbar/config.json" ``` diff --git a/.agents/skills/qa-test/scripts/live_provider_matrix.sh b/.agents/skills/qa-test/scripts/live_provider_matrix.sh index f43bb0ee..bf75cf92 100755 --- a/.agents/skills/qa-test/scripts/live_provider_matrix.sh +++ b/.agents/skills/qa-test/scripts/live_provider_matrix.sh @@ -41,15 +41,40 @@ case "$mode" in echo "missing ~/.codexbar/config.json" >&2 exit 2 fi - mapfile -t providers < <(jq -r '.providers[] | select(.enabled == true) | .id' "$HOME/.codexbar/config.json") + if ! command -v jq >/dev/null 2>&1; then + echo "missing jq" >&2 + exit 2 + fi + provider_list="$(mktemp)" + if ! jq -r '(.providers // [])[] | select(.enabled == true) | .id' "$HOME/.codexbar/config.json" >"$provider_list"; then + rm -f "$provider_list" + echo "failed to parse ~/.codexbar/config.json" >&2 + exit 2 + fi + while IFS= read -r provider; do + [[ -n "$provider" ]] && providers+=("$provider") + done <"$provider_list" + rm -f "$provider_list" + if [[ "${#providers[@]}" -eq 0 ]]; then + echo "no enabled providers found in ~/.codexbar/config.json" >&2 + exit 2 + fi ;; --default) providers=("__default__") ;; --provider) + if [[ -z "${1:-}" ]]; then + echo "missing provider" >&2 + exit 2 + fi providers=("${1:-}") ;; --providers) + if [[ -z "${1:-}" ]]; then + echo "missing providers" >&2 + exit 2 + fi IFS=',' read -r -a providers <<< "${1:-}" ;; -h|--help|"") @@ -112,8 +137,10 @@ NODE } overall=0 +ran=0 for provider in "${providers[@]}"; do [[ -z "$provider" ]] && continue + ran=$((ran + 1)) if [[ "$provider" == "__default__" ]]; then run_one default || overall=1 elif [[ "$provider" == "all" ]]; then @@ -122,4 +149,8 @@ for provider in "${providers[@]}"; do run_one "$provider" --provider "$provider" || overall=1 fi done +if [[ "$ran" -eq 0 ]]; then + echo "no provider cases ran" >&2 + exit 2 +fi exit "$overall" From 03827ebcc3bfb0ecbd792409c491a8a1b0802a9f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 00:21:08 +0100 Subject: [PATCH 41/79] fix: fail QA matrix on parser errors --- .../qa-test/scripts/live_provider_matrix.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.agents/skills/qa-test/scripts/live_provider_matrix.sh b/.agents/skills/qa-test/scripts/live_provider_matrix.sh index bf75cf92..a3d8c2ce 100755 --- a/.agents/skills/qa-test/scripts/live_provider_matrix.sh +++ b/.agents/skills/qa-test/scripts/live_provider_matrix.sh @@ -30,6 +30,10 @@ if [[ -z "$TIMEOUT_BIN" ]]; then echo "missing timeout command (install coreutils for gtimeout)" >&2 exit 2 fi +if ! command -v node >/dev/null 2>&1; then + echo "missing node" >&2 + exit 2 +fi mode="${1:-}" shift || true @@ -99,7 +103,7 @@ const redact = s => String(s || "") run_one() { local name="$1" shift - local out err start end elapsed st + local out err start end elapsed st node_status out="$(mktemp)" err="$(mktemp)" start="$(date +%s)" @@ -114,6 +118,7 @@ const [name, st, elapsed, outPath, errPath] = process.argv.slice(2); const raw = fs.readFileSync(outPath, "utf8").trim(); const err = fs.readFileSync(errPath, "utf8").trim(); let rows = []; +let formatterFailed = false; try { const payload = raw ? JSON.parse(raw) : []; const arr = Array.isArray(payload) ? payload : [payload]; @@ -127,12 +132,21 @@ try { ); } } catch (error) { + formatterFailed = true; rows.push(\`\${name}:parse-fail:error=\${redact(error.message)} stdout=\${redact(raw).slice(0, 200)} stderr=\${redact(err).slice(0, 200)}\`); } -if (!rows.length) rows.push(\`\${name}:empty:stderr=\${redact(err).slice(0, 200)}\`); +if (!rows.length) { + formatterFailed = true; + rows.push(\`\${name}:empty:stderr=\${redact(err).slice(0, 200)}\`); +} console.log(\`TEST \${name} exit=\${st} elapsed=\${elapsed}s :: \${rows.join(" | ")}\`); +if (formatterFailed) process.exit(1); NODE + node_status=$? rm -f "$out" "$err" + if [[ "$node_status" -ne 0 ]]; then + return 1 + fi return "$st" } From 1e03bca1a570964dcaa1e437ef98f37c0226f9fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 00:26:26 +0100 Subject: [PATCH 42/79] fix: align QA provider discovery --- .agents/skills/qa-test/SKILL.md | 2 +- .../qa-test/scripts/live_provider_matrix.sh | 37 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.agents/skills/qa-test/SKILL.md b/.agents/skills/qa-test/SKILL.md index 2c0ce465..81c752a1 100644 --- a/.agents/skills/qa-test/SKILL.md +++ b/.agents/skills/qa-test/SKILL.md @@ -35,7 +35,7 @@ Useful modes: Interpretation: -- `--enabled` reads `~/.codexbar/config.json` and tests only enabled providers one by one. +- `--enabled` asks `CodexBarCLI config providers` for enabled providers, honoring `CODEXBAR_CONFIG` and default toggles. - `--default` runs the app-facing default command with no provider override. - `--provider all` forces every registered provider and is expected to fail for providers without sessions/keys. - A green app config needs `--enabled` and `--default` clean; `--provider all` is a discovery/triage tool. diff --git a/.agents/skills/qa-test/scripts/live_provider_matrix.sh b/.agents/skills/qa-test/scripts/live_provider_matrix.sh index a3d8c2ce..0c8240ce 100755 --- a/.agents/skills/qa-test/scripts/live_provider_matrix.sh +++ b/.agents/skills/qa-test/scripts/live_provider_matrix.sh @@ -17,6 +17,7 @@ Usage: Environment: CODEXBAR_CLI=/path/to/CodexBarCLI + CODEXBAR_CONFIG=/path/to/config.json CODEXBAR_QA_WEB_TIMEOUT=12 CODEXBAR_QA_CASE_TIMEOUT=60 USAGE @@ -41,26 +42,38 @@ shift || true providers=() case "$mode" in --enabled) - if [[ ! -f "$HOME/.codexbar/config.json" ]]; then - echo "missing ~/.codexbar/config.json" >&2 - exit 2 - fi - if ! command -v jq >/dev/null 2>&1; then - echo "missing jq" >&2 + provider_status="$(mktemp)" + provider_err="$(mktemp)" + provider_list="$(mktemp)" + if ! "$CLI" config providers --format json --json-only >"$provider_status" 2>"$provider_err"; then + rm -f "$provider_status" "$provider_err" "$provider_list" + echo "failed to list providers via CodexBarCLI config providers" >&2 exit 2 fi - provider_list="$(mktemp)" - if ! jq -r '(.providers // [])[] | select(.enabled == true) | .id' "$HOME/.codexbar/config.json" >"$provider_list"; then - rm -f "$provider_list" - echo "failed to parse ~/.codexbar/config.json" >&2 + if ! node - "$provider_status" >"$provider_list" <<'NODE'; then +const fs = require("fs"); +const path = process.argv[2]; +const raw = fs.readFileSync(path, "utf8").trim(); +const payload = JSON.parse(raw); +if (!Array.isArray(payload)) { + throw new Error("config providers output is not an array"); +} +for (const item of payload) { + if (item && item.enabled === true && typeof item.provider === "string" && item.provider) { + console.log(item.provider); + } +} +NODE + rm -f "$provider_status" "$provider_err" "$provider_list" + echo "failed to parse CodexBarCLI config providers output" >&2 exit 2 fi while IFS= read -r provider; do [[ -n "$provider" ]] && providers+=("$provider") done <"$provider_list" - rm -f "$provider_list" + rm -f "$provider_status" "$provider_err" "$provider_list" if [[ "${#providers[@]}" -eq 0 ]]; then - echo "no enabled providers found in ~/.codexbar/config.json" >&2 + echo "no enabled providers found via CodexBarCLI config providers" >&2 exit 2 fi ;; From 0dc51f9f43c5db33b30621974f9526ab75b6617c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 01:04:47 +0100 Subject: [PATCH 43/79] perf: cap automatic codex token scans --- CHANGELOG.md | 1 + Sources/CodexBar/UsageStore+TokenCost.swift | 31 ++++ Sources/CodexBar/UsageStore.swift | 34 +--- Sources/CodexBarCore/CostUsageFetcher.swift | 8 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 98 +++++++++++ .../CostUsageFetcherScanBudgetTests.swift | 156 ++++++++++++++++++ 7 files changed, 298 insertions(+), 32 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c16da6f7..57c00240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### 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: skip automatic cold Codex token-cost scans when the local session corpus exceeds 1 GB, while keeping manual refresh available for full scans. ## 0.32.1 — 2026-05-31 diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 5e1550ce..5a04a281 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -2,6 +2,8 @@ import CodexBarCore import Foundation extension UsageStore { + nonisolated static let automaticCodexTokenScanByteLimit: Int64 = 1_000_000_000 + func tokenSnapshot(for provider: UsageProvider) -> CostUsageTokenSnapshot? { self.tokenSnapshots[provider] } @@ -93,6 +95,35 @@ extension UsageStore { .appendingPathComponent("cost-usage", isDirectory: true) } + func clearCostUsageCache() async -> String? { + let errorMessage: String? = await Task.detached(priority: .utility) { + let fm = FileManager.default + let cacheDirs = [ + Self.costUsageCacheDirectory(fileManager: fm), + ] + + for cacheDir in cacheDirs { + do { + try fm.removeItem(at: cacheDir) + } catch let error as NSError { + if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { continue } + return error.localizedDescription + } + } + return nil + }.value + + guard errorMessage == nil else { return errorMessage } + + self.tokenSnapshots.removeAll() + self.tokenErrors.removeAll() + self.lastTokenFetchAt.removeAll() + self.lastTokenFetchScope.removeAll() + self.tokenFailureGates[.codex]?.reset() + self.tokenFailureGates[.claude]?.reset() + return nil + } + nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage() } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 40173e71..96beda52 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1477,35 +1477,6 @@ extension UsageStore { } } - func clearCostUsageCache() async -> String? { - let errorMessage: String? = await Task.detached(priority: .utility) { - let fm = FileManager.default - let cacheDirs = [ - Self.costUsageCacheDirectory(fileManager: fm), - ] - - for cacheDir in cacheDirs { - do { - try fm.removeItem(at: cacheDir) - } catch let error as NSError { - if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { continue } - return error.localizedDescription - } - } - return nil - }.value - - guard errorMessage == nil else { return errorMessage } - - self.tokenSnapshots.removeAll() - self.tokenErrors.removeAll() - self.lastTokenFetchAt.removeAll() - self.lastTokenFetchScope.removeAll() - self.tokenFailureGates[.codex]?.reset() - self.tokenFailureGates[.claude]?.reset() - return nil - } - private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { self.tokenSnapshots.removeValue(forKey: provider) @@ -1600,7 +1571,10 @@ extension UsageStore { forceRefresh: force, allowVertexClaudeFallback: !self.isEnabled(.claude), codexHomePath: costScope.codexHomePath, - historyDays: historyDays) + historyDays: historyDays, + automaticCodexScanByteLimit: provider == .codex && !force + ? Self.automaticCodexTokenScanByteLimit + : nil) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2611e81a..ffad3d01 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -48,7 +48,8 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, - refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot + refreshPricingInBackground: Bool = true, + automaticCodexScanByteLimit: Int64? = nil) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -59,6 +60,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: codexHomePath, historyDays: historyDays, refreshPricingInBackground: refreshPricingInBackground, + automaticCodexScanByteLimit: automaticCodexScanByteLimit, scannerOptions: self.scannerOptionsOverride()) } @@ -75,6 +77,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, refreshPricingInBackground: Bool = true, + automaticCodexScanByteLimit: Int64? = nil, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil) async throws -> CostUsageTokenSnapshot @@ -122,6 +125,9 @@ public struct CostUsageFetcher: Sendable { } if forceRefresh { options.refreshMinIntervalSeconds = 0 + options.codexRefreshScanByteLimit = nil + } else if provider == .codex, let automaticCodexScanByteLimit { + options.codexRefreshScanByteLimit = automaticCodexScanByteLimit } let checkCancellation: CostUsageScanner.CancellationCheck = { try Task.checkCancellation() diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d57477c7..9ec2231b 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 = "c55f8a5a2d69092d" + static let value = "8da238e8fc549ded" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 6484fa6e..efe117b0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -24,6 +24,7 @@ enum CostUsageScanner { var claudeProjectsRoots: [URL]? var cacheRoot: URL? var codexTraceDatabaseURL: URL? + var codexRefreshScanByteLimit: Int64? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -34,6 +35,7 @@ enum CostUsageScanner { claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, codexTraceDatabaseURL: URL? = nil, + codexRefreshScanByteLimit: Int64? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { @@ -41,11 +43,26 @@ enum CostUsageScanner { self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot self.codexTraceDatabaseURL = codexTraceDatabaseURL + self.codexRefreshScanByteLimit = codexRefreshScanByteLimit self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan } } + struct CodexScanBudgetExceeded: LocalizedError, Equatable { + let bytes: Int64 + let limit: Int64 + + var errorDescription: String? { + "Codex token cost refresh skipped because \(Self.byteText(self.bytes)) of session logs exceeds the " + + "\(Self.byteText(self.limit)) automatic scan limit. Use manual refresh to scan it." + } + + private static func byteText(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + } + struct CodexParseResult { let days: [String: [String: [Int]]] var parsedBytes: Int64 @@ -1728,6 +1745,81 @@ enum CostUsageScanner { shouldRefresh: shouldRefresh) } + private static func shouldEnforceCodexScanBudget( + cache: CostUsageCache, + plan: CodexRefreshPlan, + options: Options) -> Bool + { + options.forceRescan || + cache.files.isEmpty || + plan.rootsChanged || + plan.windowExpanded || + plan.pricingChanged || + plan.priorityMetadataChanged || + plan.needsTurnIDCacheMigration + } + + private static func totalCodexScanBytes( + files: [URL], + roots: [URL], + checkCancellation: CancellationCheck?) throws -> Int64 + { + var total: Int64 = 0 + var countedPaths = Set() + + func countFile(_ fileURL: URL) throws { + try checkCancellation?() + guard countedPaths.insert(fileURL.path).inserted else { return } + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values?.isRegularFile != false else { return } + let size = Int64(values?.fileSize ?? 0) + let sum = total.addingReportingOverflow(size) + total = sum.overflow ? Int64.max : sum.partialValue + } + + for fileURL in files { + try countFile(fileURL) + } + + for root in roots { + try checkCancellation?() + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { continue } + + while let fileURL = enumerator.nextObject() as? URL { + try checkCancellation?() + guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } + try countFile(fileURL) + } + } + + return total + } + + private static func enforceCodexScanBudgetIfNeeded( + files: [URL], + cache: CostUsageCache, + plan: CodexRefreshPlan, + options: Options, + checkCancellation: CancellationCheck?) throws + { + guard let scanByteLimit = options.codexRefreshScanByteLimit, + scanByteLimit > 0, + shouldEnforceCodexScanBudget(cache: cache, plan: plan, options: options) + else { return } + + let scanBytes = try Self.totalCodexScanBytes( + files: files, + roots: plan.roots, + checkCancellation: checkCancellation) + if scanBytes > scanByteLimit { + throw CodexScanBudgetExceeded(bytes: scanBytes, limit: scanByteLimit) + } + } + private static func loadCodexDaily( range: CostUsageDayRange, now: Date, @@ -1786,6 +1878,12 @@ enum CostUsageScanner { } let filePathsInScan = Set(files.map(\.path)) + try Self.enforceCodexScanBudgetIfNeeded( + files: files, + cache: cache, + plan: plan, + options: options, + checkCancellation: checkCancellation) var scanState = CodexScanState() let fileIndex = CodexSessionFileIndex( diff --git a/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift b/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift new file mode 100644 index 00000000..200614c9 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherScanBudgetTests { + @Test + func `automatic codex scan budget skips oversized cold cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + _ = try self.writeCodexSessionFile(env: env, day: day, tokens: 100) + + let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + await #expect(throws: CostUsageScanner.CodexScanBudgetExceeded.self) { + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: env.codexHomeRoot.path, + automaticCodexScanByteLimit: 1, + scannerOptions: options) + } + + let skippedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(skippedCache.files.isEmpty) + + let forced = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + forceRefresh: true, + codexHomePath: env.codexHomeRoot.path, + automaticCodexScanByteLimit: 1, + scannerOptions: options) + #expect(forced.sessionTokens == 100) + } + + @Test + func `automatic codex scan budget counts fork parent root lookup files`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let childDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let parentTimestamp = env.isoString(for: parentDay.addingTimeInterval(1)) + _ = try self.writeCodexTotalSessionFile( + env: env, + day: parentDay, + filename: "parent.jsonl", + sessionID: "parent-session", + tokens: 100) + let childURL = try self.writeCodexTotalSessionFile( + env: env, + day: childDay, + filename: "child.jsonl", + sessionID: "child-session", + forkedFromID: "parent-session", + forkTimestamp: parentTimestamp, + tokens: 125, + output: 5) + let childBytes = try Int64(childURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0) + + let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + await #expect(throws: CostUsageScanner.CodexScanBudgetExceeded.self) { + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: childDay, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + automaticCodexScanByteLimit: childBytes, + scannerOptions: options) + } + + let forced = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: childDay, + forceRefresh: true, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + automaticCodexScanByteLimit: childBytes, + scannerOptions: options) + #expect(forced.sessionTokens == 30) + } + + private func writeCodexSessionFile( + env: CostUsageTestEnvironment, + day: Date, + tokens: Int) throws + -> URL + { + try env.writeCodexSessionFile(day: day, filename: "large-enough.jsonl", contents: env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": "openai/gpt-5.4"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": "openai/gpt-5.4", + ], + ], + ], + ])) + } + + private func writeCodexTotalSessionFile( + env: CostUsageTestEnvironment, + day: Date, + filename: String, + sessionID: String, + forkedFromID: String? = nil, + forkTimestamp: String? = nil, + tokens: Int, + output: Int = 0) throws + -> URL + { + var sessionPayload: [String: Any] = ["session_id": sessionID] + if let forkedFromID { + sessionPayload["forked_from_id"] = forkedFromID + } + if let forkTimestamp { + sessionPayload["timestamp"] = forkTimestamp + } + + return try env.writeCodexSessionFile(day: day, filename: filename, contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": sessionPayload, + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "model": "openai/gpt-5.4", + "total_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": output, + ], + ], + ], + ], + ])) + } +} From b54a9e6fa6626beca415d62e582447b352d82414 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 01:18:20 +0100 Subject: [PATCH 44/79] fix: enforce codex scan budget on refresh --- CHANGELOG.md | 2 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 17 +--------- .../CostUsageFetcherScanBudgetTests.swift | 33 ++++++++++++++++++- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c00240..799b1cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### 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: skip automatic cold Codex token-cost scans when the local session corpus exceeds 1 GB, while keeping manual refresh available for full scans. +- Performance: skip automatic Codex token-cost scans when the local session corpus exceeds 1 GB, while keeping manual refresh available for full scans. ## 0.32.1 — 2026-05-31 diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 9ec2231b..ad0c05bb 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 = "8da238e8fc549ded" + static let value = "3b060a685774cd0c" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index efe117b0..2809c750 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1745,20 +1745,6 @@ enum CostUsageScanner { shouldRefresh: shouldRefresh) } - private static func shouldEnforceCodexScanBudget( - cache: CostUsageCache, - plan: CodexRefreshPlan, - options: Options) -> Bool - { - options.forceRescan || - cache.files.isEmpty || - plan.rootsChanged || - plan.windowExpanded || - plan.pricingChanged || - plan.priorityMetadataChanged || - plan.needsTurnIDCacheMigration - } - private static func totalCodexScanBytes( files: [URL], roots: [URL], @@ -1807,8 +1793,7 @@ enum CostUsageScanner { checkCancellation: CancellationCheck?) throws { guard let scanByteLimit = options.codexRefreshScanByteLimit, - scanByteLimit > 0, - shouldEnforceCodexScanBudget(cache: cache, plan: plan, options: options) + scanByteLimit > 0 else { return } let scanBytes = try Self.totalCodexScanBytes( diff --git a/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift b/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift index 200614c9..f3ee6764 100644 --- a/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift @@ -81,13 +81,44 @@ struct CostUsageFetcherScanBudgetTests { #expect(forced.sessionTokens == 30) } + @Test + func `automatic codex scan budget skips oversized incremental refresh`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let initialURL = try self.writeCodexSessionFile(env: env, day: day, filename: "initial.jsonl", tokens: 100) + var options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let initial = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: env.codexHomeRoot.path, + scannerOptions: options) + #expect(initial.sessionTokens == 100) + + let initialBytes = try Int64(initialURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0) + _ = try self.writeCodexSessionFile(env: env, day: day, filename: "new.jsonl", tokens: 50) + + await #expect(throws: CostUsageScanner.CodexScanBudgetExceeded.self) { + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day.addingTimeInterval(90), + codexHomePath: env.codexHomeRoot.path, + automaticCodexScanByteLimit: initialBytes, + scannerOptions: options) + } + } + private func writeCodexSessionFile( env: CostUsageTestEnvironment, day: Date, + filename: String = "large-enough.jsonl", tokens: Int) throws -> URL { - try env.writeCodexSessionFile(day: day, filename: "large-enough.jsonl", contents: env.jsonl([ + try env.writeCodexSessionFile(day: day, filename: filename, contents: env.jsonl([ [ "type": "turn_context", "timestamp": env.isoString(for: day), From 917fc7229f10ec587e2430a557bb1b0675ba25c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 03:12:12 +0100 Subject: [PATCH 45/79] perf: speed up codex token scanning --- CHANGELOG.md | 2 +- Sources/CodexBar/UsageStore+TokenCost.swift | 2 - Sources/CodexBar/UsageStore.swift | 5 +- Sources/CodexBarCore/CostUsageFetcher.swift | 31 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageJsonl.swift | 44 +- .../CostUsageScanner+CodexFastJSON.swift | 255 ++++++ ...ostUsageScanner+CodexTruncatedPrefix.swift | 39 +- .../Vendored/CostUsage/CostUsageScanner.swift | 725 ++++++++++++++---- .../CostUsageFetcherScanBudgetTests.swift | 187 ----- .../CodexBarTests/CostUsageScannerTests.swift | 26 + 11 files changed, 942 insertions(+), 376 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift delete mode 100644 Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 799b1cfa..60d5fc94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### 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: skip automatic Codex token-cost scans when the local session corpus exceeds 1 GB, while keeping manual refresh available for full scans. +- Performance: make Codex token-cost scanning faster and more memory-efficient on large local session corpora. ## 0.32.1 — 2026-05-31 diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 5a04a281..44d2e4e6 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -2,8 +2,6 @@ import CodexBarCore import Foundation extension UsageStore { - nonisolated static let automaticCodexTokenScanByteLimit: Int64 = 1_000_000_000 - func tokenSnapshot(for provider: UsageProvider) -> CostUsageTokenSnapshot? { self.tokenSnapshots[provider] } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 96beda52..1e8d5ebf 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1571,10 +1571,7 @@ extension UsageStore { forceRefresh: force, allowVertexClaudeFallback: !self.isEnabled(.claude), codexHomePath: costScope.codexHomePath, - historyDays: historyDays, - automaticCodexScanByteLimit: provider == .codex && !force - ? Self.automaticCodexTokenScanByteLimit - : nil) + historyDays: historyDays) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index ffad3d01..0dd1a79d 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -48,8 +48,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, - refreshPricingInBackground: Bool = true, - automaticCodexScanByteLimit: Int64? = nil) async throws -> CostUsageTokenSnapshot + refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -60,10 +59,32 @@ public struct CostUsageFetcher: Sendable { codexHomePath: codexHomePath, historyDays: historyDays, refreshPricingInBackground: refreshPricingInBackground, - automaticCodexScanByteLimit: automaticCodexScanByteLimit, scannerOptions: self.scannerOptionsOverride()) } + @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") + public func loadTokenSnapshot( + provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + forceRefresh: Bool = false, + allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil, + historyDays: Int = 30, + refreshPricingInBackground: Bool = true, + automaticCodexScanByteLimit _: Int64?) async throws -> CostUsageTokenSnapshot + { + try await self.loadTokenSnapshot( + provider: provider, + environment: environment, + now: now, + forceRefresh: forceRefresh, + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath, + historyDays: historyDays, + refreshPricingInBackground: refreshPricingInBackground) + } + private func scannerOptionsOverride() -> CostUsageScanner.Options? { self.scannerOptions } @@ -77,7 +98,6 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, refreshPricingInBackground: Bool = true, - automaticCodexScanByteLimit: Int64? = nil, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil) async throws -> CostUsageTokenSnapshot @@ -125,9 +145,6 @@ public struct CostUsageFetcher: Sendable { } if forceRefresh { options.refreshMinIntervalSeconds = 0 - options.codexRefreshScanByteLimit = nil - } else if provider == .codex, let automaticCodexScanByteLimit { - options.codexRefreshScanByteLimit = automaticCodexScanByteLimit } let checkCancellation: CostUsageScanner.CancellationCheck = { try Task.checkCancellation() diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index ad0c05bb..1aacbced 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 = "3b060a685774cd0c" + static let value = "8ff0c1544161ed2a" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index 7e13a918..35f58b44 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -73,30 +73,34 @@ enum CostUsageJsonl { while true { try checkCancellation?() - let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() - if chunk.isEmpty { - flushLine() - break - } + let reachedEOF = try autoreleasepool { + let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() + if chunk.isEmpty { + flushLine() + return true + } - try checkCancellation?() - bytesRead += Int64(chunk.count) - chunk.withUnsafeBytes { rawBuffer in - guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } - var segmentStart = 0 - var index = 0 - while index < rawBuffer.count { - if base[index] == 0x0A { - appendSegment(base.advanced(by: segmentStart), count: index - segmentStart) - flushLine() - segmentStart = index + 1 + try checkCancellation?() + bytesRead += Int64(chunk.count) + chunk.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var segmentStart = 0 + var index = 0 + while index < rawBuffer.count { + if base[index] == 0x0A { + appendSegment(base.advanced(by: segmentStart), count: index - segmentStart) + flushLine() + segmentStart = index + 1 + } + index += 1 + } + if segmentStart < rawBuffer.count { + appendSegment(base.advanced(by: segmentStart), count: rawBuffer.count - segmentStart) } - index += 1 - } - if segmentStart < rawBuffer.count { - appendSegment(base.advanced(by: segmentStart), count: rawBuffer.count - segmentStart) } + return false } + if reachedEOF { break } try checkCancellation?() } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift new file mode 100644 index 00000000..958e4509 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift @@ -0,0 +1,255 @@ +import Foundation + +extension CostUsageScanner { + static func extractJSONByteStringField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> String? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + guard let parsed = parseJSONByteStringRange(in: bytes, index: &valueIndex, limit: range.upperBound), + parsed.range.lowerBound < parsed.range.upperBound + else { return nil } + if parsed.hasEscapes { + return self.decodeEscapedJSONByteString(from: bytes, in: parsed.range) + } + return String(bytes: bytes[parsed.range], encoding: .utf8) + } + } + + static func extractJSONByteObjectField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> Range? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + self.parseJSONByteObjectRange(in: bytes, index: &valueIndex, limit: range.upperBound) + } + } + + static func extractJSONByteIntField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> Int? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + self.parseJSONByteInt(in: bytes, index: &valueIndex, limit: range.upperBound) + } + } + + private static func extractJSONByteField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int, + parseValue: (inout Int) -> T?) -> T? + { + var index = range.lowerBound + var depth = 0 + + while index < range.upperBound { + switch bytes[index] { + case 0x7B: // { + depth += 1 + index += 1 + case 0x7D: // } + depth -= 1 + index += 1 + case 0x22: // " + var valueIndex = index + guard let key = parseJSONByteStringRange(in: bytes, index: &valueIndex, limit: range.upperBound) + else { return nil } + index = valueIndex + guard depth == targetDepth, + !key.hasEscapes, + self.byteRange(bytes, key.range, equals: field) + else { continue } + + self.skipJSONByteWhitespace(in: bytes, index: &valueIndex, limit: range.upperBound) + guard valueIndex < range.upperBound, bytes[valueIndex] == 0x3A else { continue } // : + + valueIndex += 1 + self.skipJSONByteWhitespace(in: bytes, index: &valueIndex, limit: range.upperBound) + if let value = parseValue(&valueIndex) { + return value + } + default: + index += 1 + } + } + + return nil + } + + private static func parseJSONByteStringRange( + in bytes: UnsafeBufferPointer, + index: inout Int, + limit: Int) -> (range: Range, hasEscapes: Bool)? + { + guard index < limit, bytes[index] == 0x22 else { return nil } // " + index += 1 + let start = index + var hasEscapes = false + + while index < limit { + switch bytes[index] { + case 0x5C: // \ + hasEscapes = true + index += 2 + case 0x22: // " + let end = index + index += 1 + return (start.., + index: inout Int, + limit: Int) -> Range? + { + guard index < limit, bytes[index] == 0x7B else { return nil } // { + let start = index + var depth = 0 + + while index < limit { + switch bytes[index] { + case 0x22: // " + guard self.parseJSONByteStringRange(in: bytes, index: &index, limit: limit) != nil else { + return nil + } + case 0x7B: // { + depth += 1 + index += 1 + case 0x7D: // } + depth -= 1 + index += 1 + if depth == 0 { + return start.., + index: inout Int, + limit: Int) -> Int? + { + var sign = 1 + if index < limit, bytes[index] == 0x2D { // - + sign = -1 + index += 1 + } + + var value = 0 + var sawDigit = false + while index < limit { + let byte = bytes[index] + guard byte >= 0x30, byte <= 0x39 else { break } + sawDigit = true + let digit = Int(byte - 0x30) + let multiplied = value.multipliedReportingOverflow(by: 10) + if multiplied.overflow { return nil } + let added = multiplied.partialValue.addingReportingOverflow(digit) + if added.overflow { return nil } + value = added.partialValue + index += 1 + } + return sawDigit ? (sign == -1 ? -value : value) : nil + } + + private static func skipJSONByteWhitespace( + in bytes: UnsafeBufferPointer, + index: inout Int, + limit: Int) + { + while index < limit { + switch bytes[index] { + case 0x20, 0x09, 0x0A, 0x0D: + index += 1 + default: + return + } + } + } + + private static func decodeEscapedJSONByteString( + from bytes: UnsafeBufferPointer, + in range: Range) -> String? + { + var out: [UInt8] = [] + out.reserveCapacity(range.count) + var index = range.lowerBound + while index < range.upperBound { + let byte = bytes[index] + guard byte == 0x5C else { // \ + out.append(byte) + index += 1 + continue + } + + index += 1 + guard index < range.upperBound else { return nil } + switch bytes[index] { + case 0x22, 0x5C, 0x2F: // ", \, / + out.append(bytes[index]) + case 0x62: // b + out.append(0x08) + case 0x66: // f + out.append(0x0C) + case 0x6E: // n + out.append(0x0A) + case 0x72: // r + out.append(0x0D) + case 0x74: // t + out.append(0x09) + case 0x75: // u + return self.decodeJSONStringViaFoundation(from: bytes, in: range) + default: + return nil + } + index += 1 + } + + return String(bytes: out, encoding: .utf8) + } + + private static func decodeJSONStringViaFoundation( + from bytes: UnsafeBufferPointer, + in range: Range) -> String? + { + var data = Data([0x22]) + data.append(UnsafeBufferPointer(rebasing: bytes[range])) + data.append(0x22) + return (try? JSONSerialization.jsonObject(with: data)) as? String + } + + private static func byteRange( + _ bytes: UnsafeBufferPointer, + _ range: Range, + equals field: [UInt8]) -> Bool + { + guard range.count == field.count else { return false } + var index = range.lowerBound + var fieldIndex = 0 + while index < range.upperBound { + guard bytes[index] == field[fieldIndex] else { return false } + index += 1 + fieldIndex += 1 + } + return true + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift index 2c2f7554..dadb0903 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift @@ -17,7 +17,7 @@ extension CostUsageScanner { ?? Self.extractJSONStringField("model_name", from: infoText, atDepth: 1) } - private static func truncatedUTF8String(from bytes: Data) -> String? { + static func truncatedUTF8String(from bytes: Data) -> String? { for dropCount in 0...min(4, bytes.count) { let end = bytes.count - dropCount if let text = String(bytes: bytes.prefix(end), encoding: .utf8) { @@ -27,7 +27,7 @@ extension CostUsageScanner { return nil } - private static func extractJSONStringField( + static func extractJSONStringField( _ field: String, from text: Substring, atDepth targetDepth: Int) -> String? @@ -39,7 +39,7 @@ extension CostUsageScanner { } } - private static func extractJSONObjectField( + static func extractJSONObjectField( _ field: String, from text: Substring, atDepth targetDepth: Int) -> Substring? @@ -50,7 +50,17 @@ extension CostUsageScanner { } } - private static func extractJSONField( + static func extractJSONIntField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int) -> Int? + { + self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in + Self.parseJSONInt(in: text, index: &index) + } + } + + static func extractJSONField( _ field: String, from text: Substring, atDepth targetDepth: Int, @@ -89,7 +99,7 @@ extension CostUsageScanner { return nil } - private static func parseJSONString(in text: Substring, index: inout String.Index) -> String? { + static func parseJSONString(in text: Substring, index: inout String.Index) -> String? { guard index < text.endIndex, text[index] == "\"" else { return nil } text.formIndex(after: &index) var value = "" @@ -113,7 +123,24 @@ extension CostUsageScanner { return nil } - private static func skipJSONWhitespace(in text: Substring, index: inout String.Index) { + static func parseJSONInt(in text: Substring, index: inout String.Index) -> Int? { + var sign = 1 + if index < text.endIndex, text[index] == "-" { + sign = -1 + text.formIndex(after: &index) + } + + var value = 0 + var sawDigit = false + while index < text.endIndex, let digit = text[index].wholeNumberValue { + sawDigit = true + value = (value * 10) + digit + text.formIndex(after: &index) + } + return sawDigit ? value * sign : nil + } + + static func skipJSONWhitespace(in text: Substring, index: inout String.Index) { while index < text.endIndex, text[index].isWhitespace { text.formIndex(after: &index) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 2809c750..ca6a285c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -24,7 +24,6 @@ enum CostUsageScanner { var claudeProjectsRoots: [URL]? var cacheRoot: URL? var codexTraceDatabaseURL: URL? - var codexRefreshScanByteLimit: Int64? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -35,7 +34,6 @@ enum CostUsageScanner { claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, codexTraceDatabaseURL: URL? = nil, - codexRefreshScanByteLimit: Int64? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { @@ -43,26 +41,11 @@ enum CostUsageScanner { self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot self.codexTraceDatabaseURL = codexTraceDatabaseURL - self.codexRefreshScanByteLimit = codexRefreshScanByteLimit self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan } } - struct CodexScanBudgetExceeded: LocalizedError, Equatable { - let bytes: Int64 - let limit: Int64 - - var errorDescription: String? { - "Codex token cost refresh skipped because \(Self.byteText(self.bytes)) of session logs exceeds the " + - "\(Self.byteText(self.limit)) automatic scan limit. Use manual refresh to scan it." - } - - private static func byteText(_ bytes: Int64) -> String { - ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) - } - } - struct CodexParseResult { let days: [String: [String: [Int]]] var parsedBytes: Int64 @@ -953,6 +936,43 @@ enum CostUsageScanner { let forkTimestamp: String? } + private struct CodexTokenCountRecord { + let timestamp: String + let model: String? + let turnID: String? + let last: CostUsageCodexTotals? + let total: CostUsageCodexTotals? + } + + private enum CodexFastLine { + case sessionMeta(CodexSessionMetadata) + case turnContext(model: String?) + case taskStarted(turnID: String?) + case tokenCount(CodexTokenCountRecord) + } + + private static let codexJSONFieldCachedInputTokens = Array("cached_input_tokens".utf8) + private static let codexJSONFieldCacheReadInputTokens = Array("cache_read_input_tokens".utf8) + private static let codexJSONFieldForkedFromId = Array("forked_from_id".utf8) + private static let codexJSONFieldForkedFromIdCamel = Array("forkedFromId".utf8) + private static let codexJSONFieldId = Array("id".utf8) + private static let codexJSONFieldInfo = Array("info".utf8) + private static let codexJSONFieldInputTokens = Array("input_tokens".utf8) + private static let codexJSONFieldLastTokenUsage = Array("last_token_usage".utf8) + private static let codexJSONFieldModel = Array("model".utf8) + private static let codexJSONFieldModelName = Array("model_name".utf8) + private static let codexJSONFieldOutputTokens = Array("output_tokens".utf8) + private static let codexJSONFieldParentSessionId = Array("parent_session_id".utf8) + private static let codexJSONFieldParentSessionIdCamel = Array("parentSessionId".utf8) + private static let codexJSONFieldPayload = Array("payload".utf8) + private static let codexJSONFieldSessionId = Array("session_id".utf8) + private static let codexJSONFieldSessionIdCamel = Array("sessionId".utf8) + private static let codexJSONFieldTimestamp = Array("timestamp".utf8) + private static let codexJSONFieldTotalTokenUsage = Array("total_token_usage".utf8) + private static let codexJSONFieldTurnId = Array("turn_id".utf8) + private static let codexJSONFieldTurnIdCamel = Array("turnId".utf8) + private static let codexJSONFieldType = Array("type".utf8) + private static func codexForkParentId(from payload: [String: Any]?) -> String? { guard let payload else { return nil } for key in ["forked_from_id", "forkedFromId", "parent_session_id", "parentSessionId"] { @@ -965,6 +985,236 @@ enum CostUsageScanner { return nil } + private static func codexForkParentId( + from bytes: UnsafeBufferPointer, + in payloadRange: Range) -> String? + { + for key in [ + self.codexJSONFieldForkedFromId, + self.codexJSONFieldForkedFromIdCamel, + self.codexJSONFieldParentSessionId, + self.codexJSONFieldParentSessionIdCamel, + ] { + guard let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { continue } + return value + } + return nil + } + + private static func codexTurnID(from bytes: UnsafeBufferPointer, in payloadRange: Range) -> String? { + for key in [self.codexJSONFieldTurnId, self.codexJSONFieldTurnIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1), !value.isEmpty { + return value + } + } + if let infoRange = extractJSONByteObjectField(codexJSONFieldInfo, from: bytes, in: payloadRange, atDepth: 1) { + for key in [self.codexJSONFieldTurnId, self.codexJSONFieldTurnIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: infoRange, atDepth: 1), !value.isEmpty { + return value + } + } + } + return nil + } + + private static func codexSessionId( + from bytes: UnsafeBufferPointer, + in rootRange: Range, + payloadRange: Range?) -> String? + { + if let payloadRange { + for key in [self.codexJSONFieldSessionId, self.codexJSONFieldSessionIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1), + !value.isEmpty + { + return value + } + } + } + for key in [Self.codexJSONFieldSessionId, Self.codexJSONFieldSessionIdCamel, Self.codexJSONFieldId] { + if let value = Self.extractJSONByteStringField(key, from: bytes, in: rootRange, atDepth: 1), + !value.isEmpty + { + return value + } + } + return nil + } + + private static func codexTotals( + from bytes: UnsafeBufferPointer, + in objectRange: Range?) -> CostUsageCodexTotals? + { + guard let objectRange else { return nil } + let input = max( + 0, + Self.extractJSONByteIntField(Self.codexJSONFieldInputTokens, from: bytes, in: objectRange, atDepth: 1) ?? 0) + let cached = max( + 0, + Self.extractJSONByteIntField(Self.codexJSONFieldCachedInputTokens, from: bytes, in: objectRange, atDepth: 1) + ?? Self.extractJSONByteIntField( + Self.codexJSONFieldCacheReadInputTokens, + from: bytes, + in: objectRange, + atDepth: 1) + ?? 0) + let output = max( + 0, + Self + .extractJSONByteIntField(Self.codexJSONFieldOutputTokens, from: bytes, in: objectRange, atDepth: 1) ?? + 0) + return CostUsageCodexTotals(input: input, cached: cached, output: output) + } + + private static func parseCodexFastLine(_ bytes: Data) -> CodexFastLine? { + bytes.withUnsafeBytes { rawBytes in + let rawBuffer = rawBytes.bindMemory(to: UInt8.self) + guard !rawBuffer.isEmpty else { return nil } + let objectRange = 0.. String? @@ -992,6 +1242,9 @@ enum CostUsageScanner { func parseSessionMetadata(from lineData: Data) -> CodexSessionMetadata? { guard !lineData.isEmpty else { return nil } + if case let .sessionMeta(metadata) = Self.parseCodexFastLine(lineData) { + return metadata + } return autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] else { return nil } @@ -1062,6 +1315,62 @@ enum CostUsageScanner { return date } + func appendSnapshot(timestamp: String, last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) { + if let last { + let rawDelta = last + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + var countedDelta = rawDelta + + if let total { + let rawTotals = total + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: rawTotals) + if Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: rawTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + countedDelta = totalDelta + } + let next = Self.codexAddTotals(base, countedDelta) + previousTotals = next + rawTotalsBaseline = rawTotals + if !Self.codexTotalsEqual(rawTotals, next) { + sawDivergentTotals = true + } + } else { + let next = Self.codexAddTotals(base, countedDelta) + previousTotals = next + rawTotalsBaseline = next + } + + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: previousTotals ?? base)) + } else if let total { + let next = total + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: next) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: next) + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let countedTotals = Self.codexAddTotals(base, delta) + previousTotals = countedTotals + rawTotalsBaseline = next + if !Self.codexTotalsEqual(next, countedTotals) { + sawDivergentTotals = true + } + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: countedTotals)) + } + } + do { _ = try CostUsageJsonl.scan( fileURL: fileURL, @@ -1070,6 +1379,20 @@ enum CostUsageScanner { checkCancellation: checkCancellation, onLine: { line in guard !line.bytes.isEmpty, !line.wasTruncated else { return } + if let fastLine = Self.parseCodexFastLine(line.bytes) { + switch fastLine { + case let .sessionMeta(metadata): + if sessionId == nil { + sessionId = metadata.sessionId + } + case let .tokenCount(record): + appendSnapshot(timestamp: record.timestamp, last: record.last, total: record.total) + case .turnContext, .taskStarted: + break + } + return + } + autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] else { return } @@ -1098,71 +1421,19 @@ enum CostUsageScanner { return 0 } - let total = info["total_token_usage"] as? [String: Any] - let last = info["last_token_usage"] as? [String: Any] - - if let last { - let rawDelta = CostUsageCodexTotals( - input: max(0, toInt(last["input_tokens"])), - cached: max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])), - output: max(0, toInt(last["output_tokens"]))) - let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) - var countedDelta = rawDelta - - if let total { - let rawTotals = CostUsageCodexTotals( - input: toInt(total["input_tokens"]), - cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), - output: toInt(total["output_tokens"])) - let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: rawTotals) - if Self.codexShouldPreferTotalDelta( - rawBaseline: rawTotalsBaseline, - currentTotal: rawTotals, - totalDelta: totalDelta, - lastDelta: rawDelta, - sawDivergentTotals: sawDivergentTotals) - { - countedDelta = totalDelta - } - let next = Self.codexAddTotals(base, countedDelta) - previousTotals = next - rawTotalsBaseline = rawTotals - if !Self.codexTotalsEqual(rawTotals, next) { - sawDivergentTotals = true - } - } else { - let next = Self.codexAddTotals(base, countedDelta) - previousTotals = next - rawTotalsBaseline = next - } - - snapshots.append(CodexTimestampedTotals( - timestamp: timestamp, - date: parsedSnapshotDate(timestamp: timestamp), - totals: previousTotals ?? base)) - } else if let total { - let next = CostUsageCodexTotals( - input: toInt(total["input_tokens"]), - cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), - output: toInt(total["output_tokens"])) - let delta = sawDivergentTotals - ? Self.codexDivergentTotalDelta( - rawBaseline: rawTotalsBaseline, - countedBaseline: previousTotals, - current: next) - : Self.codexTotalDelta(from: rawTotalsBaseline, to: next) - let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) - let countedTotals = Self.codexAddTotals(base, delta) - previousTotals = countedTotals - rawTotalsBaseline = next - if !Self.codexTotalsEqual(next, countedTotals) { - sawDivergentTotals = true - } - snapshots.append(CodexTimestampedTotals( - timestamp: timestamp, - date: parsedSnapshotDate(timestamp: timestamp), - totals: countedTotals)) + let total = (info["total_token_usage"] as? [String: Any]).map { + CostUsageCodexTotals( + input: toInt($0["input_tokens"]), + cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), + output: toInt($0["output_tokens"])) + } + let last = (info["last_token_usage"] as? [String: Any]).map { + CostUsageCodexTotals( + input: max(0, toInt($0["input_tokens"])), + cached: max(0, toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"])), + output: max(0, toInt($0["output_tokens"]))) } + appendSnapshot(timestamp: timestamp, last: last, total: total) } }) } catch is CancellationError { @@ -1274,6 +1545,222 @@ enum CostUsageScanner { } } + func handleSessionMetadata(_ metadata: CodexSessionMetadata) throws { + if sessionId == nil { + sessionId = metadata.sessionId + } + if forkedFromId == nil { + forkedFromId = metadata.forkedFromId + } + if let forkedFromId { + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: metadata.forkTimestamp ?? "") + } + } + + // swiftlint:disable:next function_body_length + func handleTokenCount(_ record: CodexTokenCountRecord) throws { + guard let dayKey = Self.dayKeyFromTimestamp(record.timestamp) ?? Self.dayKeyFromParsedISO(record.timestamp) + else { return } + + let model = currentModel ?? record.model ?? "gpt-5" + let total = record.total + let last = record.last + + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 + + func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { + guard var remaining = remainingInheritedTotals else { return rawDelta } + + let adjusted = CostUsageCodexTotals( + input: max(0, rawDelta.input - remaining.input), + cached: max(0, rawDelta.cached - remaining.cached), + output: max(0, rawDelta.output - remaining.output)) + + remaining.input = max(0, remaining.input - rawDelta.input) + remaining.cached = max(0, remaining.cached - rawDelta.cached) + remaining.output = max(0, remaining.output - rawDelta.output) + remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, + remaining.output == 0 + { + nil + } else { + remaining + } + + return adjusted + } + + let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil + if hasUnresolvedForkBaseline, let total { + let currentRawTotals = total + defer { + unresolvedForkTotalWatermark = currentRawTotals + } + guard let last, + let watermark = unresolvedForkTotalWatermark + else { + return + } + + let rawLastDelta = last + let rawTotalDelta = Self.codexTotalDelta(from: watermark, to: currentRawTotals) + let adjustedDelta = Self.codexMinTotals(rawLastDelta, rawTotalDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, adjustedDelta) + rawTotalsBaseline = previousTotals + } + + if !handledUnresolvedForkTotal, + let total, + forkedFromId != nil, + !hasUnresolvedForkBaseline + { + let rawTotals = total + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal, let last { + let rawDelta = last + let hadRemainingInheritedTotals = remainingInheritedTotals != nil + var adjustedDelta = adjustedLastDelta(rawDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + + if let total, !hasUnresolvedForkBaseline { + let rawTotals = total + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + if !hadRemainingInheritedTotals, + Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: currentTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + adjustedDelta = totalDelta + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + remainingInheritedTotals = nil + } + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(currentTotals, countedTotals) { + sawDivergentTotals = true + } + } else { + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = countedTotals + } + } else if !handledUnresolvedForkTotal, let total { + let rawTotals = total + + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal { + return + } + + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } + let cachedClamp = min(deltaCached, deltaInput) + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + turnID: record.turnID ?? currentTurnID, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput)) + } + } + + func handleFastLine(_ fastLine: CodexFastLine) throws { + switch fastLine { + case let .sessionMeta(metadata): + try handleSessionMetadata(metadata) + case let .turnContext(model): + if let model { + currentModel = model + } + case let .taskStarted(turnID): + currentTurnID = turnID + case let .tokenCount(record): + try handleTokenCount(record) + } + } + let maxLineBytes = 256 * 1024 let prefixBytes = maxLineBytes @@ -1324,6 +1811,15 @@ enum CostUsageScanner { return } + if let fastLine = Self.parseCodexFastLine(line.bytes) { + do { + try handleFastLine(fastLine) + } catch { + deferredError = error + } + return + } + autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], @@ -1745,66 +2241,6 @@ enum CostUsageScanner { shouldRefresh: shouldRefresh) } - private static func totalCodexScanBytes( - files: [URL], - roots: [URL], - checkCancellation: CancellationCheck?) throws -> Int64 - { - var total: Int64 = 0 - var countedPaths = Set() - - func countFile(_ fileURL: URL) throws { - try checkCancellation?() - guard countedPaths.insert(fileURL.path).inserted else { return } - let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values?.isRegularFile != false else { return } - let size = Int64(values?.fileSize ?? 0) - let sum = total.addingReportingOverflow(size) - total = sum.overflow ? Int64.max : sum.partialValue - } - - for fileURL in files { - try countFile(fileURL) - } - - for root in roots { - try checkCancellation?() - guard let enumerator = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], - options: [.skipsHiddenFiles, .skipsPackageDescendants]) - else { continue } - - while let fileURL = enumerator.nextObject() as? URL { - try checkCancellation?() - guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } - try countFile(fileURL) - } - } - - return total - } - - private static func enforceCodexScanBudgetIfNeeded( - files: [URL], - cache: CostUsageCache, - plan: CodexRefreshPlan, - options: Options, - checkCancellation: CancellationCheck?) throws - { - guard let scanByteLimit = options.codexRefreshScanByteLimit, - scanByteLimit > 0 - else { return } - - let scanBytes = try Self.totalCodexScanBytes( - files: files, - roots: plan.roots, - checkCancellation: checkCancellation) - if scanBytes > scanByteLimit { - throw CodexScanBudgetExceeded(bytes: scanBytes, limit: scanByteLimit) - } - } - private static func loadCodexDaily( range: CostUsageDayRange, now: Date, @@ -1863,13 +2299,6 @@ enum CostUsageScanner { } let filePathsInScan = Set(files.map(\.path)) - try Self.enforceCodexScanBudgetIfNeeded( - files: files, - cache: cache, - plan: plan, - options: options, - checkCancellation: checkCancellation) - var scanState = CodexScanState() let fileIndex = CodexSessionFileIndex( files: files, diff --git a/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift b/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift deleted file mode 100644 index f3ee6764..00000000 --- a/Tests/CodexBarTests/CostUsageFetcherScanBudgetTests.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -struct CostUsageFetcherScanBudgetTests { - @Test - func `automatic codex scan budget skips oversized cold cache`() async throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) - _ = try self.writeCodexSessionFile(env: env, day: day, tokens: 100) - - let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) - await #expect(throws: CostUsageScanner.CodexScanBudgetExceeded.self) { - _ = try await CostUsageFetcher.loadTokenSnapshot( - provider: .codex, - now: day, - codexHomePath: env.codexHomeRoot.path, - automaticCodexScanByteLimit: 1, - scannerOptions: options) - } - - let skippedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) - #expect(skippedCache.files.isEmpty) - - let forced = try await CostUsageFetcher.loadTokenSnapshot( - provider: .codex, - now: day, - forceRefresh: true, - codexHomePath: env.codexHomeRoot.path, - automaticCodexScanByteLimit: 1, - scannerOptions: options) - #expect(forced.sessionTokens == 100) - } - - @Test - func `automatic codex scan budget counts fork parent root lookup files`() async throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let parentDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) - let childDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8) - let parentTimestamp = env.isoString(for: parentDay.addingTimeInterval(1)) - _ = try self.writeCodexTotalSessionFile( - env: env, - day: parentDay, - filename: "parent.jsonl", - sessionID: "parent-session", - tokens: 100) - let childURL = try self.writeCodexTotalSessionFile( - env: env, - day: childDay, - filename: "child.jsonl", - sessionID: "child-session", - forkedFromID: "parent-session", - forkTimestamp: parentTimestamp, - tokens: 125, - output: 5) - let childBytes = try Int64(childURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0) - - let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) - await #expect(throws: CostUsageScanner.CodexScanBudgetExceeded.self) { - _ = try await CostUsageFetcher.loadTokenSnapshot( - provider: .codex, - now: childDay, - codexHomePath: env.codexHomeRoot.path, - historyDays: 1, - automaticCodexScanByteLimit: childBytes, - scannerOptions: options) - } - - let forced = try await CostUsageFetcher.loadTokenSnapshot( - provider: .codex, - now: childDay, - forceRefresh: true, - codexHomePath: env.codexHomeRoot.path, - historyDays: 1, - automaticCodexScanByteLimit: childBytes, - scannerOptions: options) - #expect(forced.sessionTokens == 30) - } - - @Test - func `automatic codex scan budget skips oversized incremental refresh`() async throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) - let initialURL = try self.writeCodexSessionFile(env: env, day: day, filename: "initial.jsonl", tokens: 100) - var options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) - options.refreshMinIntervalSeconds = 0 - - let initial = try await CostUsageFetcher.loadTokenSnapshot( - provider: .codex, - now: day, - codexHomePath: env.codexHomeRoot.path, - scannerOptions: options) - #expect(initial.sessionTokens == 100) - - let initialBytes = try Int64(initialURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0) - _ = try self.writeCodexSessionFile(env: env, day: day, filename: "new.jsonl", tokens: 50) - - await #expect(throws: CostUsageScanner.CodexScanBudgetExceeded.self) { - _ = try await CostUsageFetcher.loadTokenSnapshot( - provider: .codex, - now: day.addingTimeInterval(90), - codexHomePath: env.codexHomeRoot.path, - automaticCodexScanByteLimit: initialBytes, - scannerOptions: options) - } - } - - private func writeCodexSessionFile( - env: CostUsageTestEnvironment, - day: Date, - filename: String = "large-enough.jsonl", - tokens: Int) throws - -> URL - { - try env.writeCodexSessionFile(day: day, filename: filename, contents: env.jsonl([ - [ - "type": "turn_context", - "timestamp": env.isoString(for: day), - "payload": ["model": "openai/gpt-5.4"], - ], - [ - "type": "event_msg", - "timestamp": env.isoString(for: day.addingTimeInterval(1)), - "payload": [ - "type": "token_count", - "info": [ - "last_token_usage": [ - "input_tokens": tokens, - "cached_input_tokens": 0, - "output_tokens": 0, - ], - "model": "openai/gpt-5.4", - ], - ], - ], - ])) - } - - private func writeCodexTotalSessionFile( - env: CostUsageTestEnvironment, - day: Date, - filename: String, - sessionID: String, - forkedFromID: String? = nil, - forkTimestamp: String? = nil, - tokens: Int, - output: Int = 0) throws - -> URL - { - var sessionPayload: [String: Any] = ["session_id": sessionID] - if let forkedFromID { - sessionPayload["forked_from_id"] = forkedFromID - } - if let forkTimestamp { - sessionPayload["timestamp"] = forkTimestamp - } - - return try env.writeCodexSessionFile(day: day, filename: filename, contents: env.jsonl([ - [ - "type": "session_meta", - "timestamp": env.isoString(for: day), - "payload": sessionPayload, - ], - [ - "type": "event_msg", - "timestamp": env.isoString(for: day.addingTimeInterval(1)), - "payload": [ - "type": "token_count", - "info": [ - "model": "openai/gpt-5.4", - "total_token_usage": [ - "input_tokens": tokens, - "cached_input_tokens": 0, - "output_tokens": output, - ], - ], - ], - ], - ])) - } -} diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 2b9dd7ab..89e0ddcb 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -500,6 +500,32 @@ struct CostUsageScannerTests { #expect(delta.rows.first?.output == 6) } + @Test + func `codex fast parser does not trap on overflowing token integers`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso = env.isoString(for: day) + let hugeInteger = String(repeating: "9", count: 100) + let line = """ + {"type":"event_msg","timestamp":"\( + iso)","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":\( + hugeInteger),"cached_input_tokens":0,"output_tokens":5},"model":"openai/gpt-5.5"}}} + """ + let fileURL = try env.writeCodexSessionFile(day: day, filename: "overflow.jsonl", contents: line + "\n") + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed.count >= 3) + #expect(packed[0] == 0) + #expect(packed[1] == 0) + #expect(packed[2] == 5) + } + @Test func `claude incremental parsing reads appended lines only`() throws { let env = try CostUsageTestEnvironment() From 6fa9423f9e96b5e87fb049a7e5ce7dafb128c565 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 03:19:48 +0100 Subject: [PATCH 46/79] chore: update widget project package group --- .../CodexBarWidgetExtension.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 = ""; From c778cf74e8226828486370d6c86311b4012490cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 03:20:41 +0100 Subject: [PATCH 47/79] chore: finalize 0.32.2 changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60d5fc94..2fe4bcbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.32.2 — Unreleased +## 0.32.2 — 2026-06-01 ### Added - QA: document the live CodexBar e2e flow and add a redacted provider-matrix helper for packaged CLI smoke tests. From a6a538efc4895cda0074900b5e2503ea66493fe3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 04:17:38 +0100 Subject: [PATCH 48/79] test: stabilize release precheck --- .mac-release.env | 2 +- Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift | 3 +++ .../CodexBackgroundRefreshCoalescingTests.swift | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.mac-release.env b/.mac-release.env index 07a027ed..e734043a 100644 --- a/.mac-release.env +++ b/.mac-release.env @@ -15,7 +15,7 @@ MAC_RELEASE_ARTIFACT_PREFIX='CodexBar-macos-[A-Za-z0-9_+-]+-' MAC_RELEASE_FEED_URL='https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml' MAC_RELEASE_DOWNLOAD_URL_PREFIX='https://github.com/steipete/CodexBar/releases/download/v${MARKETING_VERSION}/' -MAC_RELEASE_PRECHECK='swiftformat Sources Tests >/dev/null && swiftlint --strict && swift test --parallel' +MAC_RELEASE_PRECHECK='swiftformat Sources Tests >/dev/null && swiftlint --strict && swift test --enable-xctest --disable-swift-testing && swift test --enable-swift-testing --disable-xctest --no-parallel' MAC_RELEASE_PACKAGE_CMD='Scripts/sign-and-notarize.sh' MAC_RELEASE_TAG_SIGNED=1 MAC_RELEASE_TAG_FORCE=1 diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 3afa2081..91f0b5da 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -606,12 +606,15 @@ struct CodexAccountScopedRefreshTests { settings.refreshFrequency = .manual settings.openAIWebAccessEnabled = true settings.codexCookieSource = .auto + settings.statusChecksEnabled = false settings._test_liveSystemCodexAccount = self.liveAccount(email: "alpha@example.com") let store = self.makeUsageStore(settings: settings) self.installImmediateCodexProvider( on: store, snapshot: self.codexSnapshot(email: "alpha@example.com", usedPercent: 18)) + await store.refresh() + let dashboardBlocker = BlockingOpenAIDashboardLoader() store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await dashboardBlocker.awaitResult() diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift index 720cf744..6b0aa0ab 100644 --- a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -117,11 +117,14 @@ struct CodexBackgroundRefreshCoalescingTests { try await blocker.awaitResult() } defer { store._test_codexCreditsLoaderOverride = nil } + let regularCompletion = RefreshCompletionProbe() let regularRefreshTask = Task { await store.refresh(forceTokenUsage: false) + await regularCompletion.markCompleted() } await blocker.waitUntilStarted(count: 1) + #expect(await regularCompletion.waitUntilCompleted() == true) let forceRefreshTask = Task { await store.refresh(forceTokenUsage: true) @@ -158,6 +161,8 @@ struct CodexBackgroundRefreshCoalescingTests { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } + await store.refresh(forceTokenUsage: false) + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } From 3f419060f87580ab9793ac669906445dd4361a58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 04:43:42 +0100 Subject: [PATCH 49/79] docs: update appcast for 0.32.2 --- appcast.xml | 54 +++++++++++++++++++++-------------------------------- 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/appcast.xml b/appcast.xml index d90d3bb3..bb576b4d 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,27 @@ CodexBar + + 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.32.1 Sun, 31 May 2026 19:10:18 +0100 @@ -51,39 +72,6 @@ ]]> - - 0.31.0 - Thu, 28 May 2026 23:11:46 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 73 - 0.31.0 - 14.0 - CodexBar 0.31.0 -

Changed

-
    -
  • Docs: update the Homebrew install command to use the official codexbar cask now that it supports Intel Macs (#1189). Thanks @SSakutaro!
  • -
  • Tests: document and audit that routine validation must not trigger macOS Keychain prompts.
  • -
  • Localization: localize popup panels and provider settings UI across supported languages (#1181). Thanks @jack24254029!
  • -
  • Localization: complete Brazilian Portuguese coverage so pt-BR no longer falls back to English for new UI strings (#1188). Thanks @ManuzimFerreira!
  • -
-

Added

-
    -
  • AWS Bedrock: support resolving usage and cost-history credentials from a named AWS profile via the AWS CLI (#1190). Thanks @oleksandr-soldatov!
  • -
  • Codex: show Codex Spark model-specific usage as an optional extra quota lane (#1195, fixes #1177). Thanks @LeoLin990405!
  • -
  • Localization: add Swedish as a selectable app language (#1186). Thanks @yeager!
  • -
-

Fixed

-
    -
  • Cost history: make token-cost JSONL scans cancellation-aware so quitting, forced refreshes, and account switches can stop stale scans sooner.
  • -
  • Codex: show Spark 5-hour and weekly usage as separate quota lanes in Codex breakdowns (#1201).
  • -
  • Codex: show captured codex login output when managed Add Account fails so users can recover from account-selection or OAuth failures (#1199). Thanks @chapati23!
  • -
  • Claude: hide the obsolete Design quota lane now that Claude Design shares the main Claude usage limit (#1197).
  • -
  • Menu bar: coalesce visible-menu rebuilds and reduce hover highlight work so the dropdown stays responsive on macOS 26.5 (#1196).
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From bd921a61e72b65ad60a4e9b89a939fd22167fffc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 04:57:06 +0100 Subject: [PATCH 50/79] chore: normalize widget project package reference --- .../CodexBarWidgetExtension.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index 83a8c7f5..195b28ba 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 = ( - 9FA0A78FB7CA1D877E7BA54B /* codexbar */, + EFBE36CB6481E7133E2A5CF3 /* CodexBar */, ); name = Packages; sourceTree = ""; From 4756ba06bf42843e0a655fbe8eb0f0ce2ba7435b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 04:57:17 +0100 Subject: [PATCH 51/79] chore: start 0.32.3 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe4bcbb..1297434e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.32.3 — Unreleased + ## 0.32.2 — 2026-06-01 ### Added diff --git a/version.env b/version.env index e048223e..f6cbd5ae 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.32.2 -BUILD_NUMBER=77 +MARKETING_VERSION=0.32.3 +BUILD_NUMBER=78 From ffd8d75a9f39e18faf68cd967c9fbdc990f844ec Mon Sep 17 00:00:00 2001 From: Yash Raj Pandey <55940078+devYRPauli@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:34:14 -0400 Subject: [PATCH 52/79] fix: handle Copilot token-billing unavailable quotas Fixes #1258. Co-authored-by: Yash Raj Pandey --- Sources/CodexBarCore/CopilotUsageModels.swift | 27 ++++++- .../Copilot/CopilotUsageFetcher.swift | 15 +++- .../CopilotUsageFetcherTests.swift | 64 +++++++++++++++ .../CopilotUsageModelsTests.swift | 81 +++++++++++++++++++ 4 files changed, 184 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index d845624b..dc8dd1db 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -22,6 +22,8 @@ public struct CopilotUsageResponse: Sendable, Decodable { public let percentRemaining: Double public let quotaId: String public let hasPercentRemaining: Bool + private let entitlementWasDecoded: Bool + private let remainingWasDecoded: Bool public var usedPercent: Double { max(0, 100 - self.percentRemaining) } @@ -31,7 +33,21 @@ public struct CopilotUsageResponse: Sendable, Decodable { } public var isPlaceholder: Bool { - self.entitlement == 0 && self.remaining == 0 && self.percentRemaining == 0 && self.quotaId.isEmpty + if self.entitlement == 0, + self.remaining == 0, + self.percentRemaining == 0, + !self.hasPercentRemaining + { + return true + } + + // An explicit zero-entitlement, zero-remaining snapshot carries no usable quota signal. + // GitHub returns this shape for token-based billing / Copilot Business seats, + // sometimes as percent_remaining=100 with a non-empty quota_id, which would + // otherwise render as a misleading "0% used" (100 - 100). Treat it as a + // placeholder so the usual handling drops it instead of showing fake usage. + return self.entitlementWasDecoded && self.remainingWasDecoded && self.entitlement == 0 && self + .remaining == 0 } private enum CodingKeys: String, CodingKey { @@ -53,6 +69,8 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.percentRemaining = percentRemaining self.quotaId = quotaId self.hasPercentRemaining = hasPercentRemaining + self.entitlementWasDecoded = true + self.remainingWasDecoded = true } public init(from decoder: any Decoder) throws { @@ -61,6 +79,8 @@ public struct CopilotUsageResponse: Sendable, Decodable { let decodedRemaining = Self.decodeNumberIfPresent(container: container, key: .remaining) self.entitlement = decodedEntitlement ?? 0 self.remaining = decodedRemaining ?? 0 + self.entitlementWasDecoded = decodedEntitlement != nil + self.remainingWasDecoded = decodedRemaining != nil let decodedPercent = Self.decodeNumberIfPresent(container: container, key: .percentRemaining) if let decodedPercent { self.percentRemaining = decodedPercent @@ -213,12 +233,14 @@ public struct CopilotUsageResponse: Sendable, Decodable { public let quotaSnapshots: QuotaSnapshots public let copilotPlan: String + public let tokenBasedBilling: Bool public let assignedDate: String? public let quotaResetDate: String? private enum CodingKeys: String, CodingKey { case quotaSnapshots = "quota_snapshots" case copilotPlan = "copilot_plan" + case tokenBasedBilling = "token_based_billing" case assignedDate = "assigned_date" case quotaResetDate = "quota_reset_date" case monthlyQuotas = "monthly_quotas" @@ -228,11 +250,13 @@ public struct CopilotUsageResponse: Sendable, Decodable { public init( quotaSnapshots: QuotaSnapshots, copilotPlan: String, + tokenBasedBilling: Bool = false, assignedDate: String?, quotaResetDate: String?) { self.quotaSnapshots = quotaSnapshots self.copilotPlan = copilotPlan + self.tokenBasedBilling = tokenBasedBilling self.assignedDate = assignedDate self.quotaResetDate = quotaResetDate } @@ -253,6 +277,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.quotaSnapshots = directSnapshots ?? QuotaSnapshots(premiumInteractions: nil, chat: nil) } self.copilotPlan = try container.decodeIfPresent(String.self, forKey: .copilotPlan) ?? "unknown" + self.tokenBasedBilling = try container.decodeIfPresent(Bool.self, forKey: .tokenBasedBilling) ?? false self.assignedDate = try container.decodeIfPresent(String.self, forKey: .assignedDate) self.quotaResetDate = try container.decodeIfPresent(String.self, forKey: .quotaResetDate) } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index 8aa9b221..758168b0 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -16,10 +16,16 @@ public struct CopilotUsageFetcher: Sendable { private let token: String private let enterpriseHost: String? + private let transport: any ProviderHTTPTransport - public init(token: String, enterpriseHost: String? = nil) { + public init( + token: String, + enterpriseHost: String? = nil, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + { self.token = token self.enterpriseHost = enterpriseHost + self.transport = transport } public static func apiHost(enterpriseHost: String?) -> String { @@ -49,7 +55,7 @@ public struct CopilotUsageFetcher: Sendable { request.setValue("token \(self.token)", forHTTPHeaderField: "Authorization") self.addCommonHeaders(to: &request) - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await self.transport.response(for: request) if response.statusCode == 401 || response.statusCode == 403 { throw URLError(.userAuthenticationRequired) @@ -73,6 +79,11 @@ public struct CopilotUsageFetcher: Sendable { // ("Premium" for primary, "Chat" for secondary) on chat-only plans. primary = nil secondary = chatWindow + } else if usage.tokenBasedBilling { + // Copilot Business token-based billing currently exposes zero-entitlement + // placeholder quotas on this endpoint, so surface the plan without fake usage. + primary = nil + secondary = nil } else { throw URLError(.cannotDecodeRawData) } diff --git a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift index 3ae186a6..c5083bec 100644 --- a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift @@ -25,4 +25,68 @@ struct CopilotUsageFetcherTests { #expect(requests.count == 1) #expect(requests.first?.url?.host == "api.github.com") } + + @Test + func `fetch returns unavailable snapshot for business token billing placeholders`() 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": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions" + }, + "chat": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "chat" + } + } + } + """.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 == nil) + #expect(snapshot.identity?.loginMethod == "Business") + } + + @Test + func `makeRateWindow drops business token billing placeholder quota`() { + // entitlement=0/remaining=0/percent_remaining=100 must not become a "0% used" + // rate window for Copilot Business token-based billing accounts. (#1258) + let placeholder = CopilotUsageResponse.QuotaSnapshot( + entitlement: 0, + remaining: 0, + percentRemaining: 100, + quotaId: "premium_interactions") + #expect(CopilotUsageFetcher.makeRateWindow(from: placeholder) == nil) + } + + @Test + func `makeRateWindow keeps real quota window`() { + let real = CopilotUsageResponse.QuotaSnapshot( + entitlement: 500, + remaining: 125, + percentRemaining: 25, + quotaId: "premium_interactions") + let window = CopilotUsageFetcher.makeRateWindow(from: real) + #expect(window?.usedPercent == 75) + } } diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index e59d2ad9..48f99b98 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -437,6 +437,87 @@ struct CopilotUsageModelsTests { #expect(response.quotaSnapshots.chat == nil) } + @Test + func `treats business token billing zero entitlement quotas as unavailable`() throws { + // GitHub Copilot Business token-based billing reports every quota as + // entitlement=0, remaining=0, percent_remaining=100. That previously rendered as a + // misleading "0% used" (100 - 100). A zero-entitlement quota carries no usage signal, + // so the snapshots must drop out instead of showing as usage. (#1258) + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions" + }, + "chat": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "chat" + }, + "completions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "completions" + } + } + } + """) + + #expect(response.tokenBasedBilling) + #expect(response.quotaSnapshots.premiumInteractions == nil) + #expect(response.quotaSnapshots.chat == nil) + } + + @Test + func `flags zero entitlement snapshot as placeholder`() { + let snapshot = CopilotUsageResponse.QuotaSnapshot( + entitlement: 0, + remaining: 0, + percentRemaining: 100, + quotaId: "chat") + #expect(snapshot.isPlaceholder) + } + + @Test + func `keeps fully consumed quota with positive entitlement`() { + // entitlement > 0 with remaining 0 is a real "100% used" window, not a placeholder. + let snapshot = CopilotUsageResponse.QuotaSnapshot( + entitlement: 500, + remaining: 0, + percentRemaining: 0, + quotaId: "premium_interactions") + #expect(!snapshot.isPlaceholder) + #expect(snapshot.usedPercent == 100) + } + + @Test + func `keeps percent only quota snapshots available`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "business", + "quota_snapshots": { + "chat": { + "percent_remaining": 40, + "quota_id": "chat" + } + } + } + """) + + #expect(response.quotaSnapshots.chat?.percentRemaining == 40) + #expect(response.quotaSnapshots.chat?.usedPercent == 60) + #expect(response.quotaSnapshots.chat?.isPlaceholder == false) + } + private static func decodeFixture(_ fixture: String) throws -> CopilotUsageResponse { try JSONDecoder().decode(CopilotUsageResponse.self, from: Data(fixture.utf8)) } From 51a8e23f64182b735009fc11bdb71a1fe11b48be Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:46:54 -0700 Subject: [PATCH 53/79] fix: stop OpenAI WebView route reload loop Co-authored-by: pickaxe <54486432+ProspectOre@users.noreply.github.com> --- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 71 +++++++++++++++---- ...enAIDashboardFetcherCreditsWaitTests.swift | 19 +++++ 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index bf742735..c07871cb 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -275,21 +275,19 @@ public struct OpenAIDashboardFetcher { continue } + try await self.handleBlockingScrapeState( + scrape, + webView: webView, + debugDumpHTML: debugDumpHTML, + logger: log) + // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. - if let href = scrape.href, !Self.isUsageRoute(href) { + if Self.shouldReloadUsageRoute(scrape) { _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } - if debugDumpHTML, - scrape.loginRequired || scrape.cloudflareInterstitial, - let html = try? await self.fetchDebugHTML(webView: webView) - { - Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) - } - try Self.throwIfBlockingScrapeState(scrape) - let dashboardData = Self.parseDashboardScrape( scrape, apiData: apiData, @@ -455,7 +453,10 @@ public struct OpenAIDashboardFetcher { continue } - if let href = scrape.href, !Self.isUsageRoute(href) { + Self.logBlockingStateIfNeeded(scrape, logger: log) + try Self.throwIfBlockingScrapeState(scrape) + + if Self.shouldReloadUsageRoute(scrape) { usageRouteSeenAt = nil dashboardSignalSeenAt = nil _ = webView.load(Self.usageURLRequest(url: self.usageURL)) @@ -463,11 +464,6 @@ public struct OpenAIDashboardFetcher { continue } - if scrape.loginRequired { throw FetchError.loginRequired } - if scrape.cloudflareInterstitial { - throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") - } - let normalizedEmail = scrape.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let bodyText = scrape.bodyText ?? "" let rateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) @@ -676,6 +672,25 @@ public struct OpenAIDashboardFetcher { || path.hasSuffix("codex/cloud/settings/analytics") } + nonisolated static func shouldReloadUsageRoute( + href: String?, + loginRequired: Bool, + workspacePicker: Bool, + cloudflareInterstitial: Bool) -> Bool + { + guard !workspacePicker, !loginRequired, !cloudflareInterstitial else { return false } + guard let href else { return false } + return !self.isUsageRoute(href) + } + + private nonisolated static func shouldReloadUsageRoute(_ scrape: ScrapeResult) -> Bool { + self.shouldReloadUsageRoute( + href: scrape.href, + loginRequired: scrape.loginRequired, + workspacePicker: scrape.workspacePicker, + cloudflareInterstitial: scrape.cloudflareInterstitial) + } + nonisolated static func usageURLRequest(url: URL) -> URLRequest { var request = URLRequest(url: url) request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") @@ -961,6 +976,32 @@ extension OpenAIDashboardFetcher { logger("usage breakdown error: \(error)") } } + +extension OpenAIDashboardFetcher { + private static func logBlockingStateIfNeeded(_ scrape: ScrapeResult, logger: (String) -> Void) { + guard scrape.loginRequired || scrape.cloudflareInterstitial else { return } + let route = self.isUsageRoute(scrape.href) ? "usage" : "other" + logger( + "blocking state before route reload route=\(route) " + + "login=\(scrape.loginRequired) cloudflare=\(scrape.cloudflareInterstitial)") + } + + private func handleBlockingScrapeState( + _ scrape: ScrapeResult, + webView: WKWebView, + debugDumpHTML: Bool, + logger: (String) -> Void) async throws + { + if debugDumpHTML, + scrape.loginRequired || scrape.cloudflareInterstitial, + let html = try? await self.fetchDebugHTML(webView: webView) + { + Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) + } + Self.logBlockingStateIfNeeded(scrape, logger: logger) + try Self.throwIfBlockingScrapeState(scrape) + } +} #else import Foundation diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index e75da4dc..f7709fc1 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -235,6 +235,25 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(!OpenAIDashboardFetcher.isUsageRoute(nil)) } + @Test(arguments: [ + ("https://chatgpt.com/#usage", true, false, false, false), + ("https://chatgpt.com/", false, false, true, false), + ("https://chatgpt.com/", false, false, false, true) + ]) + func `usage route reload skips blocking states`( + href: String, + loginRequired: Bool, + workspacePicker: Bool, + cloudflareInterstitial: Bool, + expected: Bool) + { + #expect(OpenAIDashboardFetcher.shouldReloadUsageRoute( + href: href, + loginRequired: loginRequired, + workspacePicker: workspacePicker, + cloudflareInterstitial: cloudflareInterstitial) == expected) + } + @Test func `dashboard requests prefer English localization`() throws { let url = try #require(URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")) From dc4e4835bc6e0b42573f6fb876ad6de626241681 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 23:47:22 +0100 Subject: [PATCH 54/79] docs: update changelog for Copilot and OpenAI fixes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1297434e..e1160139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.32.3 — Unreleased +### Fixed +- Copilot: treat GitHub Copilot Business token-billing zero-entitlement quotas as unavailable instead of showing misleading 0% used usage (#1258, #1270). Thanks @devYRPauli! +- OpenAI Web: stop reloading away from login and Cloudflare blocking states so the dashboard WebView does not loop on route corrections (#1259). Thanks @ProspectOre! + ## 0.32.2 — 2026-06-01 ### Added From 085319c5e9ae8b11dd96983c9c99fde8729c6e56 Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:52:14 -0700 Subject: [PATCH 55/79] fix: defer closed menu rebuilds during refresh Defer closed menu rebuild work during refreshes, and preserve stale open-menu content only for data-refresh invalidations so privacy and structural changes still force a rebuild. Co-authored-by: pickaxe <54486432+ProspectOre@users.noreply.github.com> --- CHANGELOG.md | 1 + .../CodexBar/StatusItemController+Menu.swift | 8 +- ...tatusItemController+MenuLocalization.swift | 1 + .../StatusItemController+MenuTracking.swift | 141 +++++++- .../StatusItemController+Shutdown.swift | 3 + Sources/CodexBar/StatusItemController.swift | 32 +- .../StatusMenuOpenRefreshTests.swift | 304 ++++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 7 +- 8 files changed, 474 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1160139..abb18fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - 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! ## 0.32.2 — 2026-06-01 diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 90182040..02efb265 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -83,6 +83,7 @@ extension StatusItemController { } self.cancelDeferredMenuInteractionRefreshTask() + self.cancelClosedMenuRebuild(menu) if self.isHostedSubviewMenu(menu) { self.hydrateHostedSubviewMenuIfNeeded(menu) @@ -122,11 +123,7 @@ extension StatusItemController { self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") } - if self.menuNeedsRefresh(menu) { - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - // Heights are already set during populateMenu, no need to remeasure - } + self.refreshMenuForOpenIfNeeded(menu, provider: provider) 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. @@ -152,6 +149,7 @@ extension StatusItemController { self.removeProviderSwitcherShortcutMonitor() } + self.cancelClosedMenuRebuild(menu) self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() diff --git a/Sources/CodexBar/StatusItemController+MenuLocalization.swift b/Sources/CodexBar/StatusItemController+MenuLocalization.swift index 52944810..e90f03de 100644 --- a/Sources/CodexBar/StatusItemController+MenuLocalization.swift +++ b/Sources/CodexBar/StatusItemController+MenuLocalization.swift @@ -4,6 +4,7 @@ extension StatusItemController { func menuLocalizationSignature() -> String { [ codexBarLocalizationSignature(), + self.settings.hidePersonalInfo ? "hide-personal-info" : "show-personal-info", L("Overview"), L("Cost"), ].joined(separator: "|") diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 625dbdf4..4922f9cc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -2,14 +2,39 @@ import AppKit import CodexBarCore extension StatusItemController { + private static let defaultClosedMenuPreparationDelay: Duration = .milliseconds(350) + + #if DEBUG + private static var closedMenuPreparationDelayForTesting: Duration = defaultClosedMenuPreparationDelay + static func setClosedMenuPreparationDelayForTesting(_ delay: Duration) { + self.closedMenuPreparationDelayForTesting = delay + } + + static func resetClosedMenuPreparationDelayForTesting() { + self.closedMenuPreparationDelayForTesting = self.defaultClosedMenuPreparationDelay + } + #endif + + private static var closedMenuPreparationDelay: Duration { + #if DEBUG + closedMenuPreparationDelayForTesting + #else + defaultClosedMenuPreparationDelay + #endif + } + func invalidateMenus( refreshOpenMenus: Bool = false, - deferOpenParentMenuRebuild: Bool = false) + deferOpenParentMenuRebuild: Bool = false, + allowStaleContentDuringDataRefresh: Bool = false) { #if DEBUG guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 + if !allowStaleContentDuringDataRefresh { + self.latestRequiredMenuRebuildVersion = self.menuContentVersion + } guard self.isMenuRefreshEnabled else { return } if !self.openMenus.isEmpty { guard refreshOpenMenus else { return } @@ -19,6 +44,70 @@ extension StatusItemController { deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) return } + self.prepareAttachedClosedMenusIfNeeded() + } + + func prepareAttachedClosedMenusIfNeeded() { + guard self.isMenuRefreshEnabled else { return } + guard self.openMenus.isEmpty else { return } + guard !self.isMenuDataRefreshInFlight else { return } + for menu in self.attachedMenusForClosedPreparation() { + self.rebuildClosedMenuIfNeeded(menu) + } + } + + var isMenuDataRefreshInFlight: Bool { + self.store.isRefreshing || + UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } + } + + func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { + guard self.menuNeedsRefresh(menu) else { return } + if self.canPreserveStaleMenuContentDuringRefresh(menu) { + #if DEBUG + self.menuLogger.debug( + "menu open kept existing content during refresh", + metadata: [ + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + #endif + self.deferMenuInteractionRefreshIfNeeded() + return + } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } + + private func canPreserveStaleMenuContentDuringRefresh(_ menu: NSMenu) -> Bool { + guard self.isMenuDataRefreshInFlight, !menu.items.isEmpty else { return false } + let key = ObjectIdentifier(menu) + guard let menuVersion = self.menuVersions[key] else { return false } + return menuVersion >= self.latestRequiredMenuRebuildVersion + } + + private func attachedMenusForClosedPreparation() -> [NSMenu] { + var menus: [NSMenu] = [] + var seen = Set() + + func append(_ menu: NSMenu?) { + guard let menu else { return } + let key = ObjectIdentifier(menu) + guard seen.insert(key).inserted else { return } + menus.append(menu) + } + + append(self.statusItem.menu) + append(self.mergedMenu) + append(self.fallbackMenu) + for item in self.statusItems.values { + append(item.menu) + } + for menu in self.providerMenus.values { + append(menu) + } + return menus } func renderedMenuWidth(for menu: NSMenu) -> CGFloat { @@ -28,16 +117,62 @@ extension StatusItemController { func rebuildClosedMenuIfNeeded(_ menu: NSMenu) { guard !self.hasPreparedForAppShutdown else { return } + guard !self.isMenuDataRefreshInFlight else { return } + let key = ObjectIdentifier(menu) let provider = self.menuProvider(for: menu) - Task { @MainActor [weak self, weak menu] in + self.closedMenuRebuildTokenCounter &+= 1 + let rebuildToken = self.closedMenuRebuildTokenCounter + self.closedMenuRebuildTokens[key] = rebuildToken + self.closedMenuRebuildTasks[key]?.cancel() + self.closedMenuRebuildTasks[key] = Task { @MainActor [weak self, weak menu] in + let delay = Self.closedMenuPreparationDelay + if delay > .zero { + try? await Task.sleep(for: delay) + } + guard !Task.isCancelled else { return } await Task.yield() - guard let self, let menu else { return } + guard !Task.isCancelled else { return } + guard let self else { return } + defer { + if self.closedMenuRebuildTokens[key] == rebuildToken { + self.closedMenuRebuildTasks.removeValue(forKey: key) + self.closedMenuRebuildTokens.removeValue(forKey: key) + } + } + guard let menu else { return } + guard self.closedMenuRebuildTokens[key] == rebuildToken else { return } guard !self.hasPreparedForAppShutdown else { return } + guard !self.isMenuDataRefreshInFlight else { return } guard self.openMenus[ObjectIdentifier(menu)] == nil else { return } guard self.menuNeedsRefresh(menu) else { return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) + #if DEBUG + if self.lastLoggedClosedMenuRebuildVersion != self.menuContentVersion { + self.lastLoggedClosedMenuRebuildVersion = self.menuContentVersion + self.menuLogger.debug( + "closed menu rebuild completed", + metadata: [ + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + ]) + } + #endif + } + } + + func cancelClosedMenuRebuild(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + self.closedMenuRebuildTasks.removeValue(forKey: key)?.cancel() + self.closedMenuRebuildTokens.removeValue(forKey: key) + } + + func cancelAllClosedMenuRebuilds() { + for task in self.closedMenuRebuildTasks.values { + task.cancel() } + self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) } func menuNeedsRefresh(_ menu: NSMenu) -> Bool { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 4d50a9a0..0277aca6 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -46,6 +46,7 @@ extension StatusItemController { for task in self.menuRefreshTasks.values { task.cancel() } + self.cancelAllClosedMenuRebuilds() for task in self.openMenuRebuildTasks.values { task.cancel() } @@ -56,6 +57,8 @@ extension StatusItemController { private func clearShutdownMenuState() { self.removeProviderSwitcherShortcutMonitor() self.menuRefreshTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildTasks.removeAll(keepingCapacity: false) self.openMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index b8f4df3d..cbe0ec80 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -55,16 +55,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - #if DEBUG - static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { - self.menuRefreshEnabled = enabled - } - - static func resetMenuRefreshEnabledForTesting() { - self.menuRefreshEnabled = self.defaultMenuRefreshEnabled - } - #endif - #if DEBUG var menuRefreshEnabledOverrideForTesting: Bool? #endif @@ -125,6 +115,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastMenuProvider: UsageProvider? var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuContentVersion: Int = 0 + var latestRequiredMenuRebuildVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] var lastMenuAdjunctReadinessSignature = "" var mergedMenu: NSMenu? @@ -132,6 +123,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] + var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] + var closedMenuRebuildTokens: [ObjectIdentifier: Int] = [:] + var closedMenuRebuildTokenCounter = 0 var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 @@ -150,6 +144,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var onDeferredMenuInteractionRefreshForTesting: (() -> Void)? var onOpenMenuInvalidationRetryForTesting: (() -> Void)? var isReleasedForTesting = false + var lastLoggedClosedMenuRebuildVersion: Int? var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? var _test_codexAmbientLoginRunnerOverride: @@ -448,7 +443,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.observeStoreChanges() self.invalidateMenus( refreshOpenMenus: self.didMenuAdjunctReadinessChange(), - deferOpenParentMenuRebuild: true) + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) } } } @@ -797,6 +793,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if self.statusItem.menu !== self.mergedMenu { self.statusItem.menu = self.mergedMenu } + self.prepareAttachedClosedMenusIfNeeded() } private func attachMenus(fallback: UsageProvider? = nil) { @@ -827,6 +824,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin item.menu = nil } } + self.prepareAttachedClosedMenusIfNeeded() } private func rebuildProviderStatusItems() { @@ -902,6 +900,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } +#if DEBUG +extension StatusItemController { + static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { + self.menuRefreshEnabled = enabled + } + + static func resetMenuRefreshEnabledForTesting() { + self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + } +} +#endif + extension StatusItemController { private func legacyDefaultItemIndex(forNewProvider provider: UsageProvider) -> Int? { let visibleProviders = self.settings.orderedProviders().filter { self.isVisible($0) } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index b00caa72..2f3855c2 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -32,6 +32,8 @@ extension StatusMenuTests { preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } controller.menuRefreshEnabledOverrideForTesting = true StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) @@ -113,6 +115,308 @@ extension StatusMenuTests { #expect(rebuildCount == 0) } + @Test + func `closed attached menu is prepared before next open after invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(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() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + 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 openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed attached menu preparation waits for store refresh to finish`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(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() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + 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 openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed attached menu preparation waits for token refresh to finish`() async { + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(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 openedVersion = controller.menuVersions[key] + + store.tokenRefreshInFlight.insert(.codex) + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == openedVersion) + + store.tokenRefreshInFlight.remove(.codex) + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed menu rebuild cleanup runs when weak menu disappears`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(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() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + let key: ObjectIdentifier + do { + let menu = NSMenu() + key = ObjectIdentifier(menu) + controller.rebuildClosedMenuIfNeeded(menu) + #expect(controller.closedMenuRebuildTasks[key] != nil) + #expect(controller.closedMenuRebuildTokens[key] != nil) + } + + for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { + await Task.yield() + } + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.closedMenuRebuildTokens[key] == nil) + } + + @Test + func `menu open keeps stale nonempty content while store refresh is active`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(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 openedVersion = controller.menuVersions[key] + let openedItemCount = menu.items.count + + store.isRefreshing = true + defer { store.isRefreshing = false } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.menuContentVersion != openedVersion) + #expect(menu.items.count == openedItemCount) + #expect(controller.openMenus[key] === menu) + } + + @Test + func `menu open rebuilds stale content after privacy setting changes during refresh`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(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 openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + defer { store.isRefreshing = false } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + settings.hidePersonalInfo = true + controller.invalidateMenus() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } + + @Test + func `menu open keeps stale nonempty content while token refresh is active`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(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 openedVersion = controller.menuVersions[key] + let openedItemCount = menu.items.count + + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.menuContentVersion != openedVersion) + #expect(menu.items.count == openedItemCount) + #expect(controller.openMenus[key] === menu) + } + @Test func `explicit store actions refresh a visible open menu`() async { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 935c72c1..c29d770e 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -22,11 +22,13 @@ struct StatusMenuTests { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings } func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { @@ -85,7 +87,6 @@ struct StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true settings.alibabaCodingPlanAPIRegion = .chinaMainland let fetcher = UsageFetcher() @@ -820,7 +821,6 @@ extension StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -860,7 +860,6 @@ extension StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true let registry = ProviderRegistry.shared try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) From 440aeb1cb0d4e14e7049264a155485d8c8349dfd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 11:25:55 +0100 Subject: [PATCH 56/79] perf: cache provider brand icons --- CHANGELOG.md | 1 + Sources/CodexBar/ProviderBrandIcon.swift | 11 +++++++++++ .../ProviderIconResourcesTests.swift | 15 +++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index abb18fd9..844e60e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.32.3 — Unreleased ### Fixed +- 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! diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index 86fc8d7b..844e4677 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -1,8 +1,10 @@ import AppKit import CodexBarCore +@MainActor enum ProviderBrandIcon { private static let size = NSSize(width: 16, height: 16) + private static var cache: [UsageProvider: NSImage] = [:] /// Lazy-loaded resource bundle for provider icons. private static let resourceBundle: Bundle? = { @@ -20,6 +22,10 @@ enum ProviderBrandIcon { }() static func image(for provider: UsageProvider) -> NSImage? { + if let cached = self.cache[provider] { + return cached + } + let baseName = ProviderDescriptorRegistry.descriptor(for: provider).branding.iconResourceName guard let bundle = self.resourceBundle else { return nil @@ -32,6 +38,11 @@ enum ProviderBrandIcon { image.size = self.size image.isTemplate = true + self.cache[provider] = image return image } + + static func resetCacheForTesting() { + self.cache.removeAll() + } } diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 53bcbf11..6db1d3c3 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -1,6 +1,8 @@ import AppKit +import CodexBarCore import Foundation import Testing +@testable import CodexBar @MainActor struct ProviderIconResourcesTests { @@ -53,6 +55,19 @@ struct ProviderIconResourcesTests { #expect(groq != grok) } + @Test + func `provider brand icons are cached after first load`() throws { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + let first = try #require(ProviderBrandIcon.image(for: .codex)) + let second = try #require(ProviderBrandIcon.image(for: .codex)) + + #expect(first === second) + #expect(first.size == NSSize(width: 16, height: 16)) + #expect(first.isTemplate) + } + private static func repoRoot() throws -> URL { var dir = URL(filePath: #filePath).deletingLastPathComponent() for _ in 0..<12 { From d8b7619de439d5553eb04a2dfc5cb3ae625d8407 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 11:26:06 +0100 Subject: [PATCH 57/79] chore: normalize widget package reference --- .../CodexBarWidgetExtension.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 = ""; From 55c0a105002f6dc563788e5a5cfbe0c38ad83cbd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 11:57:20 +0100 Subject: [PATCH 58/79] fix: clear bad status item placement --- CHANGELOG.md | 1 + .../MenuBarStatusItemPlacementPreflight.swift | 90 +++++++++---------- ...tusItemControllerSplitLifecycleTests.swift | 84 +++++++++++++---- 3 files changed, 109 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 844e60e5..f9661ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.32.3 — Unreleased ### 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! diff --git a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift index dbbf7841..473fabd7 100644 --- a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift +++ b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift @@ -1,69 +1,61 @@ -import Foundation +import AppKit +@MainActor enum MenuBarStatusItemPlacementPreflight { static let preferredPositionPrefix = "NSStatusItem Preferred Position " - static let lowPreferredPosition: Double = 0 - static let suspiciousPreferredPositionThreshold: Double = 100 + static let suspiciousPreferredPositionPadding: Double = 512 static func preferredPositionKey(autosaveName: String) -> String { "\(self.preferredPositionPrefix)\(autosaveName)" } @discardableResult - static func prepare(defaults: UserDefaults, autosaveName: String, legacyDefaultItemIndex: Int? = nil) -> Bool { + static func prepare( + defaults: UserDefaults, + autosaveName: String, + legacyDefaultItemIndex: Int? = nil, + maximumPreferredPosition: Double? = currentMaximumPreferredPosition()) + -> Bool + { let key = self.preferredPositionKey(autosaveName: autosaveName) - let value = defaults.object(forKey: key) - guard value != nil || !self.shouldPreserveMissingStableKey( + var repaired = self.clearPreferredPositionIfNeeded( defaults: defaults, - legacyDefaultItemIndex: legacyDefaultItemIndex) - else { - return false + key: key, + maximumPreferredPosition: maximumPreferredPosition) + if let legacyDefaultItemIndex { + let legacyKey = self.preferredPositionKey(autosaveName: "Item-\(legacyDefaultItemIndex)") + repaired = self.clearPreferredPositionIfNeeded( + defaults: defaults, + key: legacyKey, + maximumPreferredPosition: maximumPreferredPosition) || repaired } - guard self.shouldSetPreferredPosition(value) else { return false } - defaults.set(self.lowPreferredPosition, forKey: key) - return true + return repaired } - static func shouldSetPreferredPosition(_ value: Any?) -> Bool { - guard let value else { return true } + static func shouldClearPreferredPosition(_ value: Any, maximumPreferredPosition: Double?) -> Bool { guard let number = value as? NSNumber else { return true } - return number.doubleValue > self.suspiciousPreferredPositionThreshold - } - - static func shouldPreserveMissingStableKey(defaults: UserDefaults, legacyDefaultItemIndex: Int?) -> Bool { - guard let legacyDefaultItemIndex else { return false } - return self.legacyPreferredPositions(defaults: defaults).contains { position in - position.itemIndex == legacyDefaultItemIndex && !self.shouldSetPreferredPosition(position.value) + let position = number.doubleValue + if position <= 0 { + return true } + guard let maximumPreferredPosition else { return false } + return position > maximumPreferredPosition + self.suspiciousPreferredPositionPadding + } + + private static func clearPreferredPositionIfNeeded( + defaults: UserDefaults, + key: String, + maximumPreferredPosition: Double?) + -> Bool + { + guard let value = defaults.object(forKey: key), + self.shouldClearPreferredPosition(value, maximumPreferredPosition: maximumPreferredPosition) + else { return false } + defaults.removeObject(forKey: key) + return true } - static func isLegacyPreferredPositionKey(_ key: String) -> Bool { - guard key.hasPrefix(self.preferredPositionPrefix) else { return false } - return self.isDefaultStatusItemName(String(key.dropFirst(self.preferredPositionPrefix.count))) - } - - private static func legacyPreferredPositions(defaults: UserDefaults) -> [LegacyPreferredPosition] { - defaults.dictionaryRepresentation().compactMap { key, value -> LegacyPreferredPosition? in - guard key.hasPrefix(self.preferredPositionPrefix) else { return nil } - let itemName = String(key.dropFirst(self.preferredPositionPrefix.count)) - guard let itemIndex = self.defaultStatusItemIndex(itemName) else { return nil } - return LegacyPreferredPosition(itemIndex: itemIndex, value: value) - } - } - - private struct LegacyPreferredPosition { - var itemIndex: Int - var value: Any - } - - private static func isDefaultStatusItemName(_ itemName: String) -> Bool { - self.defaultStatusItemIndex(itemName) != nil - } - - private static func defaultStatusItemIndex(_ itemName: String) -> Int? { - guard itemName.hasPrefix("Item-") else { return nil } - let suffix = itemName.dropFirst("Item-".count) - guard suffix.allSatisfy(\.isNumber) else { return nil } - return Int(suffix) + private static func currentMaximumPreferredPosition() -> Double? { + NSScreen.screens.map { Double($0.frame.maxX) }.max() } } diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index 491afc42..8fce39ad 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -124,16 +124,16 @@ struct StatusItemControllerSplitLifecycleTests { } @Test - func `status item placement preflight writes low position on fresh install`() throws { + func `status item placement preflight leaves fresh install placement unset`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-missing-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } - #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) - let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") - #expect(defaults.double(forKey: key) == 0) + + #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + #expect(defaults.object(forKey: key) == nil) } @Test @@ -155,7 +155,7 @@ struct StatusItemControllerSplitLifecycleTests { } @Test - func `status item placement preflight repairs missing new key when legacy item placement is suspicious`() throws { + func `status item placement preflight clears suspicious matching legacy placement`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-high-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -166,10 +166,11 @@ struct StatusItemControllerSplitLifecycleTests { #expect(MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-merged", - legacyDefaultItemIndex: 0)) + legacyDefaultItemIndex: 0, + maximumPreferredPosition: 3000)) - #expect(defaults.double(forKey: key) == 0) - #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 11298) + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.object(forKey: "NSStatusItem Preferred Position Item-0") == nil) } @Test @@ -193,7 +194,7 @@ struct StatusItemControllerSplitLifecycleTests { } @Test - func `status item placement preflight repairs provider new key when mixed legacy placements exist`() throws { + func `status item placement preflight clears provider matching suspicious legacy placement`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-mixed-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -205,15 +206,16 @@ struct StatusItemControllerSplitLifecycleTests { #expect(MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-codex", - legacyDefaultItemIndex: 1)) + legacyDefaultItemIndex: 1, + maximumPreferredPosition: 3000)) - #expect(defaults.double(forKey: key) == 0) + #expect(defaults.object(forKey: key) == nil) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) - #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 11298) + #expect(defaults.object(forKey: "NSStatusItem Preferred Position Item-1") == nil) } @Test - func `status item placement preflight repairs provider new key when only merged legacy placement exists`() throws { + func `status item placement preflight leaves provider key unset when only merged legacy placement exists`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-single-legacy-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -221,12 +223,12 @@ struct StatusItemControllerSplitLifecycleTests { defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") - #expect(MenuBarStatusItemPlacementPreflight.prepare( + #expect(!MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-codex", legacyDefaultItemIndex: 1)) - #expect(defaults.double(forKey: key) == 0) + #expect(defaults.object(forKey: key) == nil) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) } @@ -251,7 +253,7 @@ struct StatusItemControllerSplitLifecycleTests { } @Test - func `status item placement preflight replaces suspicious high position`() throws { + func `status item placement preflight clears suspicious high position`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-high-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -259,9 +261,40 @@ struct StatusItemControllerSplitLifecycleTests { let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") defaults.set(11298, forKey: key) + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + maximumPreferredPosition: 3000)) + + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight clears old forced zero position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-zero-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(0, forKey: key) + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) - #expect(defaults.double(forKey: key) == 0) + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight clears malformed position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-malformed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set("not-a-position", forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.object(forKey: key) == nil) } @Test @@ -278,6 +311,23 @@ struct StatusItemControllerSplitLifecycleTests { #expect(defaults.double(forKey: key) == 42) } + @Test + func `status item placement preflight preserves large display position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-large-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(2500, forKey: key) + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + maximumPreferredPosition: 2560)) + + #expect(defaults.double(forKey: key) == 2500) + } + @Test func `status item defaults repair removes stale hidden Control Center keys once`() throws { let suite = "StatusItemControllerSplitLifecycleTests-repair-\(UUID().uuidString)" From 1154f552ad23f4774022433a49cd0fe8e0816006 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 12:02:47 +0100 Subject: [PATCH 59/79] test: stabilize TTY runner harness --- Tests/CodexBarTests/TTYCommandRunnerTests.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index d3904069..e2c9ee79 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -4,6 +4,8 @@ import Testing @Suite(.serialized) struct TTYCommandRunnerEnvTests { + private static let harnessPTYTimeout: TimeInterval = 10 + private final class CallbackCounter: @unchecked Sendable { private let lock = NSLock() private var count = 0 @@ -202,7 +204,10 @@ struct TTYCommandRunnerEnvTests { try fm.createDirectory(at: dir, withIntermediateDirectories: true) let runner = TTYCommandRunner() - let result = try runner.run(binary: "/bin/pwd", send: "", options: .init(timeout: 3, workingDirectory: dir)) + let result = try runner.run( + binary: "/bin/pwd", + send: "", + options: .init(timeout: Self.harnessPTYTimeout, workingDirectory: dir)) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains(dir.path)) } @@ -214,7 +219,7 @@ struct TTYCommandRunnerEnvTests { let result = try runner.run( binary: fakeClaude.path, send: "", - options: .init(timeout: 3, stopOnSubstrings: ["deep-link-enabled"])) + options: .init(timeout: Self.harnessPTYTimeout, stopOnSubstrings: ["deep-link-enabled"])) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains("deep-link-enabled")) @@ -228,7 +233,7 @@ struct TTYCommandRunnerEnvTests { binary: fakeClaude.path, send: "", options: .init( - timeout: 3, + timeout: Self.harnessPTYTimeout, stopOnSubstrings: ["deep-link-disabled"], useClaudeProbeWorkingDirectory: true)) let clean = result.text.replacingOccurrences(of: "\r", with: "") @@ -247,7 +252,7 @@ struct TTYCommandRunnerEnvTests { binary: fakeClaude.path, send: "", options: .init( - timeout: 3, + timeout: Self.harnessPTYTimeout, baseEnvironment: env, stopOnSubstrings: ["deep-link-disabled"], useClaudeProbeWorkingDirectory: true)) From b9b05d5fd0cdf5bb765f720027489060c40e9712 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 12:04:35 +0100 Subject: [PATCH 60/79] chore: finalize 0.32.3 changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9661ebd..d35c5c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.32.3 — Unreleased +## 0.32.3 — 2026-06-02 ### 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! From 743c3c447a8ef60876c3d45be543a7e0f717e547 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 12:30:57 +0100 Subject: [PATCH 61/79] docs: update appcast for 0.32.3 --- appcast.xml | 50 ++++++++++++++++++++------------------------------ 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/appcast.xml b/appcast.xml index bb576b4d..91faade2 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,26 @@ CodexBar + + 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.32.2 Mon, 01 Jun 2026 04:43:41 +0100 @@ -42,36 +62,6 @@ ]]> - - 0.32.0 - Sun, 31 May 2026 01:50:25 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 75 - 0.32.0 - 14.0 - CodexBar 0.32.0 -

Added

-
    -
  • Settings: add search to the Providers pane so large provider lists can be filtered by name or id (#1184). Thanks @046081-dotcom!
  • -
-

Fixed

-
    -
  • Augment: parse the updated auggie account status output format, fall back to browser cookies when CLI parsing fails, and restore session cookie detection (#1224). Thanks @bcharleson!
  • -
  • Amp/Ollama: require HTTPS before reattaching imported browser cookies on provider redirects to avoid cleartext cookie exposure (#1226). Thanks @Hinotoi-agent!
  • -
  • Antigravity: filter noisy remote OAuth per-model quota rows, keep consumed noisy rows detail-only, and prevent image/lite/autocomplete/internal rows from driving summary bars (#1209). Thanks @guhyun9454!
  • -
  • Claude: preserve the last good Claude Web usage snapshot across transient Unauthorized refresh failures while still surfacing repeated auth failures (#1220). Thanks @LeoLin990405!
  • -
  • CLI: avoid executing a same-user mutable temporary installer script across the macOS administrator privilege boundary (#1222). Thanks @Hinotoi-agent!
  • -
  • Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217).
  • -
  • Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad!
  • -
  • Menu bar: defer background parent-menu rebuilds until AppKit menu tracking ends so late-arriving usage data cannot stall dropdown hover on macOS 26.5 (#1227).
  • -
  • Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej!
  • -
  • Release: isolate notarization API keys and upload ZIPs in a private per-run temporary directory instead of predictable shared /tmp paths (#1228). Thanks @Hinotoi-agent!
  • -
  • Status: retry startup refreshes a few times after transient offline/network failures so provider status can recover after macOS brings the network online (#1211).
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From d57f1801200adb108f5e601da31ff9dff877206f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 12:39:33 +0100 Subject: [PATCH 62/79] chore: normalize widget package reference --- .../CodexBarWidgetExtension.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index 83a8c7f5..195b28ba 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 = ( - 9FA0A78FB7CA1D877E7BA54B /* codexbar */, + EFBE36CB6481E7133E2A5CF3 /* CodexBar */, ); name = Packages; sourceTree = ""; From 9e6557cc6fa68ff76bbeaf4357ce1ac8b7fc64fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 12:39:37 +0100 Subject: [PATCH 63/79] chore: start 0.32.4 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d35c5c94..b3cb3849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.32.4 — Unreleased + ## 0.32.3 — 2026-06-02 ### Fixed diff --git a/version.env b/version.env index f6cbd5ae..ed69b049 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.32.3 -BUILD_NUMBER=78 +MARKETING_VERSION=0.32.4 +BUILD_NUMBER=79 From 0be735b25a97771bf4d10298b981651326bc336d 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: Tue, 2 Jun 2026 21:31:24 +0800 Subject: [PATCH 64/79] Avoid redundant menu-open refreshes (#1277) --- .../CodexBar/StatusItemController+Menu.swift | 10 +++- .../StatusMenuOpenRefreshTests.swift | 57 ++++++++++++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 02efb265..225331c5 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1109,9 +1109,13 @@ extension StatusItemController { } private func scheduleOpenMenuRefresh(for menu: NSMenu) { - // Queue refresh work until the menu closes. AppKit menu tracking is modal; starting provider refreshes - // while it is active can make the menu feel frozen and can block keyboard focus from returning. - self.deferMenuInteractionRefreshIfNeeded() + // Queue refresh work only when visible menu data is missing or stale. Here "stale" means the last + // 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 key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() self.menuRefreshTasks[key] = Task { @MainActor [weak self, weak menu] in diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 2f3855c2..3d622dd3 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -6,7 +6,7 @@ import Testing extension StatusMenuTests { @Test - func `menu open defers automatic refresh until tracking ends`() async { + func `opening fresh menu does not schedule deferred refresh`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -42,6 +42,59 @@ extension StatusMenuTests { 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() } @@ -776,6 +829,7 @@ extension StatusMenuTests { self.enableOnlyCodex(settings) let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) store.openAIDashboard = nil store.lastOpenAIDashboardSnapshot = nil let providerBlocker = BlockingStatusMenuProviderRefresh() @@ -832,6 +886,7 @@ extension StatusMenuTests { self.enableOnlyCodex(settings) let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) store.openAIDashboard = nil store.lastOpenAIDashboardSnapshot = nil let providerBlocker = BlockingStatusMenuProviderRefresh() From 0ebb5043a0711acdd4e20717ca858a74efadb70d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 14:31:58 +0100 Subject: [PATCH 65/79] docs: add menu refresh changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3cb3849..b9f150f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.4 — Unreleased +### Fixed +- Menu bar: avoid queuing redundant provider refreshes when opening a fresh merged-menu dropdown, while still retrying missing or stale provider data after menu tracking ends (#1235, #1277). Thanks @hhh2210! + ## 0.32.3 — 2026-06-02 ### Fixed From edf3d8dacc2222e0939d0b0e1b2d6d7f8d5f01b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 15:21:28 +0100 Subject: [PATCH 66/79] chore: finalize 0.32.4 changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f150f4..2e4502f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.32.4 — Unreleased +## 0.32.4 — 2026-06-02 ### Fixed - Menu bar: avoid queuing redundant provider refreshes when opening a fresh merged-menu dropdown, while still retrying missing or stale provider data after menu tracking ends (#1235, #1277). Thanks @hhh2210! From 723734ef3422159020ef417e1e93e6a2197f08e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 15:43:47 +0100 Subject: [PATCH 67/79] docs: update appcast for 0.32.4 --- appcast.xml | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/appcast.xml b/appcast.xml index 91faade2..41787fbd 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,22 @@ CodexBar + + 0.32.4 + Tue, 02 Jun 2026 15:43:46 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 79 + 0.32.4 + 14.0 + CodexBar 0.32.4 +

Fixed

+
    +
  • Menu bar: avoid queuing redundant provider refreshes when opening a fresh merged-menu dropdown, while still retrying missing or stale provider data after menu tracking ends (#1235, #1277). Thanks @hhh2210!
  • +
+

View full changelog

+]]>
+ +
0.32.3 Tue, 02 Jun 2026 12:30:56 +0100 @@ -43,25 +59,6 @@ ]]> - - 0.32.1 - Sun, 31 May 2026 19:10:18 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 76 - 0.32.1 - 14.0 - CodexBar 0.32.1 -

Fixed

-
    -
  • Claude: keep Claude CLI-owned OAuth refresh tokens delegated to Claude Code when CLI storage is present, preventing CodexBar from consuming rotating refresh tokens and forcing re-login (#1161, #1239). Thanks @RajvardhanPatil07!
  • -
  • Menu bar: reuse short-lived Codex account reconciliation snapshots so repeated menu rebuilds do not reread local auth state on every open.
  • -
  • Menu bar: defer automatic provider refreshes until after AppKit menu tracking ends so opening the dropdown no longer starts work that can freeze focus and keyboard input.
  • -
  • Menu bar: suppress background keychain and OpenAI dashboard work during startup/menu tracking so the dropdown stays clickable without macOS keychain prompts or WebKit memory spikes.
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 0efa8364717c2fb8ef7f0cccd2764eafeba9755d Mon Sep 17 00:00:00 2001 From: o1xhack Date: Wed, 3 Jun 2026 16:45:15 -0700 Subject: [PATCH 68/79] docs(026): v0.32.x upstream-sync research set + autonomous loop driver (Round 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 0 bootstrap for the v0.31.0 → v0.32.4 sync (open issues #15/16/18/19/20). Scope finding: this batch is light — no new providers, no UsageSnapshot/Shared wire fields → no new wire envelope, no new iOS card. The one required fork code change is Codex parser cache invalidation (CostUsage changed +903/-91 incl. new CostUsageScanner+CodexFastJSON.swift). Everything else is value-correctness that auto-passes through existing synced fields (Antigravity row filter #1209, Copilot zero-entitlement % #1258, Augment parse #1224, Claude snapshot #1220). - 00 overview: scope / per-version summary / feature→path→workload / version target (MARKETING 0.32.4.1 / BUILD 79.1 / MOBILE 1.11.0 TBD) / DONE G1–G10 / goal - 01 design: no new wire/cards; parser cache invalidation + value-fix passthrough + iOS scope decision (default B = ship paired 1.11.0, zero functional code) - 02 development: merge conflict map + parser cache-invalidation procedure (order trap) + CloudKit audit (no Prod deploy expected) - 03 testing: cost-cache invalidation + cross-version compat + regression - PROJECT-PROMPT.md: the autonomous loop driver copy Co-Authored-By: Claude Opus 4.8 (1M context) --- .../026-v032-upstream-sync/00-overview.md | 199 ++++++++++++++++++ .../026-v032-upstream-sync/01-design.md | 60 ++++++ .../026-v032-upstream-sync/02-development.md | 66 ++++++ .../026-v032-upstream-sync/03-testing.md | 45 ++++ .../026-v032-upstream-sync/PROJECT-PROMPT.md | 19 ++ 5 files changed, 389 insertions(+) create mode 100644 CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md create mode 100644 CodexBarMobile/Research/026-v032-upstream-sync/01-design.md create mode 100644 CodexBarMobile/Research/026-v032-upstream-sync/02-development.md create mode 100644 CodexBarMobile/Research/026-v032-upstream-sync/03-testing.md create mode 100644 CodexBarMobile/Research/026-v032-upstream-sync/PROJECT-PROMPT.md diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md new file mode 100644 index 00000000..55e92ddc --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md @@ -0,0 +1,199 @@ +# 026 — v0.32.x 上游同步 · 总体文档 + +**Status:** ready +**Date:** 2026-06-03 +**Target release tag:** `v0.32.4.1-mobile.1.11.0`(MOBILE 段待 G4 确认,可能为 Mac-only `1.10.0`) +**Branch:** `upstream-sync/v0.32.4-mobile.1.11.0` +**文档集:** 本目录共 4 份 — +[00 总体](00-overview.md) · [01 设计](01-design.md) · [02 开发+架构](02-development.md) · [03 测试](03-testing.md) + +--- + +## ⭐ 最终目标版本号(锁定)+ 完成确认 + +> 本目标的**验收锚点**。每轮循环结束都对照此处:版本号是否 stamp 对、DONE 是否全勾。 +> 只有下方版本号已落定 **且** G1–G10 全部勾选,才可对用户宣告"全部工作已完成"。 + +**最终版本号(达成时必须 stamp 成这些值,依 `docs/versioning.md`):** + +| 端 | 最终版本 | 落点文件 | +|---|---|---| +| **Mac** | MARKETING `0.32.4.1` · BUILD `79.1`(上游 v0.32.4 BUILD=79 + fork `.1`)· UPSTREAM `v0.31.0`→`v0.32.4`(**发布后才 bump**,F1) | `version.env` | +| **iOS** | MOBILE_VERSION `1.11.0`(**若 G4 判定零 iOS 代码改动则保持 `1.10.0`、走 Mac-only**)· BUILD `148`+ | `CodexBarMobile/project.yml` + `version.env` | +| **Sparkle / Release tag** | `sparkle:version` `79.1.1.11.0` · tag `v0.32.4.1-mobile.1.11.0`(或 `…-mobile.1.10.0`) | appcast / GitHub release | + +> ⚠️ **关键设计决策(G4)**:本批上游**无新 wire 字段、无新 iOS 卡片**(见 §5)。iOS 端是 +> "纯验证 + 可选小增强"。发布前定:iOS 是否 ship 新 build(MOBILE→1.11.0)还是 Mac-only +> (MOBILE 保持 1.10.0,不上 TestFlight)。默认倾向 ship 配套 iOS(PM 指令"尽可能全支持" +> + release notes 刷新),但若确无 iOS 代码改动,Mac-only 亦合规。 + +**完成确认(DONE —— 全部勾选才算"全部工作完成"):** + +- [ ] **G1 · Mac 合并**:`git merge v0.32.4`(67 commits)干净、`swift build` 绿、冲突全解 +- [ ] **G2 · Codex parser 缓存失效**(本轮核心):CostUsage 大改(+903/-91,含新 `CostUsageScanner+CodexFastJSON.swift`)→ `Scripts/regenerate-codex-parser-hash.sh` + bump `parserLogicVersion`;全量 `Scripts/lint.sh lint` 绿 +- [ ] **G3 · 值修正自动透传验证**:Antigravity 配额行过滤(#1209)、Copilot 零权利 %(#1258)、Augment 解析(#1224)、Claude 快照保留(#1220)经现有 synced 字段透传 — grep 确认 + iOS 实机 = 用户 QA +- [ ] **G4 · iOS 面定稿**:判定 iOS scope(纯验证 / 可选 provider 搜索 #1184)+ 定 MOBILE 版本(1.11.0 或 Mac-only 1.10.0) +- [ ] **G5 · i18n / release notes**:若 iOS ship → 4 语 xcstrings + `MobileReleaseNotesCatalog` 1.11.0 条目 + CHANGELOG(Mac + iOS) +- [ ] **G6 · 测试**:全量 `swift test` 绿(含 cost-cache 失效单测)+ 跨版本兼容回归(2 Mac×2 iOS 不崩) +- [ ] **G7 · Code Review**:独立 Opus 4.7 agent CR loop 评审 fork 改动 → 零 findings +- [ ] **G8 · 版本号 stamp**:`version.env` + `project.yml` + `xcodegen` 重生成 +- [ ] **G9 · CloudKit 审计**:本批无新 CKRecord 字段(无新 wire 结构)→ 预判**无需** Prod schema deploy;按 `docs/cloudkit-deploy-audit.md` 正式过审 +- [ ] **G10 · 发布**:Mac 签名公证 + draft→publish + appcast + 装机;(iOS TestFlight 若 ship);合并分支 → `mobile-dev`;关 issue #15/16/18/19/20;bump `version.env` UPSTREAM_VERSION + +**当前进度:0 / 10(Round 0 起步:分支 + 文档集已建)。** + +**`/goal` 自动循环完成条件**(每回合自动复检): + +```text +v0.32.x 同步达到「可发布前完成态」,且以下每项都在本会话由命令输出或文件内容证明, +四份文档 00–03 已回写到与代码一致: +(1) git merge v0.32.4 完成、git status 干净; +(2) swift build 退出 0、xcodebuild -scheme CodexBarMobile 构建成功; +(3) CostUsage 缓存失效已处理:CodexParserHash 重生成 + parserLogicVersion bump,全量 lint.sh lint 绿; +(4) swift test 全绿(含 cost-cache 失效 + 跨版本兼容场景); +(5) Scripts/lint.sh 通过、(若 iOS ship)xcstrings 4 语齐、无 state:"new"; +(6) version.env = MARKETING 0.32.4.1 / BUILD 79.1 / MOBILE(1.11.0 或 1.10.0)/ UPSTREAM v0.31.0(发布前不 bump);project.yml stamp 对; +(7) 本 ⭐ 节 DONE 计数 = 9/10(G1–G9 勾选)、四份文档「修订记录」已更新到本轮; +(8) 最近一轮做过防回归复验且通过。 +到 9/10 即停并交回用户;或在 40 回合后停止并汇报当前 X/10。 +``` + +--- + +## 1. 一句话目标 + +把上游 `steipete/CodexBar` 从 **v0.31.0 → v0.32.4**(即 `v0.32.0/0.32.1/0.32.2/0.32.3/0.32.4` 五个 tag,对应 open issue #15/#16/#18/#19/#20)**所有用户可见的显示数据 + 数值修正**同步到 fork 的 Mac 与 iOS 端,**一次合并发布**,不拆版本。 + +宗旨(PM 指令):**只要 Mac 端新增的显示内容 iOS 能显示,就尽可能全部支持;除非与现有基础架构完全冲突才放弃。** + +--- + +## 2. 当前状态 / 起点 + +| 维度 | 当前值 | 来源 | +|---|---|---| +| 已对齐上游 tag | `v0.31.0` | `version.env: UPSTREAM_VERSION` | +| Mac MARKETING_VERSION | `0.31.0.2` | `version.env` | +| Mac BUILD_NUMBER | `73.2` | `version.env` | +| MOBILE_VERSION | `1.10.0` | `version.env` | +| iOS project.yml | MARKETING `1.10.0` / BUILD `147` | `CodexBarMobile/project.yml` | +| 上游 v0.32.4 BUILD | `79`(appcast `sparkle:version`) | upstream `v0.32.4:appcast.xml` | + +--- + +## 3. 范围(open issue 驱动) + +`gh issue list --state open --label upstream-sync` → #15(v0.32.0) · #16(v0.32.1) · #18(v0.32.2) · #19(v0.32.3) · #20(v0.32.4)。整合成一次合并到 `v0.32.4`。 + +上游 `v0.31.0..v0.32.4` = **67 commits / 122 files / +8211 -699**(大头在 Mac UI/perf/tests/docs + Codex parser 重写)。 + +--- + +## 4. 上游逐版本变更摘要(仅列与显示/数据相关者) + +### v0.32.0(#15) +- **Antigravity OAuth 配额行过滤**(#1209)— 过滤噪声远程 OAuth 配额行,仅显示已消耗行,阻止 image/lite/autocomplete/internal 行污染汇总进度条。**改变显示数据**,经现有 Antigravity 透传。 +- **Copilot**(见 v0.32.3 #1258 修复);**Augment 解析更新 + cookie fallback**(#1224)— 数据源修正,经现有字段透传。 +- **Claude 保留最后有效 Web 用量快照**(#1220)— 短暂 Unauthorized 期间不清零,可靠性/新鲜度。 +- **Settings Provider 搜索**(#1184)— Mac UI;iOS 可选小增强(P3)。 +- 其余(Amp/Ollama HTTPS cookie 安全 #1226、CLI 临时脚本隔离 #1222、Codex WebKit 刷新取消 #1217、Menu Codex 附件刷新 #1150、菜单栏定位 #1216/#1227、公证路径隔离 #1228、Status 启动重试 #1211)— Mac 安全/性能/可靠性,**无新 iOS 显示字段**。 + +### v0.32.1(#16) +- **全部 Mac 可靠性/性能**:Claude OAuth refresh-token 委托 CLI(#1239,防强制重登)、菜单栏性能、输入响应、启动稳定。**无新显示字段。** + +### v0.32.2(#18) +- **Codex token-cost 扫描器优化**(性能)— **触及 Codex parser**(`CostUsage/`),见 §5 #1 缓存失效。 +- QA 文档、菜单栏留白。**无新显示字段。** + +### v0.32.3(#19) +- **Copilot 零权利(zero-entitlement)配额修复**(#1258)— 防止显示误导性用量百分比。**数值/显示修正**,经现有 Copilot 字段透传。 +- 菜单栏定位、SVG 缓存、菜单响应、OpenAI Web 稳定性 — Mac 性能/可靠性。 + +### v0.32.4(#20) +- **菜单栏 provider 刷新优化**(#1277)— Mac-only,**无新显示字段**。 + +--- + +## 5. 特性清单 → 同步路径 → fork 工作量 + +> 三条路径:**(A) 通用 lane 自动透传**(进 `rateWindows[]`,iOS 通用渲染);**(B) 数值修复自动透传**(合并即经现有字段纠正);**(C) 新 envelope**(新 optional `SyncXxx` + 新 iOS 卡)。 + +| # | 特性 | 版本 | 路径 | fork 工作量 | +|---|---|---|---|---| +| 1 | **Codex parser 重写**(FastJSON #?, truncated prefix, 扫描性能) | 0.32.0–0.32.2 | **缓存失效** | **regenerate CodexParserHash + bump parserLogicVersion**(本轮唯一必须的 fork 代码改动) | +| 2 | **Antigravity 配额行过滤** #1209 | 0.32.0 | A 自动 | 无(透传);验证 iOS Antigravity 卡显示过滤后的行 | +| 3 | **Copilot 零权利 %** #1258 | 0.32.3 | B 自动 | 无;验证 iOS Copilot % 不再误导 | +| 4 | **Augment 解析 + cookie fallback** #1224 | 0.32.0 | B 自动 | 无;验证 iOS Augment 数值 | +| 5 | **Claude 快照保留** #1220 | 0.32.0 | B 自动 | 无;验证 iOS Claude 不闪空/旧 | +| 6 | **Claude OAuth refresh 委托** #1239 | 0.32.1 | B 自动(Mac 认证) | 无 iOS 显示影响(Mac 凭证健康) | +| 7 | **Settings Provider 搜索** #1184 | 0.32.0 | — | Mac UI;iOS 可选 provider 列表搜索(P3,默认跳过,除非 G4 决定做) | +| — | 菜单栏/性能/安全/CLI/release | 0.32.x | — | Mac-only,N/A iOS | + +**结论:本批 fork 侧极轻 —— 无新 wire envelope、无新 iOS 卡片。** 唯一必须的代码改动是 +**Codex parser 缓存失效(G2)**;其余全是经现有 synced 字段自动透传的数值修正(验证即可)。 +iOS 可能**零代码改动**(→ Mac-only),或仅做可选 provider 搜索 + release notes 刷新。 + +详细字段落点见 [01 设计文档](01-design.md)。 + +--- + +## 6. 版本目标(依 `docs/versioning.md`) + +| 变量 | From | To | 规则 | +|---|---|---|---| +| `MARKETING_VERSION`(Mac) | `0.31.0.2` | **`0.32.4.1`** | 前 3 段照抄上游 `v0.32.4`;fork 段回 `.1` | +| `BUILD_NUMBER`(Mac) | `73.2` | **`79.1`** | 上游 v0.32.4 BUILD=79 + fork `.1` | +| `MOBILE_VERSION` | `1.10.0` | **`1.11.0`**(或保持 `1.10.0` Mac-only) | 待 G4 定 | +| `UPSTREAM_VERSION` | `v0.31.0` | **`v0.31.0`→`v0.32.4`(G10 发布后)** | confirmed-shipped,发布后才 bump | +| iOS `CURRENT_PROJECT_VERSION` | `147` | **`148`+** | 每次 commit +1 | +| `sparkle:version` | `73.2.1.10.0` | **`79.1.1.11.0`**(或 `79.1.1.10.0`) | `BUILD.MOBILE` 5 段单调递增 | +| Release tag | `v0.31.0.2-mobile.1.10.0` | **`v0.32.4.1-mobile.1.11.0`** | `v{MARKETING}-mobile.{MOBILE}` | + +--- + +## 7. 阶段计划(PM 6 步落进循环) + +| 阶段 | 内容 | 闸门 | +|---|---|---| +| **A. Mac 合并** | `git merge v0.32.4`;解 fork-owned 冲突;`swift build` 绿 | 干净构建 | +| **B. parser 缓存失效** | regenerate hash + bump parserLogicVersion;全量 lint | lint 绿 | +| **C. 值修正透传 + iOS 面定** | grep 验证 #1209/#1258/#1224/#1220 经现有字段流过;定 iOS scope + MOBILE 版本 | 设计 ready | +| **D. Mac 草稿发布** | CloudKit 审计;sign-notarize;appcast | draft(用户 Mac 凭证) | +| **E. Mac 端到端 + 回归** | 全量 `swift test`;cost-cache 失效验证;跨版本同步走查 | 无回归 | +| **F. iOS(若 ship)** | project.yml bump + xcodegen;4 语 + release notes;冒烟 + lint | iOS 构建 | +| **G. 发布 + 收尾** | TestFlight(若 ship);publish + appcast;合并 mobile-dev;关 issue | 用户手里可装 | + +**CR 闸门**:每关键阶段后独立 Opus 4.7 agent CR loop,清干净再 bump 版本打包。 + +--- + +## 8. 风险 + +| # | 风险 | 缓解 | +|---|---|---| +| R1 | **CostUsage parser 大改(+903)→ 缓存失效轴漏滚** | 本轮已知必做 G2:regenerate hash + bump parserLogicVersion + 全量 lint(吸取 0.31.0.1 教训,见 memory `parser-cache-invalidation-on-upstream-merge`) | +| R2 | 67 commit 合并引入旧特性回归 | 阶段 E 全量回归 + 全 `swift test` | +| R3 | Antigravity 行过滤 #1209 改变现有显示 → iOS 旧缓存/旧 Mac 混用时不一致 | 跨版本兼容场景(03);经现有 rateWindows 透传,加 mock | +| R4 | Copilot #1258 零权利 % 修复后 iOS 端显示口径变化 | 验证 iOS Copilot 卡;属 B 自动透传 | +| R5 | 无新 wire 字段却误触 CloudKit schema | 初判否(无新 CKRecord 字段);阶段 D 正式审计 | +| R6 | iOS 实为零代码改动却强行 ship 1.11.0 | G4 显式决策 Mac-only vs iOS ship | + +--- + +## 9. 与项目护栏的一致性 + +- 不改上游 Mac-only 逻辑:仅 `git merge` + fork-owned `Sources/CodexBar/Sync/`(bridge)+ `Shared/`(wire)+ `CodexBarMobile/`。`Sources/CodexBarCore/` 上游内容只读(含 CostUsage —— 只跑 regenerate 脚本 + 改 `parserLogicVersion`,不改 parser 逻辑)。 +- 不推 upstream,只推 `origin`。不跳本地化(若 iOS ship,4 语齐)。每次 commit bump `CURRENT_PROJECT_VERSION`。不手编 .xcodeproj(`xcodegen`)。 +- **Definition of Done** = 已签名公证 + 发到用户手里(见 `docs/RELEASE-CHECKLIST.md`),不是 commit。 +- **CR before package**:清干净再打包。 +- **parser 缓存失效护栏**:见 R1 + memory。 + +--- + +## 10. 执行轮次记录(Round log) + +### Round 0 — 起步(2026-06-03) +- 读 5 个 open issue(#15/16/18/19/20)定范围 = v0.32.0→v0.32.4 一次合并。`git fetch upstream --tags` 拉到 v0.32.x。 +- 上游 `v0.31.0..v0.32.4` = 67 commits / 122 files / +8211 -699。**关键画像**:无新 provider、无 `UsageSnapshot`/`Shared/Models` 新字段 → **无新 wire envelope、无新 iOS 卡片**;CostUsage parser 大改(+903,含新 `CostUsageScanner+CodexFastJSON.swift`)→ 必做 G2 缓存失效。 +- 版本目标定(依 `docs/versioning.md`):MARKETING `0.32.4.1` / BUILD `79.1`(上游 79)/ MOBILE `1.11.0`(待 G4)/ tag `v0.32.4.1-mobile.1.11.0`。 +- 建分支 `upstream-sync/v0.32.4-mobile.1.11.0`,生成本文档集 00–03 + PROJECT-PROMPT.md。**进度 0/10**,下一步进 Round 1(Phase A:`git merge v0.32.4`)。 diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/01-design.md b/CodexBarMobile/Research/026-v032-upstream-sync/01-design.md new file mode 100644 index 00000000..f3ca62ff --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/01-design.md @@ -0,0 +1,60 @@ +# 026 — v0.32.x 同步 · 设计文档 + +[00 总体](00-overview.md) · **01 设计** · [02 开发+架构](02-development.md) · [03 测试](03-testing.md) + +--- + +## 1. 一句话设计 + +本批上游**无新 wire 字段、无新 iOS 卡片**([00 §5](00-overview.md) 已确认:无新 provider、`UsageSnapshot`/`Shared/Models` 零变动)。设计 = 三件事: +1. **Codex parser 缓存失效**(唯一必须的 fork 代码改动); +2. **值修正经现有 synced 字段自动透传**(验证为主,零 iOS 代码); +3. **iOS scope 决策**(Mac-only vs ship 1.11.0)。 + +--- + +## 2. 字段级落点(逐特性) + +### 2.1 Codex parser 重写 → 缓存失效(路径:缓存失效,非 wire) +- 上游改了 `Sources/CodexBarCore/Vendored/CostUsage/`(+903/-91,含新 `CostUsageScanner+CodexFastJSON.swift`、`CostUsageScanner.swift` +646、`CostUsagePricing.swift` +10)。 +- **不新增 wire 字段**:Codex/Claude 成本仍经现有 `SyncCostSummary` / 成本卡同步。 +- **失效轴**(见 `CostUsageCache.swift`): + - `producerKey`(codex-only)= `"codex:cu:p"` → 跑 `Scripts/regenerate-codex-parser-hash.sh` 滚动。 + - `pricingFingerprint`(全 provider,Claude 唯一轴)= `"v|codex=…|claude=…"` → bump `parserLogicVersion` 滚动(`CostUsagePricing.swift` 若新增定价条目也会滚,但仍显式 bump 以覆盖 Claude scanner 改动)。 +- **落点**:`CodexParserHash.generated.swift`(脚本生成)+ `CostUsagePricing.swift` 的 `parserLogicVersion N→N+1` + 历史注释。**Mac 端缓存重扫 → 纠正后的成本数据自动同步到 iOS(零 iOS 改动)。** + +### 2.2 Antigravity 配额行过滤 #1209(路径 A 自动透传) +- Mac 侧 `AntigravityStatusProbe.swift`(+160)过滤噪声 OAuth 配额行后,经现有 `extraRateWindows` / `rateWindows[]` 同步;iOS `ProviderUsageView.ForEach(allRateWindows)` 自动渲染过滤后的行。**零 iOS 代码**;验证 iOS Antigravity 卡显示更干净。 + +### 2.3 Copilot 零权利 % #1258(路径 B 自动透传) +- Mac `CopilotUsageFetcher.swift`(+15)修正 zero-entitlement 场景的 %;经现有 Copilot synced 字段透传。**零 iOS 代码**;验证 iOS Copilot 卡不再误导。 + +### 2.4 Augment 解析 + cookie fallback #1224(路径 B) +- Mac `Auggie*`/`Augment*`(解析格式更新 + 浏览器 cookie fallback);数值经现有 Augment synced 字段透传。**零 iOS 代码**;验证数值正确。 + +### 2.5 Claude 快照保留 #1220 / OAuth 委托 #1239(路径 B,可靠性) +- Mac `ClaudeOAuthCredentials.swift`(+98)等;短暂 Unauthorized 不清零 + refresh-token 委托 CLI。提升 Mac 端数据新鲜度/凭证健康,经现有 Claude synced 字段透传。**零 iOS 代码**;验证 iOS Claude 不闪空。 + +--- + +## 3. iOS scope 决策(G4) + +| 选项 | 内容 | MOBILE | 适用 | +|---|---|---|---| +| **A. Mac-only** | iOS 零代码改动,值修正经同步自动到达 iOS;不发 iOS build | `1.10.0` 不动 | 若确认无任何 iOS 可见增量 | +| **B. iOS 配套 ship**(默认倾向) | 零功能代码,但刷新 `MobileReleaseNotesCatalog`(1.11.0 条目说明本批值修正)+ 版本 bump,配套上 TestFlight | `1.11.0` | PM"尽可能全支持" + release notes 一致性 | +| **C. iOS + 可选增强** | B + 实现 provider 列表搜索(对应上游 Settings 搜索 #1184) | `1.11.0` | 仅当用户明确要这个增强 | + +**默认走 B**(配套 ship,零功能代码,只 release notes + 版本)。Round 1 合并后复核确无 iOS 代码改动需求即锁定 B;若用户要 provider 搜索则转 C。 + +--- + +## 4. Mock / i18n + +- **无新结构 → 无需新 mock**(除非走 C 加 provider 搜索)。 +- **i18n**:仅当走 B/C 且新增 `MobileReleaseNotesCatalog` 1.11.0 条目时,新文案 4 语(en/zh-Hans/zh-Hant/ja),照 025 的 xcstrings 加法(json.load → 加 key → dump 不排序,零 churn)。 + +--- + +## 修订记录 +- **Round 0(2026-06-03)**:初稿。确认无新 wire/卡片;设计聚焦 parser 缓存失效 + 值修正透传验证 + iOS scope 决策(默认 B 配套 ship)。 diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/02-development.md b/CodexBarMobile/Research/026-v032-upstream-sync/02-development.md new file mode 100644 index 00000000..f2bd0661 --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/02-development.md @@ -0,0 +1,66 @@ +# 026 — v0.32.x 同步 · 开发 + 架构 + +[00 总体](00-overview.md) · [01 设计](01-design.md) · **02 开发+架构** · [03 测试](03-testing.md) + +--- + +## 1. Phase A — 合并(Round 1) + +`git checkout upstream-sync/v0.32.4-mobile.1.11.0`(已建,从 mobile-dev)→ `git merge v0.32.4`。 + +**预期冲突面(照 fork 历史解):** +- **Fork 元文件**:`AGENTS.md`(ours)、`CHANGELOG.md` / `README*.md`(两侧都留)、`appcast.xml`(ours)、`version.env`(目标值)。 +- **Mac 发布脚本**(`package_app.sh` / `sign-and-notarize.sh` / `compile_and_run.sh`):保留 fork 手写 widget/CloudKit 打包;**但要确认 ours 没依赖上游已删函数**(memory `fork-script-conflict` + 025 R9 的 `generate_widget_appintents_metadata` 教训)。 +- **CostUsage(`Sources/CodexBarCore/Vendored/`)**:**这是上游代码,取 upstream 整块**(fork 不拥有 parser 逻辑)。合并后再跑缓存失效脚本。 +- **核心 fork 代码**(`Shared/`、`Sources/CodexBar/Sync/`、`CodexBarMobile/`):预期零冲突(本批无新 wire 字段)。 + +闸门:`swift build` 绿。 + +--- + +## 2. Phase B — Codex parser 缓存失效(Round 1/2,本轮核心) + +合并后 CostUsage 必然变化(+903)。**必做两步**(memory `parser-cache-invalidation-on-upstream-merge`): + +```bash +bash Scripts/regenerate-codex-parser-hash.sh # 滚动 Codex producerKey(hash → 新值) +# 编辑 Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift: +# static let parserLogicVersion = N → N+1 (当前 4 → 5) +# + 在 History 注释加 `- 5 (0.32.4.1): v0.32.x Codex 扫描器重写…` 一条 +bash Scripts/regenerate-codex-parser-hash.sh # parserLogicVersion 改完再跑一次(脚本 hash 整个 CostUsage 目录) +Scripts/lint.sh lint # 全量:swiftformat + swiftlint + i18n + parser-version + parser-hash +``` + +> **顺序坑**(025 踩过):先 bump parserLogicVersion 再 regenerate hash(脚本 hash 整个 `Vendored/CostUsage` 目录,含 CostUsagePricing.swift),否则要 regenerate 两次。 +> **为什么两轴都要**:Codex 走 producerKey(hash),Claude 只走 pricingFingerprint(parserLogicVersion)。只滚 hash 治不了 Claude。 +> **为什么 lint 要全量**:`audit-parser-version` 是 base...HEAD 前向的,合并把 parser 改动落在 base 上抓不到;只有 `audit-parser-hash`(绝对)能抓。 + +--- + +## 3. Phase C — bridge / wire(预期零改动) + +本批无新显示字段 → `Shared/Models/`、`SyncCoordinator.swift` 预期不动。合并后 grep 确认: +```bash +git diff f^..HEAD -- Shared/ Sources/CodexBar/Sync/ # 应只有冲突解决,无新 mapper +``` +若上游某 provider 的现有 synced 字段语义变了(如 Antigravity 行过滤改变 rateWindows 内容),属数据内容变化而非 schema 变化,无需 bridge 改动。 + +--- + +## 4. Phase D — CloudKit 审计 + +按 `docs/cloudkit-deploy-audit.md`:本批**无新 CKRecord 字段 / record type / zone / 索引**(无新 wire 结构,`CloudConstants.swift` 不动)→ **预判无需 Prod schema deploy**。发布前 grep 确认 `CloudConstants` 未变 + `providerPayloadVersion` 不变,历史存档记一笔。 + +--- + +## 5. 版本 stamp + 工程 + +- `version.env`:MARKETING `0.32.4.1` / BUILD `79.1`(MOBILE 待 G4;UPSTREAM 发布后才 bump)。 +- `CodexBarMobile/project.yml`:`CURRENT_PROJECT_VERSION` 147 → 148+(每 commit +1)。 +- `xcodegen generate --spec CodexBarMobile/project.yml`。 +- CHANGELOG:root(0.32.4.1 段,双语、converter-clean)+ iOS(若 ship,build 148 段)。 + +--- + +## 修订记录 +- **Round 0(2026-06-03)**:初稿。合并冲突面预判 + parser 缓存失效标准流程(含顺序坑)+ CloudKit 预判无需 deploy。 diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/03-testing.md b/CodexBarMobile/Research/026-v032-upstream-sync/03-testing.md new file mode 100644 index 00000000..4fbf29cf --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/03-testing.md @@ -0,0 +1,45 @@ +# 026 — v0.32.x 同步 · 测试 + +[00 总体](00-overview.md) · [01 设计](01-design.md) · [02 开发+架构](02-development.md) · **03 测试** + +--- + +## 1. Codex parser 缓存失效(本轮核心,可自动验证) + +- `swift test --filter CostUsageCacheTests` 全绿(含 "pricingFingerprint includes parser logic version"、"rolls when price changes"、"non codex cache does not require producer key")。 +- 全量 `Scripts/lint.sh lint`:`Codex parser hash is current (<新hash>)` + `parser-version audit` 通过。 +- **语义验证**:parserLogicVersion N→N+1 使 `pricingFingerprint` 变 → 升级用户 Codex+Claude 成本缓存失效重扫(对照 0.31.0.1→0.31.0.2 的修复路径)。 + +## 2. 跨版本兼容(2 Mac × 2 iOS,用户真机 QA) + +| Mac \ iOS | iOS 1.10.0(旧) | iOS 1.11.0/Mac-only(新) | +|---|---|---| +| **73.2(旧)** | 现状基线 | 旧 Mac 不发新内容,新 iOS 回退渲染 | +| **79.1(新)** | 新 Mac 值修正经同步到旧 iOS,**旧 iOS 通用渲染不崩** | 全新组合 | + +重点: +- **Antigravity 行过滤 #1209**:新 Mac 发过滤后的 rateWindows,旧/新 iOS `ForEach(allRateWindows)` 都正常渲染(行变少,不崩)。 +- **Copilot % #1258**:新 Mac 发修正后的 %,iOS 显示正确口径。 +- 任意组合**无崩溃 / 无丢数据**。 + +## 3. 回归(防 67 commit 引入旧特性回归) + +- 全量 `swift test`:注意 `SyncCoordinatorTests` 并行 flake(memory `swift-test-parallel-flake`)—— `--no-parallel --filter SyncCoordinatorTests` 串行确认。 +- 逐 provider / 菜单 / 设置走查(Mac);CloudKit Mac→iOS sim 同步。 +- 重点查 Codex 成本卡(parser 重写后)数值合理、std/fast/Spark lane 不回退。 + +## 4. 值修正可视化验证(用户真机 QA) + +- Antigravity 卡:配额行更干净(无 image/lite/autocomplete/internal 噪声行)。 +- Copilot 卡:zero-entitlement 账户不再显示误导 %。 +- Augment 卡:解析更新后数值正确。 +- Claude:短暂 Unauthorized 期间不闪空/不清零。 + +## 5. iOS(若 G4 走 ship) + +- `xcodebuild -sdk iphonesimulator` 冒烟;`MobileReleaseNotesCatalog` 1.11.0 条目 4 语渲染;`Scripts/lint.sh` i18n 全译无 `state:"new"`。 + +--- + +## 修订记录 +- **Round 0(2026-06-03)**:初稿。测试矩阵聚焦 parser 缓存失效 + 跨版本透传不崩 + 值修正真机验证。 diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/PROJECT-PROMPT.md b/CodexBarMobile/Research/026-v032-upstream-sync/PROJECT-PROMPT.md new file mode 100644 index 00000000..0e98823b --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/PROJECT-PROMPT.md @@ -0,0 +1,19 @@ +# CodexBar Mobile — 上游同步 · 自治循环驱动(高阶提示词) + +> 本轮(026)的 `/goal` 驱动。本文件是提示词副本;规格在 00–03 + 仓库文档里。 + +你是 CodexBar Mobile 的开发 + 发布代理。**所有规格——流程、版本号、护栏——都在仓库文档里;你的职责是:读文档 → 按文档做 → 把进度和发现写回文档 → 重复,直到发到用户手里。不要在本提示词里重复文档内容。** + +**范围怎么来:** 仓库有自动化流程,会把每个上游新版本建成一个「上游同步」issue。所以跑 `gh issue list --repo o1xhack/CodexBar-Mobile --state open --label upstream-sync`,所有 open 项就是本轮范围——整合成一次合并(Mac + iOS 同一版本,不拆),取最高 tag 为目标。逐个 `gh issue view` 读正文定特性清单。 + +**事实来源(照做即可):** `AGENTS.md` + `CLAUDE.md`(完整流程 + 护栏)、`docs/versioning.md`(版本号规则)、`docs/RELEASE-CHECKLIST.md`(Definition of Done + 验收清单)、`docs/cloudkit-deploy-audit.md`(是否需 Prod deploy)。 + +**Round 0(只一次):** 按 open issue 范围建分支,并在 `CodexBarMobile/Research/<下一个编号>-<目标tag>-upstream-sync/` 自动生成调研文档集(照上一轮 `025-v031-upstream-sync/` 的四份结构:`00` 目标/范围/特性清单→同步路径/DONE 清单 + `/goal` 条件、`01` 字段级设计、`02` 开发+架构、`03` 测试矩阵)。写完即进循环。 + +**每一大轮:** ① 读四份文档 + DONE 计数 + `git status` 定位下一单元 → ② 做+测+**独立 Opus 4.7 agent CR loop 到零 findings**(没干净不许打包)→ ③ 回写文档(进度/发现/决策+修订记录,没回写=没完成)→ ④ 复跑 build+test 防回归 → ⑤ 重复,直到 DONE 清单全满足。 + +**工作顺序(PM 指定):** ① Mac 全量同步、完全兼容上游,期间定 iOS 要做什么(尽可能全支持)→ ② Mac 补齐 iOS 显示所需(wire+bridge+mock)→ ③ Mac draft release(版本号按 `docs/versioning.md`)→ ④ 测 Mac(新+老+回归,查改动是否带来老功能 BUG)→ ⑤ iOS 同样四步、一个版本覆盖全部不拆 → ⑥ 收口:测试完善、彻底解决兼容、新功能完美。 + +**特别注意(本轮踩过的坑):** 合并若动了 `Sources/CodexBarCore/Vendored/CostUsage/`(Codex/Claude 成本 parser),必须 `Scripts/regenerate-codex-parser-hash.sh` + bump `parserLogicVersion`,并跑**全量** `Scripts/lint.sh lint`,否则升级用户成本缓存不失效。 + +**完成判据:** 发到用户手里(Mac 签名公证+Sparkle appcast + iOS TestFlight),不是 commit 了。遇用户环节(TestFlight/凭证/签名/CloudKit deploy 决策)停下交回。发布后关掉本轮 open issue + bump `version.env` UPSTREAM_VERSION。每轮用**中文**简报。 From 5d8f6167721ee2ebad32880fd4f55c21539e0c6a Mon Sep 17 00:00:00 2001 From: o1xhack Date: Wed, 3 Jun 2026 16:53:15 -0700 Subject: [PATCH 69/79] =?UTF-8?q?fix(cost):=20roll=20cache=20invalidation?= =?UTF-8?q?=20for=20v0.32.x=20Codex=20parser=20merge=20(parserLogicVersion?= =?UTF-8?q?=204=E2=86=925=20+=20hash=20regen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.32.0–v0.32.4 rewrote the Codex cost-usage scanner (CostUsage +903/-91, new CostUsageScanner+CodexFastJSON.swift). Roll both invalidation axes so upgrading users re-scan instead of keeping old-parser attributions: - regenerate CodexParserHash → 518924b891f96a03 (Codex producerKey axis) - bump parserLogicVersion 4→5 (pricingFingerprint axis — Claude's only axis) Per memory parser-cache-invalidation-on-upstream-merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 5b14098c..1ab81023 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 = "85d054bc70dae105" + static let value = "518924b891f96a03" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index d3f03770..9ee2ca2c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -363,6 +363,12 @@ enum CostUsagePricing { /// `CostUsageJsonl.swift` change vs origin/mobile-dev. /// /// History: + /// - `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 + /// producerKey axis; this parserLogicVersion bump rolls the pricingFingerprint so + /// the Claude axis (no producerKey) also invalidates caches written by the v0.31 + /// parser and re-scans with the merged scanner. /// - `4` (0.31.0.2): merged upstream v0.29.1→v0.31.0 cost-scanner /// changes — Codex `CostUsageScanner` rewrite (Spark model lane #1195, /// reworked token attribution) and `CostUsageScanner+Claude` now threads @@ -386,7 +392,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 = 4 + static let parserLogicVersion = 5 /// Stable string fingerprint of the pricing tables + parser logic. /// `CostUsageCacheIO.load` compares this against the value stored From cd641627f366e8534cf9ad3ad1ad88b6667b46ca Mon Sep 17 00:00:00 2001 From: o1xhack Date: Wed, 3 Jun 2026 16:54:32 -0700 Subject: [PATCH 70/79] =?UTF-8?q?docs(026):=20R1=20=E2=80=94=20G1=20merge?= =?UTF-8?q?=20+=20G2=20parser=20cache=20invalidation=20done=20(2/10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Research/026-v032-upstream-sync/00-overview.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md index 55e92ddc..ba6944b1 100644 --- a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md +++ b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md @@ -29,8 +29,8 @@ **完成确认(DONE —— 全部勾选才算"全部工作完成"):** -- [ ] **G1 · Mac 合并**:`git merge v0.32.4`(67 commits)干净、`swift build` 绿、冲突全解 -- [ ] **G2 · Codex parser 缓存失效**(本轮核心):CostUsage 大改(+903/-91,含新 `CostUsageScanner+CodexFastJSON.swift`)→ `Scripts/regenerate-codex-parser-hash.sh` + bump `parserLogicVersion`;全量 `Scripts/lint.sh lint` 绿 +- [x] **G1 · Mac 合并**:`git merge v0.32.4`(67 commits)干净、`swift build` 绿(21s)、5 冲突全解 — 提交 `6d3e54d4`(R1) +- [x] **G2 · Codex parser 缓存失效**(本轮核心):`regenerate-codex-parser-hash.sh` → hash `518924b891f96a03` + `parserLogicVersion` 4→5;全量 `Scripts/lint.sh lint` 绿(parser-version + hash 审计均 OK)— 提交 `5d8f6167`(R1) - [ ] **G3 · 值修正自动透传验证**:Antigravity 配额行过滤(#1209)、Copilot 零权利 %(#1258)、Augment 解析(#1224)、Claude 快照保留(#1220)经现有 synced 字段透传 — grep 确认 + iOS 实机 = 用户 QA - [ ] **G4 · iOS 面定稿**:判定 iOS scope(纯验证 / 可选 provider 搜索 #1184)+ 定 MOBILE 版本(1.11.0 或 Mac-only 1.10.0) - [ ] **G5 · i18n / release notes**:若 iOS ship → 4 语 xcstrings + `MobileReleaseNotesCatalog` 1.11.0 条目 + CHANGELOG(Mac + iOS) @@ -40,7 +40,7 @@ - [ ] **G9 · CloudKit 审计**:本批无新 CKRecord 字段(无新 wire 结构)→ 预判**无需** Prod schema deploy;按 `docs/cloudkit-deploy-audit.md` 正式过审 - [ ] **G10 · 发布**:Mac 签名公证 + draft→publish + appcast + 装机;(iOS TestFlight 若 ship);合并分支 → `mobile-dev`;关 issue #15/16/18/19/20;bump `version.env` UPSTREAM_VERSION -**当前进度:0 / 10(Round 0 起步:分支 + 文档集已建)。** +**当前进度:2 / 10(G1 合并 + G2 parser 缓存失效 ✓)。** **`/goal` 自动循环完成条件**(每回合自动复检): @@ -197,3 +197,9 @@ iOS 可能**零代码改动**(→ Mac-only),或仅做可选 provider 搜 - 上游 `v0.31.0..v0.32.4` = 67 commits / 122 files / +8211 -699。**关键画像**:无新 provider、无 `UsageSnapshot`/`Shared/Models` 新字段 → **无新 wire envelope、无新 iOS 卡片**;CostUsage parser 大改(+903,含新 `CostUsageScanner+CodexFastJSON.swift`)→ 必做 G2 缓存失效。 - 版本目标定(依 `docs/versioning.md`):MARKETING `0.32.4.1` / BUILD `79.1`(上游 79)/ MOBILE `1.11.0`(待 G4)/ tag `v0.32.4.1-mobile.1.11.0`。 - 建分支 `upstream-sync/v0.32.4-mobile.1.11.0`,生成本文档集 00–03 + PROJECT-PROMPT.md。**进度 0/10**,下一步进 Round 1(Phase A:`git merge v0.32.4`)。 + +### Round 1 — Phase A 合并 + Phase B parser 缓存失效(2026-06-03) +- **G1 ✓**:`git merge v0.32.4`(67 commits)→ 5 冲突全解:`version.env`(→0.32.4.1/79.1)、`CHANGELOG.md`(两侧都留)、`appcast.xml`(ours)、`CodexParserHash.generated`(ours,待 regenerate)、`sign-and-notarize.sh`。`swift build` 绿(21s)。提交 `6d3e54d4`。核心 fork 代码(`Shared/`、`Sync/`)零冲突。 +- **发现 F1(sign-and-notarize.sh 冲突,release 脚本坑)**:上游 #1228 把公证 API key/zip 隔离到私有临时目录,且下游公共代码改用 `$API_KEY_PATH`/`$NOTARIZATION_ZIP`(只在上游块定义)。但上游块**只认 `_P8`**,而 fork/用户用 `_FILE`。解法:采纳 #1228 私有目录隔离 + 定义两变量,但**保留 fork 的 `_FILE`/`_P8` 双支持 + fork 的 mobile 后缀 `ZIP_NAME`/`DSYM_ZIP`**(不用上游 `codexbar_app_zip_name`,否则 release.sh/appcast 找不到 zip)。属 release-time 风险,build/test 抓不到(memory `fork-script-conflict`),Phase D 打包时复核。 +- **G2 ✓**:CostUsage 确认大改(+903/-91,新 `CostUsageScanner+CodexFastJSON.swift`)。bump `parserLogicVersion` 4→5(+ v5 history 注释)→ `regenerate-codex-parser-hash.sh` → hash `518924b`。坑:`audit-parser-version` 查 `base...HEAD` 已提交 diff,故须**先提交**缓存失效改动审计才认(merge commit 里还是 4)。提交 `5d8f6167` 后全量 lint 绿。 +- **进度 2/10**。下一步:G3(值修正透传 grep 验证)+ G9(CloudKit 审计 grep)+ G6(swift test 回归)+ G7(Opus CR)。 From d9b746f8228f43048a81f6cd11ed56ce7350ae7f Mon Sep 17 00:00:00 2001 From: o1xhack Date: Wed, 3 Jun 2026 17:17:02 -0700 Subject: [PATCH 71/79] test: add Keychain-prompt safety guidance to AGENTS.md (fixes pre-existing KeychainPromptSafetyAuditTests gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeychainPromptSafetyAuditTests (upstream, present on mobile-dev pre-merge) asserts AGENTS.md contains the Keychain-prompt safety guidance. The fork's AGENTS.md never carried it, so the test was failing on mobile-dev already (NOT a v0.32.x merge regression — the other 3630 serial tests pass clean). Added the upstream guidance bullet to Step 4 — Testing. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index dd08f4cc..50a5c985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Full status definitions and index are in `CodexBarMobile/Research/README.md`. - Build with `xcodebuild` to verify compilation - Run unit tests if applicable - Verify on simulator or real device as needed +- Never run tests/checks or ad-hoc validation that can display macOS Keychain prompts. Live provider probes, browser-cookie imports, `codexbar usage` against real accounts, and real SecItem reads must be explicitly requested; otherwise use parser tests, stubs, test stores, or `KeychainNoUIQuery`. ## Step 5 — Documentation From 4a088b3e49f8a79c55a8c2bb466b59551667fcf2 Mon Sep 17 00:00:00 2001 From: o1xhack Date: Wed, 3 Jun 2026 17:17:41 -0700 Subject: [PATCH 72/79] =?UTF-8?q?docs(026):=20R2=20=E2=80=94=20G3/G6/G7/G9?= =?UTF-8?q?=20verified=20(6/10);=20next=20G4=20iOS=20scope=20decision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../026-v032-upstream-sync/00-overview.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md index ba6944b1..97dea766 100644 --- a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md +++ b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md @@ -31,16 +31,16 @@ - [x] **G1 · Mac 合并**:`git merge v0.32.4`(67 commits)干净、`swift build` 绿(21s)、5 冲突全解 — 提交 `6d3e54d4`(R1) - [x] **G2 · Codex parser 缓存失效**(本轮核心):`regenerate-codex-parser-hash.sh` → hash `518924b891f96a03` + `parserLogicVersion` 4→5;全量 `Scripts/lint.sh lint` 绿(parser-version + hash 审计均 OK)— 提交 `5d8f6167`(R1) -- [ ] **G3 · 值修正自动透传验证**:Antigravity 配额行过滤(#1209)、Copilot 零权利 %(#1258)、Augment 解析(#1224)、Claude 快照保留(#1220)经现有 synced 字段透传 — grep 确认 + iOS 实机 = 用户 QA +- [~] **G3 · 值修正自动透传验证**:Antigravity 配额行过滤(#1209)、Copilot 零权利 %(#1258)、Augment 解析(#1224)、Claude 快照保留(#1220)—— grep 确认在合并树 + `Shared/`/`Sync/` 相对 base 零 diff(经现有字段透传,无需 bridge)✓;iOS 实机可视化 = 用户 QA - [ ] **G4 · iOS 面定稿**:判定 iOS scope(纯验证 / 可选 provider 搜索 #1184)+ 定 MOBILE 版本(1.11.0 或 Mac-only 1.10.0) - [ ] **G5 · i18n / release notes**:若 iOS ship → 4 语 xcstrings + `MobileReleaseNotesCatalog` 1.11.0 条目 + CHANGELOG(Mac + iOS) -- [ ] **G6 · 测试**:全量 `swift test` 绿(含 cost-cache 失效单测)+ 跨版本兼容回归(2 Mac×2 iOS 不崩) -- [ ] **G7 · Code Review**:独立 Opus 4.7 agent CR loop 评审 fork 改动 → 零 findings +- [x] **G6 · 测试**:全量串行 `swift test --no-parallel` 绿(3630 tests / 417 suites);唯一失败 `KeychainPromptSafetyAuditTests` 是 mobile-dev 预存的 AGENTS.md 审计缺口(非合并回归),已修。并行 flake `SyncCoordinatorTests`(Index out of range)属已知(memory)。跨版本 iOS 实机 = 用户 QA +- [x] **G7 · Code Review**:独立 Opus 4.7 agent 评审 fork 改动(合并冲突解决 + parser 缓存失效)→ **SHIP**,零阻塞 findings(R1) - [ ] **G8 · 版本号 stamp**:`version.env` + `project.yml` + `xcodegen` 重生成 -- [ ] **G9 · CloudKit 审计**:本批无新 CKRecord 字段(无新 wire 结构)→ 预判**无需** Prod schema deploy;按 `docs/cloudkit-deploy-audit.md` 正式过审 +- [x] **G9 · CloudKit 审计**:`CloudConstants.swift` 相对 base 零 diff、`providerPayloadVersion=1` 未变、无新 CKRecord 字段 → **无需 Prod schema deploy** ✓ - [ ] **G10 · 发布**:Mac 签名公证 + draft→publish + appcast + 装机;(iOS TestFlight 若 ship);合并分支 → `mobile-dev`;关 issue #15/16/18/19/20;bump `version.env` UPSTREAM_VERSION -**当前进度:2 / 10(G1 合并 + G2 parser 缓存失效 ✓)。** +**当前进度:6 / 10(G1/G2/G6/G7/G9 ✓ + G3 代码层验证;剩 G4 iOS scope 决策 → G5/G8 → G10 发布)。** **`/goal` 自动循环完成条件**(每回合自动复检): @@ -203,3 +203,10 @@ iOS 可能**零代码改动**(→ Mac-only),或仅做可选 provider 搜 - **发现 F1(sign-and-notarize.sh 冲突,release 脚本坑)**:上游 #1228 把公证 API key/zip 隔离到私有临时目录,且下游公共代码改用 `$API_KEY_PATH`/`$NOTARIZATION_ZIP`(只在上游块定义)。但上游块**只认 `_P8`**,而 fork/用户用 `_FILE`。解法:采纳 #1228 私有目录隔离 + 定义两变量,但**保留 fork 的 `_FILE`/`_P8` 双支持 + fork 的 mobile 后缀 `ZIP_NAME`/`DSYM_ZIP`**(不用上游 `codexbar_app_zip_name`,否则 release.sh/appcast 找不到 zip)。属 release-time 风险,build/test 抓不到(memory `fork-script-conflict`),Phase D 打包时复核。 - **G2 ✓**:CostUsage 确认大改(+903/-91,新 `CostUsageScanner+CodexFastJSON.swift`)。bump `parserLogicVersion` 4→5(+ v5 history 注释)→ `regenerate-codex-parser-hash.sh` → hash `518924b`。坑:`audit-parser-version` 查 `base...HEAD` 已提交 diff,故须**先提交**缓存失效改动审计才认(merge commit 里还是 4)。提交 `5d8f6167` 后全量 lint 绿。 - **进度 2/10**。下一步:G3(值修正透传 grep 验证)+ G9(CloudKit 审计 grep)+ G6(swift test 回归)+ G7(Opus CR)。 + +### Round 2 — G3/G6/G7/G9 验证(2026-06-03) +- **G3 ✓(代码层)**:Antigravity #1209 / Copilot #1258(`ffd8d75a`)/ Augment #1224(`4a2ef3ae`)在合并树;`Shared/`+`Sources/CodexBar/Sync/` 相对 base **零 diff** → 值修正经现有 synced 字段透传,无需 bridge/wire 改动。iOS 可视化 = 用户真机 QA。 +- **G9 ✓**:`CloudConstants.swift` 零 diff、`providerPayloadVersion=1` 未变 → 无新 CKRecord 字段 → **无需 Prod schema deploy**。 +- **G6 ✓**:全量串行 `swift test --no-parallel` = 3630 tests / 417 suites,唯一失败是 `KeychainPromptSafetyAuditTests`(断言 AGENTS.md 含 keychain-prompt 安全指引)。查实:**mobile-dev 早就缺这两句、测试早就在 fail(非本次合并回归)**;其余 3629 全过。修法:把上游那条安全指引加进 fork AGENTS.md Step 4(提交 `d9b746f8`)→ `KeychainPromptSafetyAuditTests` 4/4 过。并行 `SyncCoordinatorTests` flake(Index out of range)属已知 memory。 +- **G7 ✓**:独立 Opus 4.7 agent 评审合并冲突解决 + parser 缓存失效 → **SHIP**,零阻塞。确认 `sign-and-notarize.sh` 所有变量 set-u 下用前已定义、`codexbar_app_zip_name` 无人调用、parser 双轴失效 `regenerate --check` 通过。 +- **进度 6/10**。剩 **G4 iOS scope(用户决策:Mac-only vs 配套 ship 1.11.0)** → G5/G8(依 G4)→ G10 发布(用户环节)。 From 3d59278ff60018584f17d76fb52feaddde277663 Mon Sep 17 00:00:00 2001 From: o1xhack Date: Wed, 3 Jun 2026 17:24:25 -0700 Subject: [PATCH 73/79] =?UTF-8?q?feat(026):=20iOS=201.11.0=20=E2=80=94=20v?= =?UTF-8?q?0.32.4=20sync=20release=20notes=20(4-lang)=20+=20version=20stam?= =?UTF-8?q?p=200.32.4.1/79.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G4 = ship paired iOS 1.11.0 (no functional iOS code; the v0.32.x value-fixes reach iOS via Mac → iCloud sync). G5 + G8: - version.env MOBILE_VERSION 1.10.0 → 1.11.0 (MARKETING 0.32.4.1 / BUILD 79.1 from R1) - project.yml MARKETING 1.10.0 → 1.11.0, CURRENT_PROJECT_VERSION 147 → 148; xcodegen - ContentView MobileReleaseNotesCatalog: new 1.11.0 entry (Antigravity rows / Copilot % / Augment parse / Claude snapshot / Codex-Claude cost re-scan), 1.10.0 Latest badge removed - Localizable.xcstrings: +7 release-notes keys, all 4 locales (314 source keys, lint green) - CHANGELOG root 0.32.4.1 (bilingual) + iOS 1.11.0 (148) Verified: Scripts/lint.sh lint green (i18n all-keys-present + parser audits), xcodebuild iphonesimulator BUILD SUCCEEDED, changelog-to-html 0.32.4.1 renders clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 36 ++++ CodexBarMobile/CHANGELOG.md | 24 +++ .../CodexBarMobile.xcodeproj/project.pbxproj | 24 +-- .../CodexBarMobile/ContentView.swift | 22 +- .../CodexBarMobile/Localizable.xcstrings | 196 ++++++++++++++++++ CodexBarMobile/project.yml | 12 +- version.env | 2 +- 7 files changed, 296 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4846b1..6cd1bef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## 0.32.4.1 (Mobile 1.11.0 · build 79.1) — 2026-06-03 — upstream v0.32.4 sync + +Syncs the Mac app to upstream CodexBar **v0.32.4** (spanning 0.32.0–0.32.4) and ships the paired iOS **1.11.0** companion. A refinement + reliability batch — no new providers; the visible wins are quieter, more accurate provider data that flows through to iPhone automatically. + +### Fixed / Improved + +- **Antigravity** quota rows are cleaner — image / lite / autocomplete / internal noise rows no longer skew the summary bar (#1209). +- **Copilot** zero-entitlement business tokens no longer show a misleading usage percentage (#1258). +- **Augment** usage parses correctly again after the upstream `auggie` status-format change, with a browser-cookie fallback (#1224). +- **Claude** keeps the last good web-usage snapshot through a brief Unauthorized refresh instead of blanking, and delegates the CLI OAuth refresh token so CodexBar stops forcing re-logins (#1220, #1239). +- **Codex cost** scanner rewrite (faster scans, new fast-JSON path) — the on-disk cost cache is invalidated and re-scanned so Codex and Claude cost cards reflect the new parser. +- Plus upstream menu-bar, OpenAI Web, and notarization-path hardening for macOS 26. + +### Compatibility + +- No wire-format, schema, or CloudKit change. Mixing app versions across Macs and iPhones stays safe — the refinements arrive once Mac is on 0.32.4. + +### 中文说明 + +同步 Mac 端到上游 CodexBar **v0.32.4**(覆盖 0.32.0–0.32.4),并配套发布 iOS **1.11.0**。本批以精修 + 可靠性为主,无新增 provider;可见收益是更干净、更准确的 provider 数据,并自动同步到 iPhone。 + +### 修复 / 改进 + +- **Antigravity** 配额行更干净 —— image / lite / autocomplete / internal 噪声行不再干扰汇总进度条(#1209)。 +- **Copilot** zero-entitlement 商业 token 不再显示误导性用量百分比(#1258)。 +- **Augment** 在上游 `auggie` 状态格式变更后用量重新正确解析,并增加浏览器 cookie fallback(#1224)。 +- **Claude** 短暂 Unauthorized 刷新期间保留最后有效的 web 用量快照而不清空,并把 CLI 的 OAuth refresh token 委托出去,避免强制重登(#1220、#1239)。 +- **Codex 成本** 扫描器重写(更快、新增 fast-JSON 路径)—— 失效并重扫磁盘成本缓存,使 Codex 与 Claude 成本卡反映新 parser。 +- 以及上游菜单栏、OpenAI Web、公证路径加固(macOS 26)。 + +### 兼容性 + +- 无 wire / schema / CloudKit 变更。Mac 与 iPhone 间混用版本安全 —— 待 Mac 升级到 0.32.4 后这些精修即到达。 + +--- + ## 0.31.0.2 (Mobile 1.10.0 · build 73.2) — 2026-06-02 — cost-cache invalidation hotfix Hotfix on top of 0.31.0.1: forces the Codex and Claude cost-usage caches to re-scan after the v0.31.0 parser update, so cost cards show the new parser's numbers instead of stale cached attributions. diff --git a/CodexBarMobile/CHANGELOG.md b/CodexBarMobile/CHANGELOG.md index b1f5020d..a1b64777 100644 --- a/CodexBarMobile/CHANGELOG.md +++ b/CodexBarMobile/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to the CodexBar iOS companion app will be documented in this file. +## [1.11.0 (148)] — 2026-06-03 — v0.32.4 upstream sync (refinements, no new iOS code) + +### Changed + +- Paired with Mac **0.32.4.1 / build 79.1** (upstream v0.32.0–v0.32.4 sync). No + functional iOS code change — the improvements reach iPhone through the existing + Mac → iCloud sync. Added the in-app 1.11.0 "What's New" entry (4 languages). + +### Improved (Mac-side, delivered to iOS via synced data) + +- **Antigravity** quota rows are cleaner (image / lite / autocomplete / internal noise + rows filtered, #1209). +- **Copilot** zero-entitlement usage % is no longer misleading (#1258). +- **Augment** parsing fixed for the new upstream `auggie` status format (#1224). +- **Claude** keeps the last good usage snapshot through brief auth hiccups (#1220). +- **Codex / Claude cost** re-scanned by the v0.32 cost-scanner update (cache invalidated + via parserLogicVersion 4→5 + parser-hash regen). + +### Notes + +- No wire-format / schema change; older iOS and older Macs interoperate safely. + +--- + ## [1.10.0 (147)] — 2026-06-03 — In-app 1.10.0 release notes ### Fixed diff --git a/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj b/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj index 038ad9fd..4a9dc1de 100644 --- a/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj +++ b/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj @@ -1039,7 +1039,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = ""; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1050,7 +1050,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.sync; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -1218,7 +1218,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexBarMobilePushExtension/PushExtension.entitlements; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobilePushExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1226,7 +1226,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.mobile.pushextension; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -1240,14 +1240,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexBarMobile/CodexBarMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.mobile; SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1278,14 +1278,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexBarMobile/CodexBarMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.mobile; SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1314,7 +1314,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = ""; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1325,7 +1325,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.sync; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -1338,7 +1338,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexBarMobilePushExtension/PushExtension.entitlements; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobilePushExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1346,7 +1346,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.mobile.pushextension; SDKROOT = iphoneos; SKIP_INSTALL = YES; diff --git a/CodexBarMobile/CodexBarMobile/ContentView.swift b/CodexBarMobile/CodexBarMobile/ContentView.swift index b32669f3..565ba6e1 100644 --- a/CodexBarMobile/CodexBarMobile/ContentView.swift +++ b/CodexBarMobile/CodexBarMobile/ContentView.swift @@ -2415,8 +2415,28 @@ private struct ReleaseNotesVersion: Identifiable { private enum MobileReleaseNotesCatalog { static let versions: [ReleaseNotesVersion] = [ ReleaseNotesVersion( - version: "1.10.0", + version: "1.11.0", status: String(localized: "Latest"), + summary: String(localized: "Quieter, more accurate provider data synced from your Mac — Antigravity quota rows without the noise, correct Copilot usage on zero-entitlement plans, fixed Augment parsing, and steadier Claude readings — from the CodexBar 0.32.4 sync."), + sections: [ + .init( + title: String(localized: "What's New"), + items: [ + String(localized: "Antigravity — quota rows are cleaner: image / lite / autocomplete / internal noise rows no longer skew the summary bar."), + String(localized: "Copilot — zero-entitlement business tokens no longer show a misleading usage percentage."), + String(localized: "Augment — usage parses correctly again after the upstream status-format change."), + String(localized: "Claude — a brief sign-in hiccup no longer blanks your usage; the last good reading is kept."), + String(localized: "Codex / Claude cost — refreshed by the v0.32 cost-scanner update; your cost cards re-scan to the corrected numbers."), + ]), + .init( + title: String(localized: "Required Mac version"), + items: [ + String(localized: "Update Mac CodexBar to 0.32.4 (fork build 79.1 or later). iPhone 1.11.0 stays forward-compatible with older Mac builds — these refinements simply arrive once Mac is updated."), + ]), + ]), + ReleaseNotesVersion( + version: "1.10.0", + status: "", summary: String(localized: "DeepSeek web-session usage and cost on your iPhone, Codex Spark and Antigravity per-model quota lanes synced through, and cost cards that show request counts in the right currency — from the CodexBar 0.31.0 sync."), sections: [ .init( diff --git a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings index f895ed45..842e0133 100644 --- a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings +++ b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings @@ -14419,6 +14419,202 @@ } } } + }, + "Quieter, more accurate provider data synced from your Mac — Antigravity quota rows without the noise, correct Copilot usage on zero-entitlement plans, fixed Augment parsing, and steadier Claude readings — from the CodexBar 0.32.4 sync.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quieter, more accurate provider data synced from your Mac — Antigravity quota rows without the noise, correct Copilot usage on zero-entitlement plans, fixed Augment parsing, and steadier Claude readings — from the CodexBar 0.32.4 sync." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac から同期される provider データがより静かで正確に — Antigravity のクォータ行からノイズを除去、zero-entitlement プランでの Copilot 使用率を修正、Augment の解析を修正、Claude の表示も安定。CodexBar 0.32.4 の同期による更新です。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从 Mac 同步来的 provider 数据更干净、更准确 —— Antigravity 配额行去除噪声、修正 zero-entitlement 套餐的 Copilot 用量、修复 Augment 解析、Claude 读数更稳。来自 CodexBar 0.32.4 同步。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從 Mac 同步來的 provider 資料更乾淨、更準確 —— Antigravity 配額行去除雜訊、修正 zero-entitlement 方案的 Copilot 用量、修復 Augment 解析、Claude 讀數更穩。來自 CodexBar 0.32.4 同步。" + } + } + } + }, + "Antigravity — quota rows are cleaner: image / lite / autocomplete / internal noise rows no longer skew the summary bar.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Antigravity — quota rows are cleaner: image / lite / autocomplete / internal noise rows no longer skew the summary bar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Antigravity — クォータ行がすっきり:image/lite/autocomplete/internal のノイズ行が集計バーを歪めなくなりました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Antigravity —— 配额行更干净:image / lite / autocomplete / internal 噪声行不再干扰汇总进度条。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Antigravity —— 配額行更乾淨:image / lite / autocomplete / internal 雜訊行不再干擾彙總進度條。" + } + } + } + }, + "Copilot — zero-entitlement business tokens no longer show a misleading usage percentage.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copilot — zero-entitlement business tokens no longer show a misleading usage percentage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Copilot — zero-entitlement のビジネストークンで誤解を招く使用率が表示されなくなりました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copilot —— zero-entitlement 的商业 token 不再显示误导性的用量百分比。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copilot —— zero-entitlement 的商業 token 不再顯示誤導性的用量百分比。" + } + } + } + }, + "Augment — usage parses correctly again after the upstream status-format change.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Augment — usage parses correctly again after the upstream status-format change." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Augment — アップストリームのステータス形式変更後も使用状況が正しく解析されるようになりました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Augment —— 上游状态格式变更后,用量重新可以正确解析。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Augment —— 上游狀態格式變更後,用量重新可以正確解析。" + } + } + } + }, + "Claude — a brief sign-in hiccup no longer blanks your usage; the last good reading is kept.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude — a brief sign-in hiccup no longer blanks your usage; the last good reading is kept." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude — 一時的なサインインの不調で使用状況が空白にならず、直近の有効な値を保持します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude —— 短暂登录波动不再清空用量,会保留最近一次有效读数。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude —— 短暫登入波動不再清空用量,會保留最近一次有效讀數。" + } + } + } + }, + "Codex / Claude cost — refreshed by the v0.32 cost-scanner update; your cost cards re-scan to the corrected numbers.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex / Claude cost — refreshed by the v0.32 cost-scanner update; your cost cards re-scan to the corrected numbers." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex/Claude コスト — v0.32 のコストスキャナー更新により再計算され、コストカードが修正後の数値で再スキャンされます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codex / Claude 成本 —— 经 v0.32 成本扫描器更新刷新,成本卡会重扫到修正后的数值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codex / Claude 成本 —— 經 v0.32 成本掃描器更新重新整理,成本卡會重掃到修正後的數值。" + } + } + } + }, + "Update Mac CodexBar to 0.32.4 (fork build 79.1 or later). iPhone 1.11.0 stays forward-compatible with older Mac builds — these refinements simply arrive once Mac is updated.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Mac CodexBar to 0.32.4 (fork build 79.1 or later). iPhone 1.11.0 stays forward-compatible with older Mac builds — these refinements simply arrive once Mac is updated." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac 版 CodexBar を 0.32.4(fork build 79.1 以降)に更新してください。iPhone 1.11.0 は古い Mac ビルドとも前方互換で、これらの改善は Mac を更新すると反映されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将 Mac 版 CodexBar 更新到 0.32.4(fork build 79.1 或更高)。iPhone 1.11.0 仍向前兼容旧版 Mac —— 这些改进会在 Mac 更新后到达。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將 Mac 版 CodexBar 更新到 0.32.4(fork build 79.1 或更高)。iPhone 1.11.0 仍向前相容舊版 Mac —— 這些改進會在 Mac 更新後到達。" + } + } + } } }, "version": "1.0" diff --git a/CodexBarMobile/project.yml b/CodexBarMobile/project.yml index 0dee15d5..12c9962d 100644 --- a/CodexBarMobile/project.yml +++ b/CodexBarMobile/project.yml @@ -34,8 +34,8 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.sync GENERATE_INFOPLIST_FILE: true - MARKETING_VERSION: "1.10.0" - CURRENT_PROJECT_VERSION: "147" + MARKETING_VERSION: "1.11.0" + CURRENT_PROJECT_VERSION: "148" CodexBarMobilePushExtension: type: app-extension @@ -55,8 +55,8 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile.pushextension - MARKETING_VERSION: "1.10.0" - CURRENT_PROJECT_VERSION: "147" + MARKETING_VERSION: "1.11.0" + CURRENT_PROJECT_VERSION: "148" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobilePushExtension/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobilePushExtension/PushExtension.entitlements @@ -75,8 +75,8 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile - MARKETING_VERSION: "1.10.0" - CURRENT_PROJECT_VERSION: "147" + MARKETING_VERSION: "1.11.0" + CURRENT_PROJECT_VERSION: "148" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobile/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobile/CodexBarMobile.entitlements diff --git a/version.env b/version.env index edfac894..8786f886 100644 --- a/version.env +++ b/version.env @@ -1,6 +1,6 @@ MARKETING_VERSION=0.32.4.1 BUILD_NUMBER=79.1 -MOBILE_VERSION=1.10.0 +MOBILE_VERSION=1.11.0 # Last upstream tag confirmed shipped to users. Lags MARKETING_VERSION # until the corresponding release reaches end users (Sparkle / App Store). # Bump this after the merged version is actually live, not at merge time. From bf42beea513eb4ec91cc8c225bc4595cf8ce342b Mon Sep 17 00:00:00 2001 From: o1xhack Date: Wed, 3 Jun 2026 17:25:21 -0700 Subject: [PATCH 74/79] =?UTF-8?q?docs(026):=20R3=20=E2=80=94=20G4/G5/G8=20?= =?UTF-8?q?done,=20iOS=201.11.0=20staged=20(9/10);=20G10=20release=20=3D?= =?UTF-8?q?=20user=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Research/026-v032-upstream-sync/00-overview.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md index 97dea766..0315896c 100644 --- a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md +++ b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md @@ -32,15 +32,15 @@ - [x] **G1 · Mac 合并**:`git merge v0.32.4`(67 commits)干净、`swift build` 绿(21s)、5 冲突全解 — 提交 `6d3e54d4`(R1) - [x] **G2 · Codex parser 缓存失效**(本轮核心):`regenerate-codex-parser-hash.sh` → hash `518924b891f96a03` + `parserLogicVersion` 4→5;全量 `Scripts/lint.sh lint` 绿(parser-version + hash 审计均 OK)— 提交 `5d8f6167`(R1) - [~] **G3 · 值修正自动透传验证**:Antigravity 配额行过滤(#1209)、Copilot 零权利 %(#1258)、Augment 解析(#1224)、Claude 快照保留(#1220)—— grep 确认在合并树 + `Shared/`/`Sync/` 相对 base 零 diff(经现有字段透传,无需 bridge)✓;iOS 实机可视化 = 用户 QA -- [ ] **G4 · iOS 面定稿**:判定 iOS scope(纯验证 / 可选 provider 搜索 #1184)+ 定 MOBILE 版本(1.11.0 或 Mac-only 1.10.0) -- [ ] **G5 · i18n / release notes**:若 iOS ship → 4 语 xcstrings + `MobileReleaseNotesCatalog` 1.11.0 条目 + CHANGELOG(Mac + iOS) +- [x] **G4 · iOS 面定稿**:用户决策 = **配套 ship iOS 1.11.0**(纯 release-notes,无功能代码;值修正经同步到达 iOS)。MOBILE → 1.11.0,tag `v0.32.4.1-mobile.1.11.0`(R3) +- [x] **G5 · i18n / release notes**:`MobileReleaseNotesCatalog` 1.11.0 条目(5 项)+ 7 文案 ×4 语 xcstrings(314 keys 全在)+ root CHANGELOG 0.32.4.1 双语 + iOS CHANGELOG 1.11.0(148)(R3) - [x] **G6 · 测试**:全量串行 `swift test --no-parallel` 绿(3630 tests / 417 suites);唯一失败 `KeychainPromptSafetyAuditTests` 是 mobile-dev 预存的 AGENTS.md 审计缺口(非合并回归),已修。并行 flake `SyncCoordinatorTests`(Index out of range)属已知(memory)。跨版本 iOS 实机 = 用户 QA - [x] **G7 · Code Review**:独立 Opus 4.7 agent 评审 fork 改动(合并冲突解决 + parser 缓存失效)→ **SHIP**,零阻塞 findings(R1) -- [ ] **G8 · 版本号 stamp**:`version.env` + `project.yml` + `xcodegen` 重生成 +- [x] **G8 · 版本号 stamp**:`version.env`(MARKETING 0.32.4.1 / BUILD 79.1 / MOBILE 1.11.0)+ `project.yml`(1.11.0 / 148)+ `xcodegen` 重生成;iOS sim build 绿(R3) - [x] **G9 · CloudKit 审计**:`CloudConstants.swift` 相对 base 零 diff、`providerPayloadVersion=1` 未变、无新 CKRecord 字段 → **无需 Prod schema deploy** ✓ - [ ] **G10 · 发布**:Mac 签名公证 + draft→publish + appcast + 装机;(iOS TestFlight 若 ship);合并分支 → `mobile-dev`;关 issue #15/16/18/19/20;bump `version.env` UPSTREAM_VERSION -**当前进度:6 / 10(G1/G2/G6/G7/G9 ✓ + G3 代码层验证;剩 G4 iOS scope 决策 → G5/G8 → G10 发布)。** +**当前进度:9 / 10(G1–G9 ✓,G3 代码层已验证 + iOS 可视化属真机 QA;剩 G10 发布 = 用户环节)。** **`/goal` 自动循环完成条件**(每回合自动复检): @@ -210,3 +210,9 @@ iOS 可能**零代码改动**(→ Mac-only),或仅做可选 provider 搜 - **G6 ✓**:全量串行 `swift test --no-parallel` = 3630 tests / 417 suites,唯一失败是 `KeychainPromptSafetyAuditTests`(断言 AGENTS.md 含 keychain-prompt 安全指引)。查实:**mobile-dev 早就缺这两句、测试早就在 fail(非本次合并回归)**;其余 3629 全过。修法:把上游那条安全指引加进 fork AGENTS.md Step 4(提交 `d9b746f8`)→ `KeychainPromptSafetyAuditTests` 4/4 过。并行 `SyncCoordinatorTests` flake(Index out of range)属已知 memory。 - **G7 ✓**:独立 Opus 4.7 agent 评审合并冲突解决 + parser 缓存失效 → **SHIP**,零阻塞。确认 `sign-and-notarize.sh` 所有变量 set-u 下用前已定义、`codexbar_app_zip_name` 无人调用、parser 双轴失效 `regenerate --check` 通过。 - **进度 6/10**。剩 **G4 iOS scope(用户决策:Mac-only vs 配套 ship 1.11.0)** → G5/G8(依 G4)→ G10 发布(用户环节)。 + +### Round 3 — G4 决策 + G5/G8 iOS 1.11.0 收尾(2026-06-03) +- **G4 ✓**:用户定 = **配套 ship iOS 1.11.0**(纯 release-notes,无功能代码)。锁定 MOBILE 1.11.0、tag `v0.32.4.1-mobile.1.11.0`、sparkle `79.1.1.11.0`。 +- **G5 ✓**:`ContentView` `MobileReleaseNotesCatalog` 加 1.11.0 条目(Antigravity 行 / Copilot % / Augment / Claude 快照 / Codex-Claude 成本重扫 5 项 + Required Mac),1.10.0 取消 Latest;7 文案 ×4 语加进 xcstrings(Python 零 churn,314 source keys 全在);root CHANGELOG 0.32.4.1 双语(changelog-to-html 渲染干净)+ iOS CHANGELOG 1.11.0(148)。 +- **G8 ✓**:`version.env` MOBILE→1.11.0;`project.yml` 1.11.0 / 148;`xcodegen` 重生成。全量 lint 绿、iOS sim build SUCCEEDED。提交 `3d59278f`。 +- **进度 9/10**。剩 **G10 发布**(用户环节):Mac sign-notarize→draft→publish+appcast→装机 + iOS 1.11.0(148) TestFlight + 合并 mobile-dev + 关 issue #15/16/18/19/20 + bump UPSTREAM_VERSION→v0.32.4。**Phase D 打包时复核 F1 的 sign-and-notarize.sh widget/notarize 改动。** From 811f9c46d47b849d80122b949cefd7a7c83e788d Mon Sep 17 00:00:00 2001 From: o1xhack Date: Thu, 4 Jun 2026 17:16:02 -0700 Subject: [PATCH 75/79] =?UTF-8?q?feat(ios):=20provider=20search=20on=20the?= =?UTF-8?q?=20Usage=20tab=20(filter=2020+=20providers)=20=E2=80=94=20build?= =?UTF-8?q?=20149?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user request: with many providers synced (20+), scrolling the Usage list to find one is tedious. Add a pinned search bar at the top of the Usage tab. - ProviderListView: @State searchText + filteredGroups (filter groups by providerName / providerID, case-insensitive; empty query → all groups, no behavior change). ForEach now over filteredGroups + a "no matching providers" EmptyStateView on no hits. - .searchable(placement: .navigationBarDrawer(.always), prompt: "Search providers") hoists to the UsageTab NavigationStack bar. - Linkage-candidate / multi-account grouping still computed from the full liveProviders set — filtering only hides rows, never drops linkage prompts. - 4 new localized strings (en/ja/zh-Hans/zh-Hant); in-app 1.11.0 release-notes item + root/iOS CHANGELOG; project.yml CURRENT_PROJECT_VERSION 148 → 149. Verified: xcodebuild iphonesimulator BUILD SUCCEEDED, Scripts/lint.sh lint green (i18n all-keys-present + all-locales-translated), independent Opus CR → SHIP. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + CodexBarMobile/CHANGELOG.md | 11 ++ .../CodexBarMobile.xcodeproj/project.pbxproj | 12 +- .../CodexBarMobile/ContentView.swift | 23 +++- .../CodexBarMobile/Localizable.xcstrings | 112 ++++++++++++++++++ CodexBarMobile/project.yml | 6 +- 6 files changed, 156 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd1bef8..7214bf54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Syncs the Mac app to upstream CodexBar **v0.32.4** (spanning 0.32.0–0.32.4) an - **Claude** keeps the last good web-usage snapshot through a brief Unauthorized refresh instead of blanking, and delegates the CLI OAuth refresh token so CodexBar stops forcing re-logins (#1220, #1239). - **Codex cost** scanner rewrite (faster scans, new fast-JSON path) — the on-disk cost cache is invalidated and re-scanned so Codex and Claude cost cards reflect the new parser. - Plus upstream menu-bar, OpenAI Web, and notarization-path hardening for macOS 26. +- **iOS** — new provider search at the top of the Usage list (filter by name) for easier navigation of a long synced provider list. ### Compatibility @@ -29,6 +30,7 @@ Syncs the Mac app to upstream CodexBar **v0.32.4** (spanning 0.32.0–0.32.4) an - **Claude** 短暂 Unauthorized 刷新期间保留最后有效的 web 用量快照而不清空,并把 CLI 的 OAuth refresh token 委托出去,避免强制重登(#1220、#1239)。 - **Codex 成本** 扫描器重写(更快、新增 fast-JSON 路径)—— 失效并重扫磁盘成本缓存,使 Codex 与 Claude 成本卡反映新 parser。 - 以及上游菜单栏、OpenAI Web、公证路径加固(macOS 26)。 +- **iOS** —— Usage 列表顶部新增 provider 搜索(按名称过滤),同步的 provider 多时更好找。 ### 兼容性 diff --git a/CodexBarMobile/CHANGELOG.md b/CodexBarMobile/CHANGELOG.md index a1b64777..d8423082 100644 --- a/CodexBarMobile/CHANGELOG.md +++ b/CodexBarMobile/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to the CodexBar iOS companion app will be documented in this file. +## [1.11.0 (149)] — 2026-06-04 — Usage provider search + +### Added + +- **Provider search on the Usage tab** — a search bar pinned at the top of the Usage + list filters provider cards by name / ID. Helps when many providers are synced (20+) + and scrolling to find one is tedious; shows a "no matching providers" state on no hits. + 4-language localized. + +--- + ## [1.11.0 (148)] — 2026-06-03 — v0.32.4 upstream sync (refinements, no new iOS code) ### Changed diff --git a/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj b/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj index 4a9dc1de..14f41497 100644 --- a/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj +++ b/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj @@ -1039,7 +1039,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = ""; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 149; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1218,7 +1218,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexBarMobilePushExtension/PushExtension.entitlements; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 149; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobilePushExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1240,7 +1240,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexBarMobile/CodexBarMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 149; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1278,7 +1278,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexBarMobile/CodexBarMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 149; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1314,7 +1314,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = ""; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 149; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1338,7 +1338,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexBarMobilePushExtension/PushExtension.entitlements; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 149; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobilePushExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/CodexBarMobile/CodexBarMobile/ContentView.swift b/CodexBarMobile/CodexBarMobile/ContentView.swift index 565ba6e1..a9e0d31a 100644 --- a/CodexBarMobile/CodexBarMobile/ContentView.swift +++ b/CodexBarMobile/CodexBarMobile/ContentView.swift @@ -174,6 +174,9 @@ private struct ProviderListView: View { /// Long-term persistence isn't needed since the candidate goes away /// the moment the legacy Mac upgrades (Research/019 §9 logic). @State private var dismissedCandidateKeys = Set() + /// Filters the Usage provider list by name / ID. Helps when many + /// providers are synced (20+) and scrolling to find one is tedious. + @State private var searchText = "" var body: some View { // Drop extinct mock zombies before any rendering so duplicate @@ -215,10 +218,15 @@ private struct ProviderListView: View { // upstream of this grouping, so each group's accounts are all // distinct (no duplicates within). let groups = liveProviders.groupedByProvider() + let query = self.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let filteredGroups = query.isEmpty ? groups : groups.filter { group in + group.representative.providerName.localizedCaseInsensitiveContains(query) + || group.providerID.localizedCaseInsensitiveContains(query) + } return ScrollView { LazyVStack(spacing: 16) { MockProviderBanner(snapshot: self.snapshot) - ForEach(groups) { group in + ForEach(filteredGroups) { group in // Within-group linkage candidate: surface on the // group row if ANY account in the group has one // (typically the legacy/missing-identity card). @@ -267,6 +275,14 @@ private struct ProviderListView: View { .accessibilityIdentifier("provider-group-\(group.providerID)") } + if filteredGroups.isEmpty { + EmptyStateView( + title: "No matching providers", + message: "No provider matches your search. Try a different name.", + systemImage: "magnifyingglass") + .padding(.vertical, 32) + } + // Sync status at scroll bottom if self.isDemoMode { Label("Showing demo data", systemImage: "sparkles") @@ -286,6 +302,10 @@ private struct ProviderListView: View { await self.usageData.refresh() } .modifier(SoftScrollEdgeModifier()) + .searchable( + text: self.$searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: Text("Search providers")) } } @@ -2422,6 +2442,7 @@ private enum MobileReleaseNotesCatalog { .init( title: String(localized: "What's New"), items: [ + String(localized: "Search — filter the Usage list by provider name; handy when many providers are synced."), String(localized: "Antigravity — quota rows are cleaner: image / lite / autocomplete / internal noise rows no longer skew the summary bar."), String(localized: "Copilot — zero-entitlement business tokens no longer show a misleading usage percentage."), String(localized: "Augment — usage parses correctly again after the upstream status-format change."), diff --git a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings index 842e0133..c3093f6d 100644 --- a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings +++ b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings @@ -14615,6 +14615,118 @@ } } } + }, + "Search providers": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search providers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロバイダーを検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索 provider" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋 provider" + } + } + } + }, + "No matching providers": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching providers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致するプロバイダーがありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有匹配的 provider" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有符合的 provider" + } + } + } + }, + "No provider matches your search. Try a different name.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No provider matches your search. Try a different name." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索に一致するプロバイダーがありません。別の名前で試してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有 provider 匹配你的搜索。换个名称试试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有 provider 符合你的搜尋。換個名稱試試。" + } + } + } + }, + "Search — filter the Usage list by provider name; handy when many providers are synced.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search — filter the Usage list by provider name; handy when many providers are synced." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索 — Usage リストをプロバイダー名で絞り込み。多数のプロバイダーが同期されているときに便利。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索 —— 按 provider 名称过滤 Usage 列表;同步的 provider 很多时很方便。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋 —— 按 provider 名稱過濾 Usage 列表;同步的 provider 很多時很方便。" + } + } + } } }, "version": "1.0" diff --git a/CodexBarMobile/project.yml b/CodexBarMobile/project.yml index 12c9962d..7c926c6f 100644 --- a/CodexBarMobile/project.yml +++ b/CodexBarMobile/project.yml @@ -35,7 +35,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.sync GENERATE_INFOPLIST_FILE: true MARKETING_VERSION: "1.11.0" - CURRENT_PROJECT_VERSION: "148" + CURRENT_PROJECT_VERSION: "149" CodexBarMobilePushExtension: type: app-extension @@ -56,7 +56,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile.pushextension MARKETING_VERSION: "1.11.0" - CURRENT_PROJECT_VERSION: "148" + CURRENT_PROJECT_VERSION: "149" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobilePushExtension/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobilePushExtension/PushExtension.entitlements @@ -76,7 +76,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile MARKETING_VERSION: "1.11.0" - CURRENT_PROJECT_VERSION: "148" + CURRENT_PROJECT_VERSION: "149" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobile/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobile/CodexBarMobile.entitlements From 19b5b5e24b7f2720957f140177412ebdb36b7e03 Mon Sep 17 00:00:00 2001 From: o1xhack Date: Thu, 4 Jun 2026 17:17:03 -0700 Subject: [PATCH 76/79] =?UTF-8?q?docs(026):=20R4=20=E2=80=94=20iOS=20Usage?= =?UTF-8?q?=20provider=20search=20added=20(build=20149,=20CR=20SHIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Research/026-v032-upstream-sync/00-overview.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md index 0315896c..cd23c6ab 100644 --- a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md +++ b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md @@ -216,3 +216,10 @@ iOS 可能**零代码改动**(→ Mac-only),或仅做可选 provider 搜 - **G5 ✓**:`ContentView` `MobileReleaseNotesCatalog` 加 1.11.0 条目(Antigravity 行 / Copilot % / Augment / Claude 快照 / Codex-Claude 成本重扫 5 项 + Required Mac),1.10.0 取消 Latest;7 文案 ×4 语加进 xcstrings(Python 零 churn,314 source keys 全在);root CHANGELOG 0.32.4.1 双语(changelog-to-html 渲染干净)+ iOS CHANGELOG 1.11.0(148)。 - **G8 ✓**:`version.env` MOBILE→1.11.0;`project.yml` 1.11.0 / 148;`xcodegen` 重生成。全量 lint 绿、iOS sim build SUCCEEDED。提交 `3d59278f`。 - **进度 9/10**。剩 **G10 发布**(用户环节):Mac sign-notarize→draft→publish+appcast→装机 + iOS 1.11.0(148) TestFlight + 合并 mobile-dev + 关 issue #15/16/18/19/20 + bump UPSTREAM_VERSION→v0.32.4。**Phase D 打包时复核 F1 的 sign-and-notarize.sh widget/notarize 改动。** + +### Round 4 — iOS Usage provider 搜索(用户加需求,2026-06-04) +- 用户反馈:20+ provider 时 Usage 列表滑动找 provider 麻烦 → 在 Usage tab 顶部加 `.searchable` 搜索栏(`.navigationBarDrawer(.always)`),按 `providerName`/`providerID` 过滤 `groups`(空查询 = 全量,零行为变化),无匹配显示 `EmptyStateView`。**linkage / 多账号分组仍用全量 `liveProviders`,过滤只隐藏行、不丢 linkage 提示**(Opus CR 专门确认)。 +- 4 个新文案 ×4 语;in-app 1.11.0 release-notes 加"搜索"项;root + iOS CHANGELOG;`project.yml` build 148→149。 +- 验证:`Scripts/lint.sh lint` 绿(source keys 全在 + 4 语齐)、iOS sim build SUCCEEDED、独立 Opus CR → **SHIP**。提交 `811f9c46`。 +- **iOS scope 修正**:本批不再是"零功能代码" —— 新增 provider 搜索(即 #1184 在只读 companion 上有意义的形态)。iOS 最终 build = **149**,tag 仍 `v0.32.4.1-mobile.1.11.0`。 +- 进度仍 **9/10**(G10 发布 = 用户环节,待授权)。G10 的 iOS TestFlight 上传 build **149**(非 148)。 From 6be4d1d90ba4f1ecbb589dd4c0e459cea6ff9184 Mon Sep 17 00:00:00 2001 From: o1xhack Date: Thu, 4 Jun 2026 18:00:52 -0700 Subject: [PATCH 77/79] =?UTF-8?q?docs(026):=20R5=20=E2=80=94=20G10=20parti?= =?UTF-8?q?al=20(Mac=200.32.4.1=20draft+install,=20iOS=20149=20TestFlight)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Research/026-v032-upstream-sync/00-overview.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md index cd23c6ab..64bfe3bf 100644 --- a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md +++ b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md @@ -223,3 +223,11 @@ iOS 可能**零代码改动**(→ Mac-only),或仅做可选 provider 搜 - 验证:`Scripts/lint.sh lint` 绿(source keys 全在 + 4 语齐)、iOS sim build SUCCEEDED、独立 Opus CR → **SHIP**。提交 `811f9c46`。 - **iOS scope 修正**:本批不再是"零功能代码" —— 新增 provider 搜索(即 #1184 在只读 companion 上有意义的形态)。iOS 最终 build = **149**,tag 仍 `v0.32.4.1-mobile.1.11.0`。 - 进度仍 **9/10**(G10 发布 = 用户环节,待授权)。G10 的 iOS TestFlight 上传 build **149**(非 148)。 + +### Round 5 — G10 部分:Mac Draft + 装机 + iOS TestFlight(用户授权,2026-06-04) +- 用户授权:Mac 出 Draft Release + 装机;iOS 传 TestFlight(明确"Draft",未授权 publish)。 +- **Mac phase1**(`release.sh`):lint 绿 → build → Developer ID 签名 → **Apple 公证 Accepted + staple + validate** → launch 验证 OK → `CodexBar-0.32.4.1-mobile.1.11.0.zip`。**R1 的 `sign-and-notarize.sh` 合并解法(#1228 私有临时目录 + fork 双 `_FILE`/`_P8` 密钥 + fork mobile 后缀 ZIP_NAME)+ widget 打包首次真打包验证通过 → F1 风险解除。** tag `v0.32.4.1-mobile.1.11.0` 已推、draft 已建(`untagged-9aea4c9cc9f60b5cc9e4`)。 +- 产物验证:`0.32.4.1` / `79.1.1.11.0`、widget `CodexBarWidget.appex` 已签、CloudKit entitlement = **Production**、Gatekeeper accepted。装到 `/Applications/CodexBar.app` 并启动(运行中)。 +- **iOS build 149**:archive + cloud-sign + 上传 ASC 成功(EXPORT SUCCEEDED),TestFlight 处理中。in-app 1.11.0 release notes 已确认含搜索 + 5 项值修正(4 语)。 +- **未做(等用户审 draft 后授权 publish)**:publish draft live + 推 appcast(Sparkle)+ close issue #15/16/18/19/20 + bump `version.env` UPSTREAM_VERSION→v0.32.4 + 合并分支→mobile-dev。 +- 进度:**G10 部分完成**(draft + 装机 + iOS TestFlight);剩 publish 收尾(用户授权后)。 From 934e462a43394b24b9bf9ebf9764fa47039cc213 Mon Sep 17 00:00:00 2001 From: o1xhack Date: Sat, 6 Jun 2026 11:34:28 -0700 Subject: [PATCH 78/79] ci: fetch-depth 0 for lint-build-test so parser-version audit can compute merge-base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser-version audit (Scripts/lint.sh) diffs origin/mobile-dev...HEAD for guarded cost-usage parser files. The default shallow (depth-1) checkout makes the merge-base unresolvable, so on any PR that touches the parser the audit can't see the parserLogicVersion bump and false-fails (#21 lint-build-test). Full history fixes it — as Scripts/lint.sh's own comment instructs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 055945f4..97bb94a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,14 @@ jobs: timeout-minutes: 70 steps: - uses: actions/checkout@v6 + # Full history so `lint.sh audit_parser_version` can compute the + # `origin/mobile-dev...HEAD` merge-base. A shallow (depth-1) checkout + # makes the parser-version audit false-fail on any PR that touches the + # cost-usage parser (it can't see the parserLogicVersion bump in the + # diff). See Scripts/lint.sh: "In CI, ensure your checkout fetches + # origin/mobile-dev (e.g. fetch-depth: 0)." + with: + fetch-depth: 0 - name: Select Xcode 26.1.1 (if present) or fallback to default run: | From 8f9db737952eaacc2327785ae218d6370abeb115 Mon Sep 17 00:00:00 2001 From: o1xhack Date: Sat, 6 Jun 2026 13:21:52 -0700 Subject: [PATCH 79/79] ci: run on PR + mobile-dev/main pushes only, not every feature-branch commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit push: branches was ["**"] → every commit on every branch spawned a full lint-build-test (often red on WIP commits). Scope push CI to the long-lived branches; feature branches get CI via their PR (pull_request) instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97bb94a6..4a96f684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,12 @@ name: CI on: + # CI runs on PRs (the merge gate) and on pushes to the long-lived branches + # only — NOT on every feature-branch commit. A feature branch gets its CI + # through the PR it opens (pull_request: opened/synchronize), so intermediate + # work-in-progress commits no longer each spawn a (often red) CI run. push: - # `["*"]` only matches single-level branch names; anything with a slash - # (e.g. `feature/…`, `release/…`) is silently skipped. `["**"]` matches - # arbitrary-depth branches so every push triggers CI. - branches: ["**"] + branches: [mobile-dev, main] pull_request: concurrency: