From 989a9aee4d183aa6d135aaba2b9548aefbd506d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:06:15 +0100 Subject: [PATCH 01/64] build: update package dependencies --- Package.resolved | 10 +++---- Package.swift | 4 +-- .../Logging/CompositeLogHandler.swift | 29 ++----------------- .../CodexBarCore/Logging/FileLogHandler.swift | 25 +++++----------- .../Logging/JSONStderrLogHandler.swift | 25 +++++----------- .../Logging/OSLogLogHandler.swift | 17 +++-------- .../Antigravity/AntigravityStatusProbe.swift | 16 ++++------ 7 files changed, 36 insertions(+), 90 deletions(-) diff --git a/Package.resolved b/Package.resolved index f84c0c217..424905644 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "74bd6f3ab6e0b0cb0c2cddb00f2167c2ab0a1c00cd54ffc1a2899c7ef8c56367", + "originHash" : "fc2d77d3435ccf0f5a2d2a8f782cbb3c38264c7542f33838f2409544695bce6e", "pins" : [ { "identity" : "commander", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", - "version" : "2.8.1" + "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", + "version" : "2.9.1" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { diff --git a/Package.swift b/Package.swift index 0ea2821a0..208b857ee 100644 --- a/Package.swift +++ b/Package.swift @@ -17,9 +17,9 @@ let package = Package( .macOS(.v14), ], dependencies: [ - .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"), .package(url: "https://github.com/steipete/Commander", from: "0.2.1"), - .package(url: "https://github.com/apple/swift-log", from: "1.9.1"), + .package(url: "https://github.com/apple/swift-log", from: "1.12.0"), .package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"), .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"), sweetCookieKitDependency, diff --git a/Sources/CodexBarCore/Logging/CompositeLogHandler.swift b/Sources/CodexBarCore/Logging/CompositeLogHandler.swift index f86aebc1c..51f989ea1 100644 --- a/Sources/CodexBarCore/Logging/CompositeLogHandler.swift +++ b/Sources/CodexBarCore/Logging/CompositeLogHandler.swift @@ -33,31 +33,8 @@ struct CompositeLogHandler: LogHandler { } } - // swiftlint:disable:next function_parameter_count - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { - self.primary.log( - level: level, - message: message, - metadata: metadata, - source: source, - file: file, - function: function, - line: line) - self.secondary.log( - level: level, - message: message, - metadata: metadata, - source: source, - file: file, - function: function, - line: line) + func log(event: LogEvent) { + self.primary.log(event: event) + self.secondary.log(event: event) } } diff --git a/Sources/CodexBarCore/Logging/FileLogHandler.swift b/Sources/CodexBarCore/Logging/FileLogHandler.swift index effe9ff5c..e43211626 100644 --- a/Sources/CodexBarCore/Logging/FileLogHandler.swift +++ b/Sources/CodexBarCore/Logging/FileLogHandler.swift @@ -103,19 +103,10 @@ struct FileLogHandler: LogHandler { set { self.metadata[metadataKey] = newValue } } - // swiftlint:disable:next function_parameter_count - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { + func log(event: LogEvent) { let ts = Self.timestamp() var combined = self.metadata - if let metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) } + if let metadata = event.metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) } var metaText = "" if !combined.isEmpty { let pairs = combined @@ -128,12 +119,12 @@ struct FileLogHandler: LogHandler { .joined(separator: " ") metaText = " \(pairs)" } - let safeMessage = LogRedactor.redact("\(message)") - let lineText = "[\(ts)] [\(level.rawValue.uppercased())] \(self.label): \(safeMessage)\(metaText)\n" - _ = source - _ = file - _ = function - _ = line + let safeMessage = LogRedactor.redact("\(event.message)") + let lineText = "[\(ts)] [\(event.level.rawValue.uppercased())] \(self.label): \(safeMessage)\(metaText)\n" + _ = event.source + _ = event.file + _ = event.function + _ = event.line self.sink.write(lineText) } diff --git a/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift b/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift index 6c02e745e..4d5c5d394 100644 --- a/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift +++ b/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift @@ -25,29 +25,20 @@ struct JSONStderrLogHandler: LogHandler { set { self.metadata[metadataKey] = newValue } } - // swiftlint:disable:next function_parameter_count - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { + func log(event: LogEvent) { let ts = Date() var combined = self.metadata - if let metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) } + if let metadata = event.metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) } let payload = JSONLogLine( timestamp: ts, - level: level.rawValue, + level: event.level.rawValue, label: self.label, - message: message.description, - source: source, - file: file, - function: function, - line: line, + message: event.message.description, + source: event.source, + file: event.file, + function: event.function, + line: event.line, metadata: combined.isEmpty ? nil : combined.mapValues(\.description)) guard let data = try? self.encoder.encode(payload), diff --git a/Sources/CodexBarCore/Logging/OSLogLogHandler.swift b/Sources/CodexBarCore/Logging/OSLogLogHandler.swift index 3a1b28754..be0dc2509 100644 --- a/Sources/CodexBarCore/Logging/OSLogLogHandler.swift +++ b/Sources/CodexBarCore/Logging/OSLogLogHandler.swift @@ -23,24 +23,15 @@ struct OSLogLogHandler: LogHandler { set { self.metadata[metadataKey] = newValue } } - // swiftlint:disable:next function_parameter_count - func log( - level: Logging.Logger.Level, - message: Logging.Logger.Message, - metadata: Logging.Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { + func log(event: LogEvent) { let msg = Self.decorate( - message: message.description, + message: event.message.description, label: self.label, subsystem: self.subsystem, metadata: self.metadata, - extraMetadata: metadata) + extraMetadata: event.metadata) - switch level { + switch event.level { case .trace: self.logger.debug("\(msg, privacy: .public)") case .debug: diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 359029d91..5b3485972 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -422,8 +422,8 @@ public struct AntigravityStatusProbe: Sendable { let commandLine: String } - struct AntigravityConnectionEndpoint: Sendable, Equatable { - enum Source: String, Sendable { + struct AntigravityConnectionEndpoint: Equatable { + enum Source: String { case languageServer = "language-server" case extensionServer = "extension-server" } @@ -897,12 +897,10 @@ extension LocalhostSessionDelegate: URLSessionDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + completionHandler(result.disposition, result.credential) } } @@ -911,12 +909,10 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + completionHandler(result.disposition, result.credential) } } From fb3553d059686183c110973fbe3f112b469e6400 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:06:20 +0100 Subject: [PATCH 02/64] test: stabilize provider state coverage --- Sources/CodexBar/ProviderRegistry.swift | 5 +- .../StatusItemController+Animation.swift | 5 +- Sources/CodexBar/UsageStore+Accessors.swift | 2 +- .../CodexBar/UsageStore+TokenAccounts.swift | 2 +- Sources/CodexBar/UsageStore.swift | 14 +-- ...AuthCredentialsStoreSecurityCLITests.swift | 86 ++++++++++--------- .../CursorStatusProbeTests.swift | 4 +- ...enAIDashboardNavigationDelegateTests.swift | 22 ++++- .../OpenAIDashboardWebViewCacheTests.swift | 4 +- .../OpenAIWebRefreshGateTests.swift | 36 ++++---- .../ProviderSettingsDescriptorTests.swift | 2 +- Tests/CodexBarTests/SettingsStoreTests.swift | 10 +-- .../StatusItemAnimationSignatureTests.swift | 14 ++- Tests/CodexBarTests/StatusMenuTests.swift | 4 +- .../UsageStoreCoverageTests.swift | 19 ++-- 15 files changed, 133 insertions(+), 96 deletions(-) diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 9f0613c33..764e418c4 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -23,7 +23,8 @@ struct ProviderRegistry { metadata: [UsageProvider: ProviderMetadata], codexFetcher: UsageFetcher, claudeFetcher: any ClaudeUsageFetching, - browserDetection: BrowserDetection) -> [UsageProvider: ProviderSpec] + browserDetection: BrowserDetection, + environmentBase: [String: String] = ProcessInfo.processInfo.environment) -> [UsageProvider: ProviderSpec] { var specs: [UsageProvider: ProviderSpec] = [:] specs.reserveCapacity(UsageProvider.allCases.count) @@ -41,7 +42,7 @@ struct ProviderRegistry { ?? .auto let snapshot = Self.makeSettingsSnapshot(settings: settings, tokenOverride: nil) let env = Self.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: environmentBase, provider: provider, settings: settings, tokenOverride: nil) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 564dde609..bed72a824 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -405,7 +405,10 @@ extension StatusItemController { // swiftlint:enable function_body_length private func shouldSkipMergedIconRender(_ signature: String) -> Bool { - guard self.shouldMergeIcons else { return false } + guard self.shouldMergeIcons else { + self.lastAppliedMergedIconRenderSignature = signature + return false + } if self.lastAppliedMergedIconRenderSignature == signature { return true } diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 743f405e2..9c3c759e9 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -81,7 +81,7 @@ extension UsageStore { return self.codexFetcher.loadAccountInfo() } let env = ProviderRegistry.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: self.environmentBase, provider: .codex, settings: self.settings, tokenOverride: nil) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4940eeec5..7a00c1236 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -99,7 +99,7 @@ extension UsageStore { let sourceMode = self.sourceMode(for: provider) let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: override) let env = ProviderRegistry.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: self.environmentBase, provider: provider, settings: self.settings, tokenOverride: override) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d2af04b21..36cc0bf2a 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -179,6 +179,7 @@ final class UsageStore { @ObservationIgnored let browserDetection: BrowserDetection @ObservationIgnored private let registry: ProviderRegistry @ObservationIgnored let settings: SettingsStore + @ObservationIgnored let environmentBase: [String: String] @ObservationIgnored private let sessionQuotaNotifier: any SessionQuotaNotifying @ObservationIgnored private let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota) @ObservationIgnored let openAIWebLogger = CodexBarLog.logger(LogCategories.openAIWeb) @@ -220,7 +221,8 @@ final class UsageStore { historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(), sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), - startupBehavior: StartupBehavior = .automatic) + startupBehavior: StartupBehavior = .automatic, + environmentBase: [String: String] = ProcessInfo.processInfo.environment) { self.codexFetcher = fetcher self.browserDetection = browserDetection @@ -228,6 +230,7 @@ final class UsageStore { self.costUsageFetcher = costUsageFetcher self.settings = settings self.registry = registry + self.environmentBase = environmentBase self.historicalUsageHistoryStore = historicalUsageHistoryStore self.planUtilizationHistoryStore = planUtilizationHistoryStore self.sessionQuotaNotifier = sessionQuotaNotifier @@ -247,7 +250,8 @@ final class UsageStore { metadata: self.providerMetadata, codexFetcher: fetcher, claudeFetcher: self.claudeFetcher, - browserDetection: browserDetection) + browserDetection: browserDetection, + environmentBase: environmentBase) self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in implementation.makeRuntime().map { (implementation.id, $0) } }) @@ -419,7 +423,7 @@ final class UsageStore { // Otherwise providers (notably token-account-backed API providers) can fetch successfully but be // hidden from the menu because their credentials are not in ProcessInfo's environment. let environment = ProviderRegistry.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: self.environmentBase, provider: provider, settings: self.settings, tokenOverride: nil) @@ -777,7 +781,7 @@ extension UsageStore { let ampCookieHeader = self.settings.ampCookieHeader let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader - let processEnvironment = ProcessInfo.processInfo.environment + let processEnvironment = self.environmentBase let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) @@ -898,7 +902,7 @@ extension UsageStore { let sourceMode = self.sourceMode(for: .claude) let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) let environment = ProviderRegistry.makeEnvironment( - base: ProcessInfo.processInfo.environment, + base: self.environmentBase, provider: .claude, settings: self.settings, tokenOverride: nil) diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift index 1dc85d7c7..1e49708b0 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -693,50 +693,56 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let securityData = self.makeCredentialsData( - accessToken: "security-repair-no-fingerprint-probe", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() - let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 456, - createdAt: 455, - persistentRefHash: "sentinel") - - let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental, - operation: { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - try ProviderInteractionContext.$current.withValue(.background) { - try ClaudeOAuthCredentialsStore - .withClaudeKeychainFingerprintStoreOverrideForTesting( - fingerprintStore) - { - try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: nil, - fingerprint: sentinelFingerprint) - { - try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .data(securityData)) + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-repair-no-fingerprint-probe", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 456, + createdAt: 455, + persistentRefHash: "sentinel") + + let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) { - try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) + try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + } } - } } - } - } - }) + } + }) - #expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe") - #expect(record.source == .claudeKeychain) - #expect(fingerprintStore.fingerprint == nil) + #expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe") + #expect(record.source == .claudeKeychain) + #expect(fingerprintStore.fingerprint == nil) + } + } } } } diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index 2b02bbcbb..b7ba51eae 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -1045,7 +1045,9 @@ final class CursorStatusProbeStubURLProtocol: URLProtocol { Self.lock.unlock() do { - let handler = try #require(handler) + guard let handler else { + throw URLError(.cancelled) + } let (response, data) = try handler(self.request) self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) self.client?.urlProtocol(self, didLoad: data) diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 13c31e625..0ee8e0e70 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -3,11 +3,25 @@ import Testing import WebKit @testable import CodexBarCore +@Suite(.serialized) struct OpenAIDashboardNavigationDelegateTests { final class DelegateBox: @unchecked Sendable { var delegate: NavigationDelegate? } + @MainActor + private func waitForResult( + _ result: @escaping () -> Result?, + timeout: TimeInterval = NavigationDelegate.postCommitSuccessDelay + 10.0) async -> Result? + { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let result = result() { return result } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return result() + } + @Test func `ignores NSURLErrorCancelled`() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) @@ -56,10 +70,10 @@ struct OpenAIDashboardNavigationDelegateTests { box.delegate?.webView(webView, didCommit: nil) #expect(result == nil) - try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + let completed = await self.waitForResult { result } box.delegate = nil - switch result { + switch completed { case .success?: #expect(Bool(true)) default: @@ -79,10 +93,10 @@ struct OpenAIDashboardNavigationDelegateTests { let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) box.delegate?.webView(webView, didFail: nil, withError: timeout) - try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + let completed = await self.waitForResult { result } box.delegate = nil - switch result { + switch completed { case let .failure(error as NSError)?: #expect(error.domain == NSURLErrorDomain) #expect(error.code == NSURLErrorTimedOut) diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index 84d500fb2..f6558b810 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -188,8 +188,8 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } - @Test("Evict all should remove every cached WebView") - func evictAllRemovesAllEntries() async throws { + @Test + func `Evict all should remove every cached WebView`() async throws { if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index 67c7282cc..eead8bb84 100644 --- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -3,8 +3,8 @@ import Testing @testable import CodexBar struct OpenAIWebRefreshGateTests { - @Test("Battery saver keeps background OpenAI web refreshes off") - func batterySaverDisablesBackgroundRefresh() { + @Test + func `Battery saver keeps background OpenAI web refreshes off`() { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, @@ -13,8 +13,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldRun == false) } - @Test("Disabling battery saver restores normal OpenAI web refreshes") - func disabledBatterySaverAllowsBackgroundRefresh() { + @Test + func `Disabling battery saver restores normal OpenAI web refreshes`() { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: false, @@ -23,8 +23,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldRun == true) } - @Test("Manual refresh still forces OpenAI web refreshes with battery saver enabled") - func manualRefreshBypassesBatterySaver() { + @Test + func `Manual refresh still forces OpenAI web refreshes with battery saver enabled`() { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, @@ -33,22 +33,22 @@ struct OpenAIWebRefreshGateTests { #expect(shouldRun == true) } - @Test("Battery saver stale-submenu refresh respects the cooldown") - func batterySaverStaleRefreshDoesNotForce() { + @Test + func `Battery saver stale-submenu refresh respects the cooldown`() { let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: true) #expect(shouldForce == false) } - @Test("Normal stale-submenu refresh still forces when battery saver is off") - func nonBatterySaverStaleRefreshForces() { + @Test + func `Normal stale-submenu refresh still forces when battery saver is off`() { let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: false) #expect(shouldForce == true) } - @Test("Recent successful dashboard refresh stays throttled") - func recentSuccessSkipsRefresh() { + @Test + func `Recent successful dashboard refresh stays throttled`() { let now = Date() let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( @@ -63,8 +63,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == true) } - @Test("Recent failed dashboard refresh also stays throttled") - func recentFailureSkipsRefresh() { + @Test + func `Recent failed dashboard refresh also stays throttled`() { let now = Date() let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( @@ -79,8 +79,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == true) } - @Test("Force refresh bypasses throttle after failures") - func forceRefreshBypassesCooldown() { + @Test + func `Force refresh bypasses throttle after failures`() { let now = Date() let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( @@ -95,8 +95,8 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == false) } - @Test("Account switches bypass the prior-attempt cooldown") - func accountChangeBypassesCooldown() { + @Test + func `Account switches bypass the prior-attempt cooldown`() { let now = Date() let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 99f8a9837..81a159b8e 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -125,7 +125,7 @@ struct ProviderSettingsDescriptorTests { } @Test - func codexExposesOpenAIWebExtrasToggleAsDefaultOffOptIn() throws { + func `codex exposes open AI web extras toggle as default off opt in`() throws { let suite = "ProviderSettingsDescriptorTests-codex-openai-toggle" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 53e67413c..0a50a5166 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -649,7 +649,7 @@ struct SettingsStoreTests { } @Test - func defaultsOpenAIWebAccessToDisabled() throws { + func `defaults open AI web access to disabled`() throws { let suite = "SettingsStoreTests-openai-web" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -670,7 +670,7 @@ struct SettingsStoreTests { } @Test - func infersOpenAIWebAccessEnabledForLegacyConfiguredCodexCookies() throws { + func `infers open AI web access enabled for legacy configured codex cookies`() throws { let suite = "SettingsStoreTests-openai-web-legacy" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -695,7 +695,7 @@ struct SettingsStoreTests { } @Test - func infersOpenAIWebAccessEnabledForLegacyCodexConfigWithImplicitAutoCookies() throws { + func `infers open AI web access enabled for legacy codex config with implicit auto cookies`() throws { let suite = "SettingsStoreTests-openai-web-legacy-implicit-auto" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -720,7 +720,7 @@ struct SettingsStoreTests { } @Test - func disablingOpenAIWebAccessTurnsCodexCookieSourceOff() throws { + func `disabling open AI web access turns codex cookie source off`() throws { let suite = "SettingsStoreTests-openai-web-toggle" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -746,7 +746,7 @@ struct SettingsStoreTests { } @Test - func openAIWebBatterySaverPersistsSeparatelyFromExtrasAvailability() throws { + func `open AI web battery saver persists separately from extras availability`() throws { let suite = "SettingsStoreTests-openai-web-battery-saver" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index f7cb6047a..ef85a9715 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -14,9 +14,13 @@ struct StatusItemAnimationSignatureTests { } @Test - func `merged render signature changes when unified icon style changes`() { + func `merged render signature changes when unified icon style changes`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-style-signature" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) let settings = SettingsStore( - configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-style-signature"), + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.statusChecksEnabled = false @@ -57,9 +61,11 @@ struct StatusItemAnimationSignatureTests { controller.applyIcon(phase: nil) let combinedSignature = controller.lastAppliedMergedIconRenderSignature - settings.syntheticAPIToken = "" + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: false) + } - #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProvidersForDisplay() == [.codex]) #expect(store.enabledProviders() == [.codex]) #expect(store.iconStyle == .codex) controller.applyIcon(phase: nil) diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index c74ecf69c..c40134467 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -653,7 +653,7 @@ extension StatusMenuTests { } @Test - func hidesOpenAIWebSubmenusWhenOpenAIWebExtrasDisabled() { + func `hides open AI web submenus when open AI web extras disabled`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -702,7 +702,7 @@ extension StatusMenuTests { } @Test - func showsOpenAIWebSubmenusWhenHistoryExists() throws { + func `shows open AI web submenus when history exists`() throws { self.disableMenuCardsForTesting() let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusMenuTests-history"), diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 9e9258447..b5710878a 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -134,7 +134,7 @@ struct UsageStoreCoverageTests { } @Test - func backgroundRefreshOnlyTracksEnabledProviders() throws { + func `background refresh only tracks enabled providers`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-refresh") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -167,7 +167,7 @@ struct UsageStoreCoverageTests { } @Test - func cleanupPreservesEnabledButUnavailableProviderState() throws { + func `cleanup preserves enabled but unavailable provider state`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-preserve-unavailable") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -204,7 +204,7 @@ struct UsageStoreCoverageTests { } @Test - func backgroundWorkExcludesEnabledButUnavailableProviders() throws { + func `background work excludes enabled but unavailable providers`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-unavailable") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -229,7 +229,7 @@ struct UsageStoreCoverageTests { } @Test - func visibleUnavailableProviderGetsExplicitUserFacingState() throws { + func `visible unavailable provider gets explicit user facing state`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-unavailable-message") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -256,7 +256,7 @@ struct UsageStoreCoverageTests { } @Test - func refreshClearsEnabledButUnavailableCachedState() async throws { + func `refresh clears enabled but unavailable cached state`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -305,7 +305,7 @@ struct UsageStoreCoverageTests { } @Test - func refreshClearsEnabledButUnavailableFailureState() async throws { + func `refresh clears enabled but unavailable failure state`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-failure-cleanup") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -340,7 +340,7 @@ struct UsageStoreCoverageTests { } @Test - func unavailableProviderWithOnlyCachedStatusGetsSingleCleanupPass() async throws { + func `unavailable provider with only cached status gets single cleanup pass`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup") settings.refreshFrequency = .manual settings.statusChecksEnabled = true @@ -372,7 +372,7 @@ struct UsageStoreCoverageTests { } @Test - func statusIndicatorsAndFailureGate() { + func `status indicators and failure gate`() { #expect(!ProviderStatusIndicator.none.hasIssue) #expect(ProviderStatusIndicator.maintenance.hasIssue) #expect(ProviderStatusIndicator.unknown.label == "Status unknown") @@ -423,7 +423,8 @@ struct UsageStoreCoverageTests { UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + settings: settings, + environmentBase: [:]) } } From 624bdc33c8e61ec6c3f2b6decafa5d4945cede87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:06:25 +0100 Subject: [PATCH 03/64] docs: update 0.21 changelog --- CHANGELOG.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bddd111e3..ec428c9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,36 +1,37 @@ # Changelog -## 0.21 — Unreleased +## 0.21 — 2026-04-18 ### Highlights - Abacus AI: add a new provider for ChatLLM and RouteLLM credit tracking with browser-cookie import, manual-cookie support, and monthly pace rendering. Thanks @ChrisGVE! -- Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! -- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. -- Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram! - Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF! +- Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! - Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205! -- Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! +- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation. +- Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation! +- Antigravity: restore account and quota probing across newer localhost endpoint/token layouts and API-level retry failures (#693, fixes #692). Thanks @anirudhvee! - Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm! - Battery / refresh: cut menu redraw churn, skip background work for unavailable providers, and reuse cached OpenAI web views more efficiently (#708). -- Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane! +- Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram! +- Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han! ### Providers & Usage - Abacus AI: add provider support for ChatLLM and RouteLLM monthly compute-credit tracking with cookie import, manual cookie headers, timeout/browser-detection threading, optional billing fallback, and hardened cached-session retry behavior. Thanks @ChrisGVE! +- Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709). +- Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. +- Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! - z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation. - Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663). -- Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram! -- Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee! +- Antigravity: try both language-server and extension-server endpoint/token combinations, retry after API-level errors, scope insecure localhost trust handling to loopback hosts, and restore local quota/account probing on newer Antigravity builds (#693, fixes #692). Thanks @anirudhvee! - Antigravity: prefer `userTier.name` over generic plan info when rendering the account plan so Google AI Ultra and similar tiers show their real subscription name, while still falling back cleanly when the tier label is absent or blank (#303). Thanks @zacklavin11! -- Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709). - Ollama: recognize `__Secure-session` cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee! - OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary! -- Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205! - Refresh pipeline: skip background work for unavailable providers, clear stale cached state, and show explicit unavailable messages (#708). -- Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008! - Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han! - OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500. -- Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable. - OpenAI web: keep cached WebViews across same-account refreshes and clean them up only when accounts or providers go stale (#708). +- Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram! +- Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008! ### Menu & Settings - Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm! From 9aefd871820832d0d4b20df48af58f4df456f1ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 19:49:53 +0100 Subject: [PATCH 04/64] docs: update appcast for 0.21 --- appcast.xml | 99 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 41 deletions(-) diff --git a/appcast.xml b/appcast.xml index 4a55cde5f..0585c6e37 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,64 @@ CodexBar + + 0.21 + Sat, 18 Apr 2026 19:49:47 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 56 + 0.21 + 14.0 + CodexBar 0.21 +

Highlights

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

Providers & Usage

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

Menu & Settings

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

Development & Tooling

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

View full changelog

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

Highlights

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

Providers & Usage

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

Menu & Settings

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

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 41288a8081aaa1d321024874df5d12cb97a48188 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 19:19:00 +0000 Subject: [PATCH 05/64] Expand MiniMax Token Plan with multi-model remains and menu sections Co-authored-by: c.skeleton --- Sources/CodexBar/MenuCardView.swift | 222 +++++++++++++++++- .../Providers/MiniMax/MiniMaxModelUsage.swift | 58 +++++ .../MiniMax/MiniMaxUsageFetcher.swift | 152 +++++++++++- .../MiniMax/MiniMaxUsageSnapshot.swift | 6 +- .../CodexBarTests/MiniMaxMenuCardTests.swift | 143 +++++++++++ .../CodexBarTests/MiniMaxProviderTests.swift | 66 ++++++ docs/minimax.md | 7 + 7 files changed, 646 insertions(+), 8 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift create mode 100644 Tests/CodexBarTests/MiniMaxMenuCardTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 4d9c05127..9d87f362d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -88,6 +88,22 @@ struct UsageMenuCardView: View { let spendLine: String } + /// Grouped Token Plan rows (`model_remains[]`) for MiniMax menu card. + struct MiniMaxSection { + let title: String + let rows: [MiniMaxRow] + } + + struct MiniMaxRow: Identifiable, Equatable { + let id: String + let title: String + let percent: Double + let percentStyle: PercentStyle + let resetText: String? + let detailText: String? + let secondaryLine: String? + } + let provider: UsageProvider let providerName: String let email: String @@ -95,6 +111,8 @@ struct UsageMenuCardView: View { let subtitleStyle: SubtitleStyle let planText: String? let metrics: [Metric] + /// Non-nil only for MiniMax when `model_remains` has more than one row or weekly detail. + let minimaxSections: [MiniMaxSection]? let usageNotes: [String] let creditsText: String? let creditsRemaining: Double? @@ -125,7 +143,8 @@ struct UsageMenuCardView: View { Divider() } - if self.model.metrics.isEmpty { + let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false + if self.model.metrics.isEmpty, !hasMiniMaxSections { if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder { @@ -134,7 +153,7 @@ struct UsageMenuCardView: View { .font(.subheadline) } } else { - let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty + let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || hasMiniMaxSections let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasCost = self.model.tokenUsage != nil || hasProviderCost @@ -151,6 +170,11 @@ struct UsageMenuCardView: View { if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } + if let sections = self.model.minimaxSections, !sections.isEmpty { + MiniMaxTokenPlanSectionsView( + sections: sections, + progressColor: self.model.progressColor) + } } } if hasUsage, hasCredits || hasCost { @@ -216,7 +240,8 @@ struct UsageMenuCardView: View { private var hasDetails: Bool { !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil || self.model.tokenUsage != nil || - self.model.providerCost != nil + self.model.providerCost != nil || + (self.model.minimaxSections?.isEmpty == false) } } @@ -346,6 +371,70 @@ private struct ProviderCostContent: View { } } +private struct MiniMaxTokenPlanSectionsView: View { + let sections: [UsageMenuCardView.Model.MiniMaxSection] + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(Array(self.sections.enumerated()), id: \.offset) { _, section in + VStack(alignment: .leading, spacing: 8) { + Text(section.title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + ForEach(section.rows) { row in + MiniMaxTokenPlanRowView(row: row, progressColor: self.progressColor) + } + } + } + } + .padding(.top, 4) + } +} + +private struct MiniMaxTokenPlanRowView: View { + let row: UsageMenuCardView.Model.MiniMaxRow + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(self.row.title) + .font(.footnote) + .fontWeight(.medium) + if let statusText = self.row.detailText, statusText.isEmpty == false { + Text(statusText) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + UsageProgressBar( + percent: self.row.percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) + .font(.caption2) + Spacer() + if let reset = self.row.resetText { + Text(reset) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(2) + } + } + if let secondary = self.row.secondaryLine, !secondary.isEmpty { + Text(secondary) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + private struct MetricRow: View { let metric: UsageMenuCardView.Model.Metric let title: String @@ -459,8 +548,9 @@ struct UsageMenuCardUsageSectionView: View { @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { + let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false VStack(alignment: .leading, spacing: 12) { - if self.model.metrics.isEmpty { + if self.model.metrics.isEmpty, !hasMiniMaxSections { if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder { @@ -479,6 +569,11 @@ struct UsageMenuCardUsageSectionView: View { UsageNotesContent(notes: self.model.usageNotes) } } + if let sections = self.model.minimaxSections, !sections.isEmpty { + MiniMaxTokenPlanSectionsView( + sections: sections, + progressColor: self.model.progressColor) + } if self.showBottomDivider { Divider() } @@ -753,6 +848,7 @@ extension UsageMenuCardView.Model { lastError: input.lastError) let redacted = Self.redactedText(input: input, subtitle: subtitle) let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil + let minimaxSections = Self.miniMaxSections(input: input) return UsageMenuCardView.Model( provider: input.provider, @@ -762,6 +858,7 @@ extension UsageMenuCardView.Model { subtitleStyle: subtitle.style, planText: planText, metrics: metrics, + minimaxSections: minimaxSections, usageNotes: usageNotes, creditsText: creditsText, creditsRemaining: input.credits?.remaining, @@ -773,6 +870,123 @@ extension UsageMenuCardView.Model { progressColor: Self.progressColor(for: input.provider)) } + private static func miniMaxSections(input: Input) -> [MiniMaxSection]? { + guard input.provider == .minimax, + let models = input.snapshot?.minimaxUsage?.models, + !models.isEmpty + else { + return nil + } + let hasWeeklyDetail = models.contains { $0.weeklyTotal != nil || $0.weeklyRemaining != nil } + guard models.count > 1 || hasWeeklyDetail else { + return nil + } + + let fiveHour = models.filter { if case .fiveHour = $0.window { return true }; return false } + let daily = models.filter { if case .daily = $0.window { return true }; return false } + let weeklyOnly = models.filter { if case .weekly = $0.window { return true }; return false } + let other = models.filter { if case .other = $0.window { return true }; return false } + + var sections: [MiniMaxSection] = [] + if !fiveHour.isEmpty { + sections.append(MiniMaxSection( + title: "5-hour window", + rows: fiveHour.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !daily.isEmpty { + sections.append(MiniMaxSection( + title: "Daily quota", + rows: daily.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !weeklyOnly.isEmpty { + sections.append(MiniMaxSection( + title: "Weekly quota", + rows: weeklyOnly.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !other.isEmpty { + sections.append(MiniMaxSection( + title: "Other windows", + rows: other.map { Self.miniMaxRow(model: $0, input: input) })) + } + return sections.isEmpty ? nil : sections + } + + private static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow { + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left + let used = model.usedPercent ?? 0 + let barPercent = percentStyle == .used ? used : (100 - used) + let resetText: String? = if let at = model.resetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: used, + windowMinutes: model.windowMinutes, + resetsAt: at, + resetDescription: nil), + style: input.resetTimeDisplayStyle, + now: input.now) + } else { + nil + } + let detailText = Self.miniMaxDetailLine(model: model) + let secondaryLine = Self.miniMaxWeeklySecondaryLine(model: model, input: input) + return MiniMaxRow( + id: model.identifier, + title: model.displayName, + percent: Self.clamped(barPercent), + percentStyle: percentStyle, + resetText: resetText, + detailText: detailText, + secondaryLine: secondaryLine) + } + + private static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { + guard let total = model.availablePrompts else { return nil } + let used = model.currentPrompts ?? max(0, total - (model.remainingPrompts ?? 0)) + let remaining = model.remainingPrompts + let usedStr = UsageFormatter.tokenCountString(used) + let totalStr = UsageFormatter.tokenCountString(total) + if let remaining { + let remStr = UsageFormatter.tokenCountString(remaining) + return "\(usedStr)/\(totalStr) (\(remStr) remaining)" + } + return "\(usedStr)/\(totalStr)" + } + + private static func miniMaxWeeklySecondaryLine(model: MiniMaxModelUsage, input: Input) -> String? { + guard model.weeklyTotal != nil || model.weeklyRemaining != nil else { return nil } + let total = model.weeklyTotal + let used = model.weeklyUsed + let remaining = model.weeklyRemaining + let usedStr = used.map { UsageFormatter.tokenCountString($0) } ?? "—" + let totalStr = total.map { UsageFormatter.tokenCountString($0) } ?? "—" + let pctStr: String = if let p = model.weeklyUsedPercent { + String(format: "%.1f%%", p) + } else { + "—" + } + let weeklyReset: String? = if let at = model.weeklyResetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: model.weeklyUsedPercent ?? 0, + windowMinutes: 7 * 24 * 60, + resetsAt: at, + resetDescription: nil), + style: input.resetTimeDisplayStyle, + now: input.now) + } else { + nil + } + let remStr = remaining.map { UsageFormatter.tokenCountString($0) } + var line = "↳ Weekly \(usedStr)/\(totalStr) (\(pctStr) used)" + if let remStr { + line += " · \(remStr) remaining" + } + if let weeklyReset { + line += " · \(weeklyReset)" + } + return line + } + private static func usageNotes(input: Input) -> [String] { if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift new file mode 100644 index 000000000..63df7701e --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift @@ -0,0 +1,58 @@ +import Foundation + +/// One quota row from `coding_plan/remains` `model_remains[]` (Token Plan). +public struct MiniMaxModelUsage: Sendable, Equatable { + public enum WindowKind: Sendable, Equatable { + case fiveHour + case daily + case weekly + case other(minutes: Int?) + } + + public let identifier: String + public let displayName: String + public let availablePrompts: Int? + public let currentPrompts: Int? + public let remainingPrompts: Int? + public let windowMinutes: Int? + public let usedPercent: Double? + public let resetsAt: Date? + public let weeklyTotal: Int? + public let weeklyUsed: Int? + public let weeklyRemaining: Int? + public let weeklyUsedPercent: Double? + public let weeklyResetsAt: Date? + public let window: WindowKind + + public init( + identifier: String, + displayName: String, + availablePrompts: Int?, + currentPrompts: Int?, + remainingPrompts: Int?, + windowMinutes: Int?, + usedPercent: Double?, + resetsAt: Date?, + weeklyTotal: Int?, + weeklyUsed: Int?, + weeklyRemaining: Int?, + weeklyUsedPercent: Double?, + weeklyResetsAt: Date?, + window: WindowKind) + { + self.identifier = identifier + self.displayName = displayName + self.availablePrompts = availablePrompts + self.currentPrompts = currentPrompts + self.remainingPrompts = remainingPrompts + self.windowMinutes = windowMinutes + self.usedPercent = usedPercent + self.resetsAt = resetsAt + self.weeklyTotal = weeklyTotal + self.weeklyUsed = weeklyUsed + self.weeklyRemaining = weeklyRemaining + self.weeklyUsedPercent = weeklyUsedPercent + self.weeklyResetsAt = weeklyResetsAt + self.window = window + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 1b78131d5..62a91c4c1 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -393,6 +393,15 @@ struct MiniMaxModelRemains: Decodable { let startTime: Int? let endTime: Int? let remainsTime: Int? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? + let weeklyEndTime: Int? + let weeklyRemainsTime: Int? + let modelName: String? + let modelType: String? + let modelId: String? + let modelTitle: String? + let displayName: String? private enum CodingKeys: String, CodingKey { case currentIntervalTotalCount = "current_interval_total_count" @@ -400,6 +409,16 @@ struct MiniMaxModelRemains: Decodable { case startTime = "start_time" case endTime = "end_time" case remainsTime = "remains_time" + case currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" + case weeklyEndTime = "weekly_end_time" + case weeklyRemainsTime = "weekly_remains_time" + case modelName = "model_name" + case modelType = "model_type" + case modelId = "model_id" + case modelTitle = "model_title" + case displayName = "name" + case title = "title" } init(from decoder: Decoder) throws { @@ -409,6 +428,19 @@ struct MiniMaxModelRemains: Decodable { self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) + self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + self.modelType = try container.decodeIfPresent(String.self, forKey: .modelType) + self.modelId = try container.decodeIfPresent(String.self, forKey: .modelId) + self.modelTitle = try container.decodeIfPresent(String.self, forKey: .modelTitle) + if let name = try container.decodeIfPresent(String.self, forKey: .displayName) { + self.displayName = name + } else { + self.displayName = try container.decodeIfPresent(String.self, forKey: .title) + } } } @@ -487,7 +519,8 @@ enum MiniMaxUsageParser { windowMinutes: available?.windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + models: []) } static func parseCodingPlanRemains( @@ -504,7 +537,8 @@ enum MiniMaxUsageParser { throw MiniMaxUsageError.apiError(message) } - guard let first = payload.data.modelRemains.first else { + let rows = payload.data.modelRemains + guard let first = rows.first else { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } @@ -533,6 +567,10 @@ enum MiniMaxUsageParser { nil } + let models = rows.enumerated().map { index, row in + self.buildModelUsage(row: row, index: index, now: now) + } + return MiniMaxUsageSnapshot( planName: planName, availablePrompts: total, @@ -541,7 +579,115 @@ enum MiniMaxUsageParser { windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + models: models) + } + + private static func buildModelUsage(row: MiniMaxModelRemains, index: Int, now: Date) -> MiniMaxModelUsage { + let total = row.currentIntervalTotalCount + let remaining = row.currentIntervalUsageCount + let usedPercent = self.usedPercent(total: total, remaining: remaining) + let startDate = self.dateFromEpoch(row.startTime) + let endDate = self.dateFromEpoch(row.endTime) + let windowMinutes = self.windowMinutes(start: startDate, end: endDate) + let resetsAt = self.resetsAt(end: endDate, remains: row.remainsTime, now: now) + + let currentPrompts: Int? = if let total, let remaining { + max(0, total - remaining) + } else { + nil + } + + let weeklyTotal = row.currentWeeklyTotalCount + let weeklyRemaining = row.currentWeeklyUsageCount + let weeklyUsed: Int? = if let weeklyTotal, let weeklyRemaining { + max(0, weeklyTotal - weeklyRemaining) + } else { + nil + } + let weeklyUsedPercent = self.usedPercent(total: weeklyTotal, remaining: weeklyRemaining) + let weeklyEndDate = self.dateFromEpoch(row.weeklyEndTime) + let weeklyResetsAt = self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) + + let identifier = self.modelIdentifier(row: row, index: index) + let displayName = self.modelDisplayName(row: row, identifier: identifier) + let windowKind = self.classifyWindowKind( + windowMinutes: windowMinutes, + start: startDate, + end: endDate, + hasWeeklyQuota: weeklyTotal != nil || weeklyRemaining != nil, + hasIntervalQuota: total != nil || remaining != nil) + + return MiniMaxModelUsage( + identifier: identifier, + displayName: displayName, + availablePrompts: total, + currentPrompts: currentPrompts, + remainingPrompts: remaining, + windowMinutes: windowMinutes, + usedPercent: usedPercent, + resetsAt: resetsAt, + weeklyTotal: weeklyTotal, + weeklyUsed: weeklyUsed, + weeklyRemaining: weeklyRemaining, + weeklyUsedPercent: weeklyUsedPercent, + weeklyResetsAt: weeklyResetsAt, + window: windowKind) + } + + private static func modelIdentifier(row: MiniMaxModelRemains, index: Int) -> String { + let candidates = [ + row.modelName, + row.modelType, + row.modelId, + row.modelTitle, + row.displayName, + ] + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + } + return "model-\(index + 1)" + } + + private static func modelDisplayName(row: MiniMaxModelRemains, identifier: String) -> String { + let candidates = [ + row.modelName, + row.displayName, + row.modelTitle, + row.modelType, + row.modelId, + ] + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + } + return identifier + } + + private static func classifyWindowKind( + windowMinutes: Int?, + start: Date?, + end: Date?, + hasWeeklyQuota: Bool, + hasIntervalQuota: Bool) -> MiniMaxModelUsage.WindowKind + { + if let windowMinutes, windowMinutes == 300 { + return .fiveHour + } + if let windowMinutes, windowMinutes == 24 * 60 { + return .daily + } + if let start, let end { + let mins = Int(end.timeIntervalSince(start) / 60) + if mins >= 1380, mins <= 1500 { + return .daily + } + } + if hasWeeklyQuota, !hasIntervalQuota { + return .weekly + } + return .other(minutes: windowMinutes) } private static func usedPercent(total: Int?, remaining: Int?) -> Double? { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 09ed671e2..2e5732f77 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -9,6 +9,8 @@ public struct MiniMaxUsageSnapshot: Sendable { public let usedPercent: Double? public let resetsAt: Date? public let updatedAt: Date + /// All rows from `model_remains` (first row mirrors the scalar fields above). + public let models: [MiniMaxModelUsage] public init( planName: String?, @@ -18,7 +20,8 @@ public struct MiniMaxUsageSnapshot: Sendable { windowMinutes: Int?, usedPercent: Double?, resetsAt: Date?, - updatedAt: Date) + updatedAt: Date, + models: [MiniMaxModelUsage] = []) { self.planName = planName self.availablePrompts = availablePrompts @@ -28,6 +31,7 @@ public struct MiniMaxUsageSnapshot: Sendable { self.usedPercent = usedPercent self.resetsAt = resetsAt self.updatedAt = updatedAt + self.models = models } } diff --git a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift new file mode 100644 index 000000000..28a5cbde6 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift @@ -0,0 +1,143 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MiniMaxMenuCardTests { + @Test + func `minimax sections group fiveHour and daily rows`() throws { + let now = Date() + let models: [MiniMaxModelUsage] = [ + MiniMaxModelUsage( + identifier: "text-gen", + displayName: "Text", + availablePrompts: 4500, + currentPrompts: 100, + remainingPrompts: 4400, + windowMinutes: 300, + usedPercent: 2.2, + resetsAt: nil, + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .fiveHour), + MiniMaxModelUsage( + identifier: "image-01", + displayName: "image-01", + availablePrompts: 120, + currentPrompts: 0, + remainingPrompts: 120, + windowMinutes: 1440, + usedPercent: 0, + resetsAt: nil, + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .daily), + ] + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan", + availablePrompts: 4500, + currentPrompts: 100, + remainingPrompts: 4400, + windowMinutes: 300, + usedPercent: 2.2, + resetsAt: nil, + updatedAt: now, + models: models) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let sections = try #require(model.minimaxSections) + #expect(sections.count == 2) + #expect(sections[0].title == "5-hour window") + #expect(sections[0].rows.count == 1) + #expect(sections[0].rows[0].title == "Text") + #expect(sections[1].title == "Daily quota") + #expect(sections[1].rows[0].title == "image-01") + } + + @Test + func `minimax shows sections when single row has weekly quota`() throws { + let now = Date() + let models: [MiniMaxModelUsage] = [ + MiniMaxModelUsage( + identifier: "speech-hd", + displayName: "Speech HD", + availablePrompts: 11000, + currentPrompts: 10995, + remainingPrompts: 5, + windowMinutes: 1440, + usedPercent: 99.95, + resetsAt: nil, + weeklyTotal: 77000, + weeklyUsed: 6354, + weeklyRemaining: 70646, + weeklyUsedPercent: 91.7, + weeklyResetsAt: nil, + window: .daily), + ] + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan", + availablePrompts: 11000, + currentPrompts: 10995, + remainingPrompts: 5, + windowMinutes: 1440, + usedPercent: 99.95, + resetsAt: nil, + updatedAt: now, + models: models) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let sections = try #require(model.minimaxSections) + #expect(sections.count == 1) + let row = try #require(sections.first?.rows.first) + #expect(row.secondaryLine?.contains("Weekly") == true) + #expect(row.secondaryLine?.contains("70646") == true) + } +} diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 7cf0524bc..5cb6e31e0 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -113,6 +113,8 @@ struct MiniMaxUsageParserTests { #expect(snapshot.windowMinutes == 300) #expect(snapshot.usedPercent == 75) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.models.count == 1) + #expect(snapshot.models.first?.window == .fiveHour) } @Test @@ -149,6 +151,69 @@ struct MiniMaxUsageParserTests { #expect(snapshot.windowMinutes == 300) #expect(abs((snapshot.usedPercent ?? 0) - expectedUsed) < 0.01) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.models.count == 1) + } + + @Test + func `parses multiple model_remains rows and weekly fields`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start5h = 1_700_000_000_000 + let end5h = start5h + 5 * 60 * 60 * 1000 + let dayStart = 1_700_000_000_000 + let dayEnd = dayStart + 24 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Token Plan", + "model_remains": [ + { + "model_name": "text-gen", + "current_interval_total_count": 4500, + "current_interval_usage_count": 4381, + "start_time": \(start5h), + "end_time": \(end5h), + "remains_time": 240000 + }, + { + "model_name": "image-01", + "current_interval_total_count": 120, + "current_interval_usage_count": 120, + "start_time": \(dayStart), + "end_time": \(dayEnd), + "remains_time": 3600000 + }, + { + "model_name": "speech-hd", + "current_interval_total_count": 11000, + "current_interval_usage_count": 5, + "current_weekly_total_count": "77000", + "current_weekly_usage_count": "70646", + "start_time": \(dayStart), + "end_time": \(dayEnd), + "remains_time": 3600000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Token Plan") + #expect(snapshot.availablePrompts == 4500) + #expect(snapshot.models.count == 3) + + let text = snapshot.models.first { $0.identifier == "text-gen" } + #expect(text?.window == .fiveHour) + #expect(text?.currentPrompts == 119) + + let image = snapshot.models.first { $0.identifier == "image-01" } + #expect(image?.window == .daily) + #expect(image?.usedPercent == 0) + + let speech = snapshot.models.first { $0.identifier == "speech-hd" } + #expect(speech?.weeklyTotal == 77000) + #expect(speech?.weeklyRemaining == 70646) + #expect(speech?.weeklyUsed == 6354) } @Test @@ -193,6 +258,7 @@ struct MiniMaxUsageParserTests { #expect(snapshot.windowMinutes == 300) #expect(snapshot.usedPercent == 75) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.models.count == 1) } @Test diff --git a/docs/minimax.md b/docs/minimax.md index 6b7b7d647..58d62309c 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -80,7 +80,14 @@ either a Bearer API token or a session cookie header. - Reset: derived from `remains_time` (fallback to `end_time`) or HTML "Resets in …". - Plan/tier: best-effort from response fields or HTML title. +### Coding Plan multi-model (`model_remains[]`) +- The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. +- Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). +- Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. +- Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**. + ## Key files - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` +- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` From ab3b0b0e81c369d04e1c68b1e7cf0a324aeee3ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 20:31:38 +0100 Subject: [PATCH 06/64] chore: start 0.22 development --- CHANGELOG.md | 2 ++ version.env | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec428c9c9..fc9abc84c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.22 — Unreleased + ## 0.21 — 2026-04-18 ### Highlights diff --git a/version.env b/version.env index b28d35c74..f2f277da7 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.21 +MARKETING_VERSION=0.22 BUILD_NUMBER=56 From 390a8b9866e2a3ab0fe9dbbd618196a79889d7b1 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 15 Apr 2026 21:18:51 +0800 Subject: [PATCH 07/64] Handle fnm Gemini OAuth config discovery Gemini token refresh failed when the CLI was installed via fnm because CodexBar only looked for OAuth client credentials in legacy oauth2.js paths under node_modules. fnm installs can expose Gemini through fnm_multishells symlinks and bundle the CLI into bundle/gemini.js plus hashed chunk files, so neither the Node version directory nor the chunk filename is stable enough to hardcode. Update Gemini OAuth credential discovery to: - keep the cheap legacy oauth2.js path reads as the primary lookup - detect fnm-managed paths and resolve the active Gemini package root via fnm - support bundled CLI layouts by following bundle imports instead of assuming fixed chunk names - bound the directory walk-up so an unrelated Gemini install elsewhere on the host cannot contaminate discovery started from the actual binary path Also add API tests covering the legacy layouts and the fnm bundle layout so expired-token refresh works across both installation styles. --- .../Providers/Gemini/GeminiStatusProbe.swift | 288 +++++++++++++++++- .../GeminiStatusProbeAPITests.swift | 124 ++++++++ .../CodexBarTests/GeminiTestEnvironment.swift | 172 ++++++++++- 3 files changed, 555 insertions(+), 29 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index d978630aa..7ec634f59 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -475,20 +475,280 @@ public struct GeminiStatusProbe: Sendable { } // Resolve symlinks to find the actual installation - let fm = FileManager.default - var realPath = geminiPath - if let resolved = try? fm.destinationOfSymbolicLink(atPath: geminiPath) { - if resolved.hasPrefix("/") { - realPath = resolved - } else { - realPath = (geminiPath as NSString).deletingLastPathComponent + "/" + resolved + let resolvedGeminiPath = URL(fileURLWithPath: geminiPath).resolvingSymlinksInPath().path + + // Try the legacy layouts first — they're cheap file reads and cover the common cases + // (Homebrew, npm/bun sibling, Nix) without spawning subprocesses or walking the tree. + if let credentials = Self.extractOAuthCredentialsFromLegacyPaths(realGeminiPath: resolvedGeminiPath) { + return credentials + } + + // For fnm-managed installs, ask fnm where the package lives + if Self.isLikelyFnmManagedPath(geminiPath) || Self.isLikelyFnmManagedPath(resolvedGeminiPath), + let fnmPath = TTYCommandRunner.which("fnm"), + let packageRoot = Self.resolveGeminiPackageRootViaFnm(fnmPath: fnmPath, environment: env), + let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) + { + return credentials + } + + // Fall back to walking up the directory tree from the binary + if let packageRoot = Self.findGeminiPackageRoot(startingAt: resolvedGeminiPath), + let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) + { + return credentials + } + + return nil + } + + private static func isLikelyFnmManagedPath(_ path: String) -> Bool { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + return normalized.contains("/fnm_multishells/") + || (normalized.contains("/node-versions/") && normalized.contains("/fnm/")) + } + + private static func resolveGeminiPackageRootViaFnm( + fnmPath: String, + environment: [String: String]) -> String? + { + guard let currentVersion = runProcess( + executable: fnmPath, + arguments: ["current"], + environment: environment, + timeout: 2.0), + !currentVersion.isEmpty + else { + return nil + } + + // Prefer npm root -g because require.resolve searches from the current + // working directory and often fails for globally-installed packages. + if let npmRoot = runProcess( + executable: fnmPath, + arguments: [ + "exec", + "--using", + currentVersion, + "npm", + "root", + "-g", + ], + environment: environment, + timeout: 4.0), + !npmRoot.isEmpty + { + let packageRoot = "\(npmRoot)/@google/gemini-cli" + let packageJSONPath = "\(packageRoot)/package.json" + if FileManager.default.fileExists(atPath: packageJSONPath) { + return packageRoot + } + } + + // Fallback for non-npm global installations. + if let packageJSONPath = runProcess( + executable: fnmPath, + arguments: [ + "exec", + "--using", + currentVersion, + "node", + "-p", + "require.resolve('@google/gemini-cli/package.json')", + ], + environment: environment, + timeout: 4.0), + !packageJSONPath.isEmpty + { + return (packageJSONPath as NSString).deletingLastPathComponent + } + + return nil + } + + private static func runProcess( + executable: String, + arguments: [String], + environment: [String: String], + timeout: TimeInterval) -> String? + { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + + var mergedEnvironment = environment + mergedEnvironment["PATH"] = PathBuilder.effectivePATH( + purposes: [.tty, .nodeTooling], + env: environment, + loginPATH: LoginShellPathCache.shared.current) + process.environment = mergedEnvironment + + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + process.standardInput = nil + + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSemaphore.signal() + } + + do { + try process.run() + } catch { + return nil + } + + let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success + if !didExit { + if process.isRunning { + process.terminate() + _ = exitSemaphore.wait(timeout: .now() + 0.5) + } + return nil + } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0, + let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty + else { + return nil + } + + return output.components(separatedBy: .newlines).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func findGeminiPackageRoot(startingAt path: String) -> String? { + let fileManager = FileManager.default + var currentURL = URL(fileURLWithPath: path).standardizedFileURL + + var isDirectory: ObjCBool = false + if !fileManager.fileExists(atPath: currentURL.path, isDirectory: &isDirectory) || !isDirectory.boolValue { + currentURL.deleteLastPathComponent() + } + + // Bound the walk so an unrelated Gemini install elsewhere on the host + // (e.g. a global npm/brew install unrelated to the resolved binary) can't + // contaminate discovery started from the actual binary path. + let maxAscents = 8 + for _ in 0...maxAscents { + let packageJSONURL = currentURL.appendingPathComponent("package.json") + if let data = try? Data(contentsOf: packageJSONURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + json["name"] as? String == "@google/gemini-cli" + { + return currentURL.path + } + + // Also check for a global Node installation layout: + // /lib/node_modules/@google/gemini-cli/package.json + let globalPackageJSONURL = currentURL + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("package.json") + if let data = try? Data(contentsOf: globalPackageJSONURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + json["name"] as? String == "@google/gemini-cli" + { + return globalPackageJSONURL.deletingLastPathComponent().path + } + + let parentURL = currentURL.deletingLastPathComponent() + if parentURL.path == currentURL.path { + return nil + } + currentURL = parentURL + } + + return nil + } + + private static func extractOAuthCredentials(fromGeminiPackageRoot packageRoot: String) -> OAuthClientCredentials? { + // Check the standard distributed file first, then any sibling core package + let oauthFile = "dist/src/code_assist/oauth2.js" + let candidatePaths = [ + "\(packageRoot)/\(oauthFile)", + "\(packageRoot)/node_modules/@google/gemini-cli-core/\(oauthFile)", + ] + + for path in candidatePaths { + if let content = try? String(contentsOfFile: path, encoding: .utf8), + let credentials = Self.parseOAuthCredentials(from: content) + { + return credentials + } + } + + return Self.extractOAuthCredentialsFromBundle(packageRoot: packageRoot) + } + + private static func extractOAuthCredentialsFromBundle(packageRoot: String) -> OAuthClientCredentials? { + let bundleRoot = URL(fileURLWithPath: packageRoot).appendingPathComponent("bundle", isDirectory: true) + let entryURL = bundleRoot.appendingPathComponent("gemini.js") + + guard FileManager.default.fileExists(atPath: entryURL.path) else { + return nil + } + + var pendingURLs = [entryURL] + var visitedPaths = Set() + + while !pendingURLs.isEmpty { + let currentURL = pendingURLs.removeFirst() + let standardizedPath = currentURL.standardizedFileURL.path + guard visitedPaths.insert(standardizedPath).inserted, + let content = try? String(contentsOf: currentURL, encoding: .utf8) + else { + continue + } + + if let credentials = Self.parseOAuthCredentials(from: content) { + return credentials + } + + let imports = Self.extractRelativeJavaScriptImports(from: content) + for importPath in imports { + let nextURL = URL(fileURLWithPath: importPath, relativeTo: currentURL.deletingLastPathComponent()) + .standardizedFileURL + guard nextURL.path.hasPrefix(bundleRoot.path) else { continue } + pendingURLs.append(nextURL) } } - // Navigate from bin/gemini to the oauth2.js file - // Homebrew path: .../libexec/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js - // Bun/npm path: .../node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js (sibling package) - let binDir = (realPath as NSString).deletingLastPathComponent + return nil + } + + private static func extractRelativeJavaScriptImports(from content: String) -> [String] { + let patterns = [ + #"(?:import|export)\s+(?:[^;]*?\s+from\s+)?[\"'](\./[^\"']+\.js)[\"']"#, + #"import\(\s*[\"'](\./[^\"']+\.js)[\"']\s*\)"#, + ] + + var discoveredPaths: [String] = [] + var seen = Set() + let fullRange = NSRange(content.startIndex..., in: content) + + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } + for match in regex.matches(in: content, range: fullRange) { + guard let range = Range(match.range(at: 1), in: content) else { continue } + let path = String(content[range]) + if seen.insert(path).inserted { + discoveredPaths.append(path) + } + } + } + + return discoveredPaths + } + + private static func extractOAuthCredentialsFromLegacyPaths(realGeminiPath: String) -> OAuthClientCredentials? { + let binDir = (realGeminiPath as NSString).deletingLastPathComponent let baseDir = (binDir as NSString).deletingLastPathComponent let oauthSubpath = @@ -509,8 +769,10 @@ public struct GeminiStatusProbe: Sendable { ] for path in possiblePaths { - if let content = try? String(contentsOfFile: path, encoding: .utf8) { - return self.parseOAuthCredentials(from: content) + if let content = try? String(contentsOfFile: path, encoding: .utf8), + let credentials = Self.parseOAuthCredentials(from: content) + { + return credentials } } diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index f4a457fa8..61cb8b0f5 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -67,6 +67,13 @@ struct GeminiStatusProbeAPITests { switch host { case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } let json = GeminiAPITestHelpers.jsonData([ "access_token": "new-token", "expires_in": 3600, @@ -139,6 +146,120 @@ struct GeminiStatusProbeAPITests { switch host { case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + let json = GeminiAPITestHelpers.jsonData(["projects": []]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + let auth = request.value(forHTTPHeaderField: "Authorization") + if auth != "Bearer new-token" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + let auth = request.value(forHTTPHeaderField: "Authorization") + if auth != "Bearer new-token" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Paid") + } + + @Test + func `refreshes expired token with fnm bundle layout`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + let binURL = try env.writeFakeGeminiCLI(layout: .fnmBundle) + // Match the real fnm layout: package root is inside the same multishell + // dir as the bin symlink target, under lib/node_modules/@google/gemini-cli. + let multishellRoot = binURL.deletingLastPathComponent().deletingLastPathComponent() + let packageJSONPath = multishellRoot + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("package.json") + let npmRoot = packageJSONPath + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + _ = try env.writeFakeFnm(npmRoot: npmRoot, geminiPackageJSONPath: packageJSONPath.path) + + let previousPath = ProcessInfo.processInfo.environment["PATH"] + let fakeBinDir = env.homeURL.appendingPathComponent("bin").path + let pathValue = if let previousPath, !previousPath.isEmpty { + "\(fakeBinDir):\(binURL.deletingLastPathComponent().path):\(previousPath)" + } else { + "\(fakeBinDir):\(binURL.deletingLastPathComponent().path)" + } + setenv("PATH", pathValue, 1) + + let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + unsetenv("GEMINI_CLI_PATH") + defer { + if let previousPath { + setenv("PATH", previousPath, 1) + } else { + unsetenv("PATH") + } + + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } let json = GeminiAPITestHelpers.jsonData([ "access_token": "new-token", "expires_in": 3600, @@ -178,6 +299,9 @@ struct GeminiStatusProbeAPITests { let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) let snapshot = try await probe.fetch() #expect(snapshot.accountPlan == "Paid") + + let updated = try env.readCredentials() + #expect(updated["access_token"] as? String == "new-token") } @Test diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift index 3d6b0b4bb..0a371003e 100644 --- a/Tests/CodexBarTests/GeminiTestEnvironment.swift +++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift @@ -4,6 +4,7 @@ struct GeminiTestEnvironment { enum GeminiCLILayout { case npmNested case nixShare + case fnmBundle } let homeURL: URL @@ -57,9 +58,9 @@ struct GeminiTestEnvironment { let binDir = base.appendingPathComponent("bin") try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) - let oauthPath: URL = switch layout { + switch layout { case .npmNested: - base + let oauthPath = base .appendingPathComponent("lib") .appendingPathComponent("node_modules") .appendingPathComponent("@google") @@ -71,8 +72,28 @@ struct GeminiTestEnvironment { .appendingPathComponent("src") .appendingPathComponent("code_assist") .appendingPathComponent("oauth2.js") + + if includeOAuth { + try FileManager.default.createDirectory( + at: oauthPath.deletingLastPathComponent(), + withIntermediateDirectories: true) + + let oauthContent = """ + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + } + + let geminiBinary = binDir.appendingPathComponent("gemini") + try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiBinary.path) + return geminiBinary + case .nixShare: - base + let oauthPath = base .appendingPathComponent("share") .appendingPathComponent("gemini-cli") .appendingPathComponent("node_modules") @@ -82,25 +103,144 @@ struct GeminiTestEnvironment { .appendingPathComponent("src") .appendingPathComponent("code_assist") .appendingPathComponent("oauth2.js") - } - if includeOAuth { - try FileManager.default.createDirectory( - at: oauthPath.deletingLastPathComponent(), - withIntermediateDirectories: true) + if includeOAuth { + try FileManager.default.createDirectory( + at: oauthPath.deletingLastPathComponent(), + withIntermediateDirectories: true) + + let oauthContent = """ + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + } - let oauthContent = """ - const OAUTH_CLIENT_ID = 'test-client-id'; - const OAUTH_CLIENT_SECRET = 'test-client-secret'; + let geminiBinary = binDir.appendingPathComponent("gemini") + try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiBinary.path) + return geminiBinary + + case .fnmBundle: + // Mirror a real fnm multishell layout: bin/gemini is a single relative + // symlink into the same multishell's lib/node_modules/@google/gemini-cli, + // which is a plain directory with the real package.json + bundle/*.js. + let multishellRoot = self.homeURL + .appendingPathComponent("Library") + .appendingPathComponent("Caches") + .appendingPathComponent("fnm_multishells") + .appendingPathComponent("12345_67890") + let binDir = multishellRoot.appendingPathComponent("bin") + let packageRoot = multishellRoot + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + let bundleDir = packageRoot.appendingPathComponent("bundle") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) + + let packageJSON = """ + { + "name": "@google/gemini-cli" + } """ - try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + try packageJSON.write( + to: packageRoot.appendingPathComponent("package.json"), + atomically: true, + encoding: .utf8) + + let chunkName = "chunk-TEST123.js" + let geminiEntry = bundleDir.appendingPathComponent("gemini.js") + let geminiContent = """ + #!/usr/bin/env node + import { start } from "./\(chunkName)"; + start(); + """ + try geminiContent.write(to: geminiEntry, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiEntry.path) + + let chunkContent = if includeOAuth { + """ + export const start = () => {}; + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + } else { + "export const start = () => {};\n" + } + try chunkContent.write( + to: bundleDir.appendingPathComponent(chunkName), + atomically: true, + encoding: .utf8) + + // Relative symlink matching what fnm actually creates: + // fnm_multishells/XXX/bin/gemini -> ../lib/node_modules/@google/gemini-cli/bundle/gemini.js + // Use the path-based API so the target is stored as a literal relative + // string; the URL-based API resolves URL(fileURLWithPath: "../...") against + // the process CWD, which produces a bogus absolute target. + let geminiBinary = binDir.appendingPathComponent("gemini") + try FileManager.default.createSymbolicLink( + atPath: geminiBinary.path, + withDestinationPath: "../lib/node_modules/@google/gemini-cli/bundle/gemini.js") + + return geminiBinary } + } + + func writeFakeFnm( + currentVersion: String = "v24.6.0", + npmRoot: String? = nil, + geminiPackageJSONPath: String) throws -> URL + { + let binDir = self.homeURL.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + + let fnmPath = binDir.appendingPathComponent("fnm") + let script = if let npmRoot { + """ + #!/bin/bash + if [ "$1" = "current" ]; then + printf '%s\n' "\(currentVersion)" + exit 0 + fi + + if [ "$1" = "exec" ] && [ "$4" = "npm" ] && [ "$5" = "root" ] && [ "$6" = "-g" ]; then + printf '%s\n' "\(npmRoot)" + exit 0 + fi - let geminiBinary = binDir.appendingPathComponent("gemini") - try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + if [ "$1" = "exec" ] && [ "$4" = "node" ]; then + printf '%s\n' "\(geminiPackageJSONPath)" + exit 0 + fi + + exit 1 + """ + } else { + """ + #!/bin/bash + if [ "$1" = "current" ]; then + printf '%s\n' "\(currentVersion)" + exit 0 + fi + + if [ "$1" = "exec" ]; then + printf '%s\n' "\(geminiPackageJSONPath)" + exit 0 + fi + + exit 1 + """ + } + try script.write(to: fnmPath, atomically: true, encoding: .utf8) try FileManager.default.setAttributes( [.posixPermissions: 0o755], - ofItemAtPath: geminiBinary.path) - return geminiBinary + ofItemAtPath: fnmPath.path) + return fnmPath } } From ad0aada26a9b12bbfdb96f03c5ed2c0c542cb07f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:28:27 +0100 Subject: [PATCH 08/64] docs: credit Gemini OAuth discovery fix (#723) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9abc84c..ba7796f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.22 — Unreleased +### Providers & Usage +- Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! + ## 0.21 — 2026-04-18 ### Highlights From 64f0bac2bb722471858a477439a6bd3a18f82e20 Mon Sep 17 00:00:00 2001 From: Erik Josephson Date: Tue, 24 Mar 2026 05:50:55 -0400 Subject: [PATCH 09/64] fix: handle errSecInteractionNotAllowed in KeychainCacheStore to prevent cache self-destruction on wake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the keychain is temporarily locked (e.g. immediately after wake from sleep), SecItemCopyMatching returns errSecInteractionNotAllowed (-25308). Previously this fell into the default case, returned .invalid, and the caller deleted the cache entry — causing every wake from sleep to require a fresh read of "Claude Code-credentials", which triggers a keychain prompt. Two changes: 1. Apply KeychainNoUIQuery to the cache load query so the call never blocks waiting for UI interaction (consistent with how other no-UI reads are done elsewhere in the codebase). 2. Add an explicit case for errSecInteractionNotAllowed that returns .missing instead of .invalid — the entry is valid, just temporarily inaccessible, so it should not be deleted. --- Sources/CodexBarCore/KeychainCacheStore.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index ebe5c45ae..2fbd1df2a 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -46,13 +46,14 @@ public enum KeychainCacheStore { return testResult } #if os(macOS) - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] + KeychainNoUIQuery.apply(to: &query) var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -70,6 +71,12 @@ public enum KeychainCacheStore { return .found(decoded) case errSecItemNotFound: return .missing + case errSecInteractionNotAllowed: + // Keychain is temporarily locked (e.g. immediately after wake from sleep). + // The cache entry is valid — treat as missing so the caller falls through + // gracefully rather than deleting a perfectly good cache entry. + self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") + return .missing default: self.log.error("Keychain cache read failed (\(key.account)): \(status)") return .invalid From aa26573b35fb354d16647bb1d8e92804050f9f8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:34:40 +0100 Subject: [PATCH 10/64] fix: preserve keychain cache after wake (#594) --- CHANGELOG.md | 3 ++ Sources/CodexBarCore/KeychainCacheStore.swift | 30 ++++++++++++------- .../KeychainCacheStoreTests.swift | 17 +++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7796f87..9c137ae33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Providers & Usage - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! +### Fixes +- Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98! + ## 0.21 — 2026-04-18 ### Highlights diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 2fbd1df2a..03060d9c2 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -69,17 +69,8 @@ public enum KeychainCacheStore { return .invalid } return .found(decoded) - case errSecItemNotFound: - return .missing - case errSecInteractionNotAllowed: - // Keychain is temporarily locked (e.g. immediately after wake from sleep). - // The cache entry is valid — treat as missing so the caller falls through - // gracefully rather than deleting a perfectly good cache entry. - self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") - return .missing default: - self.log.error("Keychain cache read failed (\(key.account)): \(status)") - return .invalid + return self.loadResultForKeychainReadFailure(status: status, key: key) } #else return .missing @@ -211,6 +202,25 @@ public enum KeychainCacheStore { return decoder } + #if os(macOS) + static func loadResultForKeychainReadFailure( + status: OSStatus, + key: Key) -> LoadResult + { + switch status { + case errSecItemNotFound: + return .missing + case errSecInteractionNotAllowed: + // Keychain is temporarily locked, e.g. immediately after wake from sleep. + self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") + return .missing + default: + self.log.error("Keychain cache read failed (\(key.account)): \(status)") + return .invalid + } + } + #endif + private static func loadFromTestStore( key: Key, as type: Entry.Type) -> LoadResult? diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 0eb90d640..41bd82728 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -68,4 +68,21 @@ struct KeychainCacheStoreTests { #expect(Bool(false), "Expected keychain cache entry to be cleared") } } + + #if os(macOS) + @Test + func `interaction not allowed is treated as temporarily missing`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let result: KeychainCacheStore.LoadResult = KeychainCacheStore.loadResultForKeychainReadFailure( + status: errSecInteractionNotAllowed, + key: key) + + switch result { + case .missing: + #expect(true) + case .found, .invalid: + #expect(Bool(false), "Expected temporary keychain lock to preserve cache") + } + } + #endif } From cad7f8385a552530886f8443c75f6485c78b75dc Mon Sep 17 00:00:00 2001 From: skhe Date: Sat, 18 Apr 2026 17:38:54 +0800 Subject: [PATCH 11/64] Prefer verification_uri_complete for Copilot login --- .../Providers/Copilot/CopilotLoginFlow.swift | 2 +- .../Providers/Copilot/CopilotDeviceFlow.swift | 6 +++ .../CopilotDeviceFlowTests.swift | 42 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/CopilotDeviceFlowTests.swift diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 55275ae61..1170f2236 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -30,7 +30,7 @@ struct CopilotLoginFlow { return // Cancelled } - if let url = URL(string: code.verificationUri) { + if let url = URL(string: code.verificationURLToOpen) { NSWorkspace.shared.open(url) } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift index 094ad9f2e..ff778adf8 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift @@ -11,13 +11,19 @@ public struct CopilotDeviceFlow: Sendable { public let deviceCode: String public let userCode: String public let verificationUri: String + public let verificationUriComplete: String? public let expiresIn: Int public let interval: Int + public var verificationURLToOpen: String { + self.verificationUriComplete ?? self.verificationUri + } + enum CodingKeys: String, CodingKey { case deviceCode = "device_code" case userCode = "user_code" case verificationUri = "verification_uri" + case verificationUriComplete = "verification_uri_complete" case expiresIn = "expires_in" case interval } diff --git a/Tests/CodexBarTests/CopilotDeviceFlowTests.swift b/Tests/CodexBarTests/CopilotDeviceFlowTests.swift new file mode 100644 index 000000000..7e0b1f20e --- /dev/null +++ b/Tests/CodexBarTests/CopilotDeviceFlowTests.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Foundation +import Testing + +struct CopilotDeviceFlowTests { + @Test + func `prefers verification uri complete when available`() throws { + let response = try JSONDecoder().decode( + CopilotDeviceFlow.DeviceCodeResponse.self, + from: Data( + """ + { + "device_code": "device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "verification_uri_complete": "https://github.com/login/device?user_code=ABCD-EFGH", + "expires_in": 900, + "interval": 5 + } + """.utf8)) + + #expect(response.verificationURLToOpen == "https://github.com/login/device?user_code=ABCD-EFGH") + } + + @Test + func `falls back to verification uri when complete url missing`() throws { + let response = try JSONDecoder().decode( + CopilotDeviceFlow.DeviceCodeResponse.self, + from: Data( + """ + { + "device_code": "device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5 + } + """.utf8)) + + #expect(response.verificationURLToOpen == "https://github.com/login/device") + } +} From d968dd9696125fad5e2dd4eb016eb520f3bb6262 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:38:52 +0100 Subject: [PATCH 12/64] docs: credit Copilot verification URL fix (#739) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c137ae33..d50ab29d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Providers & Usage - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! +- Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! ### Fixes - Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98! From ce27570243310d3f31bccee47c83196f96d9f419 Mon Sep 17 00:00:00 2001 From: hezhongtang Date: Mon, 13 Apr 2026 16:54:28 +0800 Subject: [PATCH 13/64] fix(alibaba): update China mainland RPC endpoint to bailian-cs.console.aliyun.com - Change consoleRPCBaseURLString from bailian-beijing-cs.aliyuncs.com to bailian-cs.console.aliyun.com (the old domain causes TLS errors) - Update consoleSite from BAILIAN_CONSOLE to BAILIAN_ALIYUN to match actual browser request payload - Add new domain to cookie importer allowlist and test stub --- .../Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift | 4 ++-- .../Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift | 1 + Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift index 1e7e5b772..c3d94e2ec 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift @@ -47,7 +47,7 @@ public enum AlibabaCodingPlanAPIRegion: String, CaseIterable, Sendable { case .international: "MODELSTUDIO_ALIBABACLOUD" case .chinaMainland: - "BAILIAN_CONSOLE" + "BAILIAN_ALIYUN" } } @@ -79,7 +79,7 @@ public enum AlibabaCodingPlanAPIRegion: String, CaseIterable, Sendable { case .international: "https://bailian-singapore-cs.alibabacloud.com" case .chinaMainland: - "https://bailian-beijing-cs.aliyuncs.com" + "https://bailian-cs.console.aliyun.com" } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift index ea354697a..9d8f9e27b 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift @@ -13,6 +13,7 @@ public enum AlibabaCodingPlanCookieImporter { private static let cookieClient = BrowserCookieClient() private static let cookieDomains = [ "bailian-singapore-cs.alibabacloud.com", + "bailian-cs.console.aliyun.com", "bailian-beijing-cs.aliyuncs.com", "modelstudio.console.alibabacloud.com", "bailian.console.aliyun.com", diff --git a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift index 413b9661f..a013df8f4 100644 --- a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift @@ -945,6 +945,7 @@ final class AlibabaConsoleSECTokenStubURLProtocol: URLProtocol { "modelstudio.console.alibabacloud.com", "bailian-singapore-cs.alibabacloud.com", "bailian.console.aliyun.com", + "bailian-cs.console.aliyun.com", "bailian-beijing-cs.aliyuncs.com", ].contains(host) } From aae3003dbb7e8b14599462c12d9a6255eceb1f64 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:43:01 +0100 Subject: [PATCH 14/64] docs: credit Alibaba endpoint fix (#712) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d50ab29d7..98007322c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Providers & Usage - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! +- Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! ### Fixes - Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98! From ea02e5fad770001f86d4105fe15c034dda10e596 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 17 Apr 2026 03:26:38 -0400 Subject: [PATCH 15/64] Handle Synthetic rolling, weekly, and search quota shape Parse Synthetic's current quota response (rollingFiveHourLimit, weeklyTokenLimit, search.hourly) in addition to the legacy pack format, and surface weekly credit regeneration pacing in the menu card. Keep slot identity stable so a missing lane never promotes another lane into the wrong UI label, and rebuild the countdown at render time so it doesn't freeze between snapshot refreshes. --- Sources/CodexBar/MenuCardView.swift | 45 +++- .../CodexBarCore/ProviderCostSnapshot.swift | 4 + .../SyntheticProviderDescriptor.swift | 10 +- .../Synthetic/SyntheticUsageStats.swift | 241 ++++++++++++++++-- .../SyntheticProviderTests.swift | 113 ++++++++ 5 files changed, 386 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 4d9c05127..e63301d46 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1105,7 +1105,7 @@ extension UsageMenuCardView.Model { percentStyle: PercentStyle, zaiTimeDetail: String?) -> Metric { - let paceDetail = Self.weeklyPaceDetail( + var paceDetail = Self.weeklyPaceDetail( window: weekly, now: input.now, pace: input.weeklyPace, @@ -1141,6 +1141,16 @@ extension UsageMenuCardView.Model { { weeklyResetText = detail } + if input.provider == .synthetic, + let regen = Self.syntheticRegenDetail( + weekly: weekly, + cost: input.snapshot?.providerCost, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + weeklyResetText = regen.resetText + paceDetail = regen.pace + } return Metric( id: "secondary", title: input.metadata.weeklyLabel, @@ -1314,6 +1324,38 @@ extension UsageMenuCardView.Model { paceOnTop: paceOnTop) } + private static func syntheticRegenDetail( + weekly: RateWindow, + cost: ProviderCostSnapshot?, + now: Date, + showUsed: Bool) -> (resetText: String, pace: PaceDetail)? + { + guard let cost, + cost.limit > 0, + let nextRegenAmount = cost.nextRegenAmount, + nextRegenAmount > 0, + let resetsAt = weekly.resetsAt + else { return nil } + + let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) + let resetText = "Regenerates \(countdown)" + + let nextRegenPercent = (nextRegenAmount / cost.limit) * 100 + let afterNextRegenRemaining = min(100, weekly.remainingPercent + nextRegenPercent) + let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining + let suffix = showUsed ? "used after next regen" : "after next regen" + let ticksToFull = max(0, cost.used) / nextRegenAmount + let left = String(format: "%.0f%% %@", afterNextRegen, suffix) + let right = if ticksToFull <= 0.1 { + "Near full" + } else if ticksToFull < 1.5 { + "Full in ~1 regen" + } else { + String(format: "Full in ~%.0f regens", ceil(ticksToFull)) + } + return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) + } + private static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, @@ -1378,6 +1420,7 @@ extension UsageMenuCardView.Model { { guard let cost else { return nil } guard cost.limit > 0 else { return nil } + guard provider != .synthetic else { return nil } let used: String let limit: String diff --git a/Sources/CodexBarCore/ProviderCostSnapshot.swift b/Sources/CodexBarCore/ProviderCostSnapshot.swift index c3d25af49..7941fb540 100644 --- a/Sources/CodexBarCore/ProviderCostSnapshot.swift +++ b/Sources/CodexBarCore/ProviderCostSnapshot.swift @@ -9,6 +9,8 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { public let period: String? /// Optional renewal/reset timestamp for the period. public let resetsAt: Date? + /// Optional amount restored on the next regeneration tick for providers with rolling credit recovery. + public let nextRegenAmount: Double? public let updatedAt: Date public init( @@ -17,6 +19,7 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { currencyCode: String, period: String? = nil, resetsAt: Date? = nil, + nextRegenAmount: Double? = nil, updatedAt: Date) { self.used = used @@ -24,6 +27,7 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { self.currencyCode = currencyCode self.period = period self.resetsAt = resetsAt + self.nextRegenAmount = nextRegenAmount self.updatedAt = updatedAt } } diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift index 550ab9190..b0501408d 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift @@ -10,12 +10,12 @@ public enum SyntheticProviderDescriptor { metadata: ProviderMetadata( id: .synthetic, displayName: "Synthetic", - sessionLabel: "Quota", - weeklyLabel: "Usage", - opusLabel: nil, - supportsOpus: false, + sessionLabel: "Five-hour quota", + weeklyLabel: "Weekly tokens", + opusLabel: "Search hourly", + supportsOpus: true, supportsCredits: false, - creditsHint: "", + creditsHint: "Weekly token quota regenerates continuously.", toggleTitle: "Show Synthetic usage", cliName: "synthetic", defaultEnabled: false, diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index 198c42c25..ea444f5b9 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -9,29 +9,42 @@ public struct SyntheticQuotaEntry: Sendable { public let windowMinutes: Int? public let resetsAt: Date? public let resetDescription: String? + public let cost: ProviderCostSnapshot? public init( label: String?, usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, - resetDescription: String?) + resetDescription: String?, + cost: ProviderCostSnapshot? = nil) { self.label = label self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.cost = cost } } public struct SyntheticUsageSnapshot: Sendable { public let quotas: [SyntheticQuotaEntry] + /// Slot-identified lanes for the known Synthetic response shape: [rolling-5h, weekly, search-hourly]. + /// When set, `toUsageSnapshot` maps slot 0 → primary, slot 1 → secondary, slot 2 → tertiary, + /// so a missing lane stays nil instead of promoting the next lane into the wrong UI label. + public let slottedQuotas: [SyntheticQuotaEntry?]? public let planName: String? public let updatedAt: Date - public init(quotas: [SyntheticQuotaEntry], planName: String?, updatedAt: Date) { + public init( + quotas: [SyntheticQuotaEntry], + slottedQuotas: [SyntheticQuotaEntry?]? = nil, + planName: String?, + updatedAt: Date) + { self.quotas = quotas + self.slottedQuotas = slottedQuotas self.planName = planName self.updatedAt = updatedAt } @@ -39,11 +52,13 @@ public struct SyntheticUsageSnapshot: Sendable { extension SyntheticUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let primaryEntry = self.quotas.first - let secondaryEntry = self.quotas.dropFirst().first + let slots = self.slottedQuotas + ?? [self.quotas.first, self.quotas.dropFirst().first, self.quotas.dropFirst(2).first] + let entries: [SyntheticQuotaEntry?] = (0..<3).map { slots.indices.contains($0) ? slots[$0] : nil } - let primary = primaryEntry.map(Self.rateWindow(for:)) - let secondary = secondaryEntry.map(Self.rateWindow(for:)) + let primary = entries[0].map(Self.rateWindow(for:)) + let secondary = entries[1].map(Self.rateWindow(for:)) + let tertiary = entries[2].map(Self.rateWindow(for:)) let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -56,8 +71,8 @@ extension SyntheticUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, - providerCost: nil, + tertiary: tertiary, + providerCost: self.quotas.first(where: { $0.cost != nil })?.cost, updatedAt: self.updatedAt, identity: identity) } @@ -147,20 +162,46 @@ enum SyntheticUsageParser { }() let planName = self.planName(from: root) - let quotaObjects = self.quotaObjects(from: root) - let quotas = quotaObjects.compactMap { self.parseQuota($0) } + if let slots = self.prioritizedQuotaSlots(from: root) { + let slotted: [SyntheticQuotaEntry?] = slots.map { $0.flatMap(self.parseQuota) } + let flat = slotted.compactMap(\.self) + guard !flat.isEmpty else { + throw SyntheticUsageError.parseFailed("Missing quota data.") + } + return SyntheticUsageSnapshot( + quotas: flat, + slottedQuotas: slotted, + planName: planName, + updatedAt: now) + } + + let quotas = self.fallbackQuotaObjects(from: root).compactMap(self.parseQuota) guard !quotas.isEmpty else { throw SyntheticUsageError.parseFailed("Missing quota data.") } - return SyntheticUsageSnapshot( quotas: quotas, planName: planName, updatedAt: now) } - private static func quotaObjects(from root: [String: Any]) -> [[String: Any]] { + /// Returns slot-positional quota payloads `[rolling-5h, weekly, search-hourly]` when the known Synthetic + /// response shape is detected. Missing lanes stay nil in their slot so downstream code doesn't shift + /// labels. Returns nil if none of the known keys appear, so the fallback path runs. + private static func prioritizedQuotaSlots(from root: [String: Any]) -> [[String: Any]?]? { + let dataDict = root["data"] as? [String: Any] + let rolling = self.namedQuota(root["rollingFiveHourLimit"], label: "Rolling five-hour limit") + ?? self.namedQuota(dataDict?["rollingFiveHourLimit"], label: "Rolling five-hour limit") + let weekly = self.namedQuota(root["weeklyTokenLimit"], label: "Weekly token limit") + ?? self.namedQuota(dataDict?["weeklyTokenLimit"], label: "Weekly token limit") + let searchHourly = self.namedQuota((root["search"] as? [String: Any])?["hourly"], label: "Search hourly") + ?? self.namedQuota((dataDict?["search"] as? [String: Any])?["hourly"], label: "Search hourly") + let slots: [[String: Any]?] = [rolling, weekly, searchHourly] + return slots.contains(where: { $0 != nil }) ? slots : nil + } + + private static func fallbackQuotaObjects(from root: [String: Any]) -> [[String: Any]] { let dataDict = root["data"] as? [String: Any] let candidates: [Any?] = [ root["quotas"], @@ -179,14 +220,8 @@ enum SyntheticUsageParser { ] for candidate in candidates { - if let array = candidate as? [[String: Any]] { return array } - if let array = candidate as? [Any] { - let dicts = array.compactMap { $0 as? [String: Any] } - if !dicts.isEmpty { return dicts } - } - if let dict = candidate as? [String: Any], self.isQuotaPayload(dict) { - return [dict] - } + let quotas = self.extractQuotaObjects(from: candidate) + if !quotas.isEmpty { return quotas } } return [] } @@ -239,14 +274,19 @@ enum SyntheticUsageParser { let windowMinutes = windowMinutes(from: payload) let resetsAt = self.firstDate(in: payload, keys: self.resetKeys) + // Leave resetDescription nil when resetsAt is set so the UI rebuilds the countdown each render + // against the current clock instead of freezing a stale "in Xm" string at parse time. let resetDescription = resetsAt == nil ? self.windowDescription(minutes: windowMinutes) : nil + let cost = self.providerCost(from: payload, usedPercent: clamped, resetsAt: resetsAt) + return SyntheticQuotaEntry( label: label, usedPercent: clamped, windowMinutes: windowMinutes, resetsAt: resetsAt, - resetDescription: resetDescription) + resetDescription: resetDescription, + cost: cost) } private static func isQuotaPayload(_ payload: [String: Any]) -> Bool { @@ -271,6 +311,70 @@ enum SyntheticUsageParser { if let seconds = self.firstDouble(in: payload, keys: windowSecondsKeys) { return Int((seconds / 60).rounded()) } + if let text = self.firstString(in: payload, keys: windowStringKeys) { + return self.windowMinutes(from: text) + } + return nil + } + + private static func namedQuota(_ candidate: Any?, label: String) -> [String: Any]? { + guard var payload = candidate as? [String: Any], self.isQuotaPayload(payload) else { return nil } + if payload["label"] == nil, payload["name"] == nil { + payload["label"] = label + } + return payload + } + + private static func extractQuotaObjects(from candidate: Any?) -> [[String: Any]] { + switch candidate { + case let array as [[String: Any]]: + var nestedQuotas: [[String: Any]] = [] + for entry in array { + if self.isQuotaPayload(entry) { + nestedQuotas.append(entry) + } else { + nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: entry)) + } + } + return nestedQuotas + case let array as [Any]: + return array.flatMap { self.extractQuotaObjects(from: $0) } + case let dict as [String: Any]: + if self.isQuotaPayload(dict) { + return [dict] + } + var nestedQuotas: [[String: Any]] = [] + for key in dict.keys.sorted() { + nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: dict[key])) + } + return nestedQuotas + default: + return [] + } + } + + private static func windowMinutes(from text: String) -> Int? { + let normalized = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "") + guard !normalized.isEmpty else { return nil } + + let unitMappings: [(suffixes: [String], multiplier: Double)] = [ + (["minutes", "minute", "mins", "min", "m"], 1), + (["hours", "hour", "hrs", "hr", "h"], 60), + (["days", "day", "d"], 24 * 60), + ] + + for mapping in unitMappings { + for suffix in mapping.suffixes { + guard normalized.hasSuffix(suffix) else { continue } + let valueText = String(normalized.dropLast(suffix.count)) + guard let value = Double(valueText), value > 0 else { return nil } + return Int((value * mapping.multiplier).rounded()) + } + } + return nil } @@ -288,6 +392,57 @@ enum SyntheticUsageParser { return "\(minutes) minute\(minutes == 1 ? "" : "s") window" } + private static func providerCost( + from payload: [String: Any], + usedPercent: Double, + resetsAt: Date?) -> ProviderCostSnapshot? + { + guard let limit = self.firstCurrency(in: payload, keys: self.costLimitKeys) else { return nil } + + let remaining = self.firstCurrency(in: payload, keys: self.costRemainingKeys) + let usedFromPayload = self.firstCurrency(in: payload, keys: self.costUsedKeys) + let nextRegenAmount = self.firstCurrency(in: payload, keys: self.regenAmountKeys) + let used = if let usedFromPayload { + usedFromPayload + } else if let remaining { + max(0, limit - remaining) + } else { + (usedPercent.clamped(to: 0...100) / 100) * limit + } + + return ProviderCostSnapshot( + used: used, + limit: limit, + currencyCode: "USD", + period: "Weekly", + resetsAt: resetsAt, + nextRegenAmount: nextRegenAmount, + updatedAt: Date()) + } + + private static func firstCurrency(in payload: [String: Any], keys: [String]) -> Double? { + for key in keys { + guard let value = payload[key] else { continue } + if let text = value as? String, + let parsed = self.parseCurrency(text) + { + return parsed + } + if let number = self.doubleValue(value) { + return number + } + } + return nil + } + + private static func parseCurrency(_ text: String) -> Double? { + let cleaned = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "$", with: "") + .replacingOccurrences(of: ",", with: "") + return Double(cleaned) + } + private static func normalizedPercent(_ value: Double?) -> Double? { guard let value else { return nil } if value <= 1 { return value * 100 } @@ -423,6 +578,13 @@ enum SyntheticUsageParser { private static let limitKeys = [ "limit", + "messageLimit", + "message_limit", + "messages", + "maxRequests", + "max_requests", + "requestLimit", + "request_limit", "quota", "max", "total", @@ -433,6 +595,10 @@ enum SyntheticUsageParser { private static let usedKeys = [ "used", "usage", + "usedMessages", + "used_messages", + "messagesUsed", + "messages_used", "requests", "requestCount", "request_count", @@ -456,6 +622,10 @@ enum SyntheticUsageParser { "renew_at", "renewsAt", "renews_at", + "nextTickAt", + "next_tick_at", + "nextRegenAt", + "next_regen_at", "periodEnd", "period_end", "expiresAt", @@ -464,6 +634,26 @@ enum SyntheticUsageParser { "end_at", ] + private static let regenAmountKeys = [ + "nextRegenCredits", + "next_regen_credits", + ] + + private static let costLimitKeys = [ + "maxCredits", + "max_credits", + ] + + private static let costRemainingKeys = [ + "remainingCredits", + "remaining_credits", + ] + + private static let costUsedKeys = [ + "usedCredits", + "used_credits", + ] + private static let windowMinutesKeys = [ "windowMinutes", "window_minutes", @@ -491,6 +681,15 @@ enum SyntheticUsageParser { "periodSeconds", "period_seconds", ] + + private static let windowStringKeys = [ + "window", + "windowLabel", + "window_label", + "period", + "periodLabel", + "period_label", + ] } public enum SyntheticUsageError: LocalizedError, Sendable { diff --git a/Tests/CodexBarTests/SyntheticProviderTests.swift b/Tests/CodexBarTests/SyntheticProviderTests.swift index 32b63e481..08a0ad4b4 100644 --- a/Tests/CodexBarTests/SyntheticProviderTests.swift +++ b/Tests/CodexBarTests/SyntheticProviderTests.swift @@ -61,4 +61,117 @@ struct SyntheticUsageSnapshotTests { #expect(usage.primary?.resetsAt == expectedReset) #expect(usage.loginMethod(for: .synthetic) == nil) } + + @Test + func `parses nested subscription pack quota`() throws { + let json = """ + { + "subscription": { + "packs": 2, + "rateLimit": { + "messages": 1000, + "requests": 250, + "period": "5hr", + "resetsAt": "2026-04-16T18:00:00Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expectedReset = try #require(formatter.date(from: "2026-04-16T18:00:00Z")) + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == expectedReset) + } + + @Test + func `parses live root level rolling and weekly quotas`() throws { + let json = """ + { + "subscription": { + "limit": 750, + "requests": 0, + "renewsAt": "2026-04-17T08:35:49.493Z" + }, + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.05884722222223, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 750, + "max": 750, + "limited": false + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expectedPrimaryReset = try #require(formatter.date(from: "2026-04-17T03:44:11Z")) + let expectedSecondaryReset = try #require(formatter.date(from: "2026-04-17T05:19:30Z")) + let expectedTertiaryReset = try #require(fractionalFormatter.date(from: "2026-04-17T04:30:01.494Z")) + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetsAt == expectedPrimaryReset) + #expect(usage.primary?.resetDescription == nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 1.9411527777777715) < 0.001) + #expect(usage.secondary?.resetsAt == expectedSecondaryReset) + #expect(usage.secondary?.resetDescription == nil) + #expect(usage.tertiary?.usedPercent == 0.8) + #expect(usage.tertiary?.resetsAt == expectedTertiaryReset) + #expect(usage.providerCost?.limit == 36) + #expect(abs((usage.providerCost?.used ?? 0) - 0.7) < 0.0001) + #expect(usage.providerCost?.nextRegenAmount == 0.72) + } + + @Test + func `preserves slot identity when rolling lane is missing`() throws { + let json = """ + { + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.0, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 2.0) < 0.001) + #expect(usage.tertiary?.usedPercent == 0.8) + #expect(usage.providerCost?.limit == 36) + } } From f67bfdaee823592d2e1025640c7d52480f0bb76a Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 17 Apr 2026 14:21:19 -0400 Subject: [PATCH 16/64] Show Synthetic rolling regen detail --- Sources/CodexBar/MenuCardView.swift | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index e63301d46..3672e7dc0 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1085,6 +1085,18 @@ extension UsageMenuCardView.Model { } } } + if input.provider == .synthetic, + let regen = Self.syntheticRollingRegenDetail( + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + primaryResetText = regen.resetText + primaryDetailLeft = regen.pace.leftLabel + primaryDetailRight = regen.pace.rightLabel + primaryPacePercent = regen.pace.pacePercent + primaryPaceOnTop = regen.pace.paceOnTop + } return Metric( id: "primary", title: input.metadata.sessionLabel, @@ -1356,6 +1368,35 @@ extension UsageMenuCardView.Model { return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) } + private static func syntheticRollingRegenDetail( + window: RateWindow, + now: Date, + showUsed: Bool) -> (resetText: String, pace: PaceDetail)? + { + guard let resetsAt = window.resetsAt else { return nil } + + let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) + let resetText = "Regenerates \(countdown)" + + let nextRegenPercent = 5.0 + let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent) + let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining + let suffix = showUsed ? "used after next regen" : "after next regen" + let left = String(format: "%.0f%% %@", afterNextRegen, suffix) + + let missingPercent = max(0, window.usedPercent) + let ticksToFull = missingPercent / nextRegenPercent + let right = if ticksToFull <= 0.1 { + "Near full" + } else if ticksToFull < 1.5 { + "Full in ~1 regen" + } else { + String(format: "Full in ~%.0f regens", ceil(ticksToFull)) + } + + return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) + } + private static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, From 45f040fb590260cbbc65eb1d90d8feac31c5f73c Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 17 Apr 2026 21:51:20 -0400 Subject: [PATCH 17/64] Parse Synthetic tickPercent for rolling regen detail Drop the hardcoded 5% rolling-tick assumption: thread the API's tickPercent through SyntheticQuotaEntry into RateWindow.nextRegenPercent and require it in syntheticRollingRegenDetail. Also flatten the string window-suffix matcher into a single longest-first list so future units can't shadow each other. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/CodexBar/MenuCardView.swift | 6 +- .../Synthetic/SyntheticUsageStats.swift | 53 ++++++--- Sources/CodexBarCore/UsageFetcher.swift | 11 +- .../SyntheticMenuCardTests.swift | 107 ++++++++++++++++++ .../SyntheticProviderTests.swift | 50 ++++++++ 5 files changed, 207 insertions(+), 20 deletions(-) create mode 100644 Tests/CodexBarTests/SyntheticMenuCardTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3672e7dc0..6356c51a6 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1373,12 +1373,14 @@ extension UsageMenuCardView.Model { now: Date, showUsed: Bool) -> (resetText: String, pace: PaceDetail)? { - guard let resetsAt = window.resetsAt else { return nil } + guard let resetsAt = window.resetsAt, + let nextRegenPercent = window.nextRegenPercent, + nextRegenPercent > 0 + else { return nil } let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) let resetText = "Regenerates \(countdown)" - let nextRegenPercent = 5.0 let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent) let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining let suffix = showUsed ? "used after next regen" : "after next regen" diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index ea444f5b9..f3e2edff8 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -9,6 +9,7 @@ public struct SyntheticQuotaEntry: Sendable { public let windowMinutes: Int? public let resetsAt: Date? public let resetDescription: String? + public let nextRegenPercent: Double? public let cost: ProviderCostSnapshot? public init( @@ -17,6 +18,7 @@ public struct SyntheticQuotaEntry: Sendable { windowMinutes: Int?, resetsAt: Date?, resetDescription: String?, + nextRegenPercent: Double? = nil, cost: ProviderCostSnapshot? = nil) { self.label = label @@ -24,6 +26,7 @@ public struct SyntheticQuotaEntry: Sendable { self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.nextRegenPercent = nextRegenPercent self.cost = cost } } @@ -82,7 +85,8 @@ extension SyntheticUsageSnapshot { usedPercent: quota.usedPercent, windowMinutes: quota.windowMinutes, resetsAt: quota.resetsAt, - resetDescription: quota.resetDescription) + resetDescription: quota.resetDescription, + nextRegenPercent: quota.nextRegenPercent) } } @@ -279,6 +283,8 @@ enum SyntheticUsageParser { let resetDescription = resetsAt == nil ? self.windowDescription(minutes: windowMinutes) : nil let cost = self.providerCost(from: payload, usedPercent: clamped, resetsAt: resetsAt) + let nextRegenPercent = self.normalizedPercent( + self.firstDouble(in: payload, keys: Self.tickPercentKeys)) return SyntheticQuotaEntry( label: label, @@ -286,6 +292,7 @@ enum SyntheticUsageParser { windowMinutes: windowMinutes, resetsAt: resetsAt, resetDescription: resetDescription, + nextRegenPercent: nextRegenPercent, cost: cost) } @@ -312,7 +319,7 @@ enum SyntheticUsageParser { return Int((seconds / 60).rounded()) } if let text = self.firstString(in: payload, keys: windowStringKeys) { - return self.windowMinutes(from: text) + return self.windowMinutes(fromText: text) } return nil } @@ -353,31 +360,36 @@ enum SyntheticUsageParser { } } - private static func windowMinutes(from text: String) -> Int? { + /// Parses durations like `"5hr"`, `"30min"`, `"2 days"`. Suffixes are sorted longest-first so + /// multi-letter units always win over their single-letter aliases — no ordering surprises if a + /// future unit shares a trailing letter with another. + static func windowMinutes(fromText text: String) -> Int? { let normalized = text .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .replacingOccurrences(of: " ", with: "") guard !normalized.isEmpty else { return nil } - let unitMappings: [(suffixes: [String], multiplier: Double)] = [ - (["minutes", "minute", "mins", "min", "m"], 1), - (["hours", "hour", "hrs", "hr", "h"], 60), - (["days", "day", "d"], 24 * 60), - ] - - for mapping in unitMappings { - for suffix in mapping.suffixes { - guard normalized.hasSuffix(suffix) else { continue } - let valueText = String(normalized.dropLast(suffix.count)) - guard let value = Double(valueText), value > 0 else { return nil } - return Int((value * mapping.multiplier).rounded()) - } + for (suffix, multiplier) in Self.windowSuffixMultipliers { + guard normalized.hasSuffix(suffix) else { continue } + let valueText = String(normalized.dropLast(suffix.count)) + guard let value = Double(valueText), value > 0 else { return nil } + return Int((value * multiplier).rounded()) } - return nil } + private static let windowSuffixMultipliers: [(suffix: String, multiplier: Double)] = { + let raw: [(String, Double)] = [ + ("minutes", 1), ("minute", 1), ("mins", 1), ("min", 1), ("m", 1), + ("hours", 60), ("hour", 60), ("hrs", 60), ("hr", 60), ("h", 60), + ("days", 24 * 60), ("day", 24 * 60), ("d", 24 * 60), + ] + return raw + .sorted { $0.0.count > $1.0.count } + .map { (suffix: $0.0, multiplier: $0.1) } + }() + private static func windowDescription(minutes: Int?) -> String? { guard let minutes, minutes > 0 else { return nil } let dayMinutes = 24 * 60 @@ -639,6 +651,13 @@ enum SyntheticUsageParser { "next_regen_credits", ] + private static let tickPercentKeys = [ + "tickPercent", + "tick_percent", + "nextTickPercent", + "next_tick_percent", + ] + private static let costLimitKeys = [ "maxCredits", "max_credits", diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 90360ca60..24d413717 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -6,12 +6,21 @@ public struct RateWindow: Codable, Equatable, Sendable { public let resetsAt: Date? /// Optional textual reset description (used by Claude CLI UI scrape). public let resetDescription: String? + /// Optional percent restored on the next regeneration tick for providers with rolling recovery. + public let nextRegenPercent: Double? - public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?) { + public init( + usedPercent: Double, + windowMinutes: Int?, + resetsAt: Date?, + resetDescription: String?, + nextRegenPercent: Double? = nil) + { self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.nextRegenPercent = nextRegenPercent } public var remainingPercent: Double { diff --git a/Tests/CodexBarTests/SyntheticMenuCardTests.swift b/Tests/CodexBarTests/SyntheticMenuCardTests.swift new file mode 100644 index 000000000..df7d00951 --- /dev/null +++ b/Tests/CodexBarTests/SyntheticMenuCardTests.swift @@ -0,0 +1,107 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct SyntheticMenuCardTests { + private static func makeModel( + primary: RateWindow?, + secondary: RateWindow? = nil, + providerCost: ProviderCostSnapshot? = nil, + now: Date) throws -> UsageMenuCardView.Model + { + let identity = ProviderIdentitySnapshot( + providerID: .synthetic, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: providerCost, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.synthetic]) + return UsageMenuCardView.Model.make(.init( + provider: .synthetic, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + @Test + func `rolling regen text uses parsed tickPercent not hardcoded fallback`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let primary = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil, + nextRegenPercent: 2) + let model = try Self.makeModel(primary: primary, now: now) + let metric = try #require(model.metrics.first) + // 50% used / 2% per tick = 25 ticks to full. + #expect(metric.detailRightText == "Full in ~25 regens") + #expect(metric.detailLeftText == "52% after next regen") + } + + @Test + func `rolling regen omits Synthetic-specific text when tickPercent is missing`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let primary = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil, + nextRegenPercent: nil) + let model = try Self.makeModel(primary: primary, now: now) + let metric = try #require(model.metrics.first) + // Without nextRegenPercent we no longer assert a regen-specific label; + // the renderer must not fabricate ticks-to-full from a guessed rate. + #expect(metric.detailRightText?.contains("regen") != true) + } + + @Test + func `weekly regen text near full reports both labels consistently`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let secondary = RateWindow( + usedPercent: 1, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let cost = ProviderCostSnapshot( + used: 0.36, + limit: 36, + currencyCode: "USD", + period: "Weekly", + resetsAt: now.addingTimeInterval(3600), + nextRegenAmount: 0.72, + updatedAt: now) + let model = try Self.makeModel( + primary: nil, + secondary: secondary, + providerCost: cost, + now: now) + let weekly = try #require(model.metrics.first(where: { $0.id == "secondary" })) + // used=$0.36 / nextRegen=$0.72 = 0.5 ticks → between 0.1 and 1.5 → "Full in ~1 regen". + #expect(weekly.detailRightText == "Full in ~1 regen") + // remaining 99% + 2% next regen caps at 100% → "100% after next regen". + #expect(weekly.detailLeftText == "100% after next regen") + } +} diff --git a/Tests/CodexBarTests/SyntheticProviderTests.swift b/Tests/CodexBarTests/SyntheticProviderTests.swift index 08a0ad4b4..abe9765b0 100644 --- a/Tests/CodexBarTests/SyntheticProviderTests.swift +++ b/Tests/CodexBarTests/SyntheticProviderTests.swift @@ -145,6 +145,56 @@ struct SyntheticUsageSnapshotTests { #expect(usage.providerCost?.nextRegenAmount == 0.72) } + @Test + func `parses rolling lane tickPercent into primary nextRegenPercent`() throws { + let json = """ + { + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 750, + "max": 750, + "limited": false + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.nextRegenPercent == 5.0) + } + + @Test + func `omits nextRegenPercent when rolling lane lacks tickPercent`() throws { + let json = """ + { + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "remaining": 750, + "max": 750 + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.nextRegenPercent == nil) + } + + @Test + func `parses time string suffixes covering minutes hours and days`() { + #expect(SyntheticUsageParser.windowMinutes(fromText: "5min") == 5) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5m") == 5) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5hr") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5h") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5hours") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "2days") == 2880) + #expect(SyntheticUsageParser.windowMinutes(fromText: "2d") == 2880) + #expect(SyntheticUsageParser.windowMinutes(fromText: "1 hour") == 60) + #expect(SyntheticUsageParser.windowMinutes(fromText: "junk") == nil) + #expect(SyntheticUsageParser.windowMinutes(fromText: "") == nil) + } + @Test func `preserves slot identity when rolling lane is missing`() throws { let json = """ From 5a1c06ec9d24fe8618960e301a56b815d52101a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:46:27 +0100 Subject: [PATCH 18/64] docs: credit Synthetic quota response fix (#732) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98007322c..595e876c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.22 — Unreleased ### Providers & Usage +- Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish! - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! From 59d22aba846e85014e460ba7b0a14625b04699a8 Mon Sep 17 00:00:00 2001 From: icey-zhang Date: Thu, 16 Apr 2026 11:14:57 +0800 Subject: [PATCH 19/64] Fix Antigravity probe failures caused by URLSession delegate signature mismatch and port/token handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URLSessionDelegate TLS challenge methods used `@Sendable` completionHandler but Swift 6.2 SDK requires `@MainActor @Sendable`, causing the delegate to never be called. This made all HTTPS connections to Antigravity's self-signed localhost server fail silently, falling back to the HTTP extension port which uses a different CSRF token and doesn't serve the quota API — resulting in misleading "session expired" (403) or "HTTP 404" errors. Fixes: - Switch delegate methods to async variants to match the current SDK protocol signature - Extract `--extension_server_csrf_token` from process args and use it for HTTP extension port fallback - Rank port probe results (success > httpError > unreachable) so a 403 on the extension port doesn't shadow a working API port Co-Authored-By: Claude Opus 4.6 --- .../Antigravity/AntigravityStatusProbe.swift | 53 ++++++++++++++++--- .../AntigravityStatusProbeTests.swift | 30 +++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 5b3485972..22f2600a0 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -665,9 +665,41 @@ public struct AntigravityStatusProbe: Sendable { let ok = await testConnectivity(endpoint, timeout) if ok { return endpoint } } + if let fallback = fallbackProbeEndpoint(candidateEndpoints) { + self.log.debug("Port probe fell back to best-effort endpoint", metadata: [ + "source": fallback.source.rawValue, + "scheme": fallback.scheme, + "port": "\(fallback.port)", + ]) + return fallback + } throw AntigravityStatusProbeError.portDetectionFailed("no working API port found") } + static func fallbackProbePort(ports: [Int], extensionPort: Int?) -> Int? { + if let nonExtension = ports.first(where: { $0 != extensionPort }) { + return nonExtension + } + if let extensionPort { + return extensionPort + } + return ports.first + } + + static func isReachableProbeError(_ error: Error) -> Bool { + guard case let AntigravityStatusProbeError.apiError(message) = error else { return false } + return message.hasPrefix("HTTP ") + } + + private static func fallbackProbeEndpoint( + _ endpoints: [AntigravityConnectionEndpoint]) -> AntigravityConnectionEndpoint? + { + if let languageServerEndpoint = endpoints.first(where: { $0.source == .languageServer }) { + return languageServerEndpoint + } + return endpoints.first + } + private static func testEndpointConnectivity( _ endpoint: AntigravityConnectionEndpoint, timeout: TimeInterval) async -> Bool @@ -680,6 +712,15 @@ public struct AntigravityStatusProbe: Sendable { context: RequestContext(endpoints: [endpoint], timeout: timeout)) return true } catch { + if self.isReachableProbeError(error) { + self.log.debug("Port probe received HTTP response; treating endpoint as reachable", metadata: [ + "source": endpoint.source.rawValue, + "scheme": endpoint.scheme, + "port": "\(endpoint.port)", + "error": error.localizedDescription, + ]) + return true + } self.log.debug("Port probe failed", metadata: [ "source": endpoint.source.rawValue, "scheme": endpoint.scheme, @@ -896,11 +937,9 @@ private final class LocalhostSessionDelegate: NSObject { extension LocalhostSessionDelegate: URLSessionDelegate { func urlSession( _ session: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let result = self.challengeResult(challenge) - completionHandler(result.disposition, result.credential) + self.challengeResult(challenge) } } @@ -908,11 +947,9 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { func urlSession( _ session: URLSession, task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let result = self.challengeResult(challenge) - completionHandler(result.disposition, result.credential) + self.challengeResult(challenge) } } diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index f1d0e75b5..5e91fc46e 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -745,4 +745,34 @@ struct AntigravityStatusProbeTests { #expect(usage.accountEmail(for: .antigravity) == "test@example.com") #expect(usage.loginMethod(for: .antigravity) == "Pro") } + + @Test + func `http probe errors still count as reachable`() { + #expect( + AntigravityStatusProbe.isReachableProbeError( + AntigravityStatusProbeError.apiError("HTTP 403: Forbidden"))) + #expect( + AntigravityStatusProbe.isReachableProbeError( + AntigravityStatusProbeError.apiError("HTTP 404: Not Found"))) + #expect( + !AntigravityStatusProbe.isReachableProbeError( + AntigravityStatusProbeError.apiError("Invalid response"))) + #expect(!AntigravityStatusProbe.isReachableProbeError(AntigravityStatusProbeError.notRunning)) + } + + @Test + func `fallback probe port prefers non extension candidate`() { + #expect( + AntigravityStatusProbe.fallbackProbePort( + ports: [51170, 61775], + extensionPort: 61775) == 51170) + #expect( + AntigravityStatusProbe.fallbackProbePort( + ports: [61775], + extensionPort: 61775) == 61775) + #expect( + AntigravityStatusProbe.fallbackProbePort( + ports: [51170, 61775], + extensionPort: nil) == 51170) + } } From 2be8ae213134f1710eac76a224f0ca68d46d09d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:54:26 +0100 Subject: [PATCH 20/64] docs: credit Antigravity probe fix (#727) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 595e876c6..8161d73bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Providers & Usage - Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish! +- Antigravity: restore localhost probing with async TLS challenge handling, extension-token fallback, and best-effort port selection (#727). Thanks @icey-zhang! - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! From 1f3d5bd7ad3325a54282a610c8eaeb8923df5874 Mon Sep 17 00:00:00 2001 From: Chad Neal Date: Thu, 19 Mar 2026 23:29:20 -0600 Subject: [PATCH 21/64] Fix preferences sidebar clipping and window sizing on macOS Tahoe The Providers tab sidebar was clipping provider names, icons, and reorder handles on the left edge due to .listStyle(.sidebar) imposing internal leading insets that push content beyond the 240px frame on macOS Tahoe. - Replace List with ScrollView+VStack for full control over sidebar layout - Add direct NSWindow.setFrame resizing since SwiftUI's .windowResizability(.contentSize) doesn't propagate frame changes - Add PreferencesTab.title and CaseIterable to eliminate hardcoded tab title strings in the window finder - Use system selectedContentBackgroundColor for sidebar selection highlight - Add ensure_swift_version() to compile_and_run.sh for Xcode toolchain fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- Scripts/compile_and_run.sh | 22 ++++++++++ .../PreferencesProviderSidebarView.swift | 43 +++++++++++-------- Sources/CodexBar/PreferencesView.swift | 31 ++++++++++++- 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index 865f2a797..909b41a66 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -29,6 +29,27 @@ delete_keychain_service_items() { done } +# Ensure Swift >= 5.5 (required for --arch flag in swift build) +ensure_swift_version() { + local swift_ver + swift_ver=$(swift --version 2>&1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1) + local major minor + major=$(echo "$swift_ver" | cut -d. -f1) + minor=$(echo "$swift_ver" | cut -d. -f2) + if [[ "${major:-0}" -ge 6 ]] || { [[ "${major:-0}" -eq 5 ]] && [[ "${minor:-0}" -ge 5 ]]; }; then + return 0 + fi + # Try Xcode toolchain + local xcrun_swift + xcrun_swift=$(xcrun --find swift 2>/dev/null || true) + if [[ -n "$xcrun_swift" && -x "$xcrun_swift" ]]; then + log "WARN: PATH swift is v${swift_ver}; switching to Xcode toolchain at $(dirname "$xcrun_swift")" + export PATH="$(dirname "$xcrun_swift"):$PATH" + return 0 + fi + fail "Swift >= 5.5 required (found ${swift_ver:-none}). Install Xcode or update swiftly." +} + has_signing_identity() { local identity="${1:-}" if [[ -z "${identity}" ]]; then @@ -173,6 +194,7 @@ for arg in "$@"; do esac done +ensure_swift_version resolve_signing_mode if [[ "${CLEAR_ADHOC_KEYCHAIN}" == "1" && "${SIGNING_MODE}" != "adhoc" ]]; then fail "--clear-adhoc-keychain is only supported when using adhoc signing." diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index 32a1b5b47..4e4935921 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -13,26 +13,33 @@ struct ProviderSidebarListView: View { @State private var draggingProvider: UsageProvider? var body: some View { - List(selection: self.$selection) { - ForEach(self.providers, id: \.self) { provider in - ProviderSidebarRowView( - provider: provider, - store: self.store, - isEnabled: self.isEnabled(provider), - subtitle: self.subtitle(provider), - draggingProvider: self.$draggingProvider) - .tag(provider) - .onDrop( - of: [UTType.plainText], - delegate: ProviderSidebarDropDelegate( - item: provider, - providers: self.providers, - dragging: self.$draggingProvider, - moveProviders: self.moveProviders)) + ScrollView { + VStack(spacing: 0) { + ForEach(self.providers, id: \.self) { provider in + ProviderSidebarRowView( + provider: provider, + store: self.store, + isEnabled: self.isEnabled(provider), + subtitle: self.subtitle(provider), + draggingProvider: self.$draggingProvider) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(self.selection == provider ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, 4)) + .contentShape(Rectangle()) + .onTapGesture { self.selection = provider } + .onDrop( + of: [UTType.plainText], + delegate: ProviderSidebarDropDelegate( + item: provider, + providers: self.providers, + dragging: self.$draggingProvider, + moveProviders: self.moveProviders)) + } } + .padding(.vertical, 4) } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) .background( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) .fill(Color(nsColor: .controlBackgroundColor).opacity(0.8))) diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 14a541695..408a83d6d 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -1,7 +1,7 @@ import AppKit import SwiftUI -enum PreferencesTab: String, Hashable { +enum PreferencesTab: String, CaseIterable, Hashable { case general case providers case display @@ -13,6 +13,17 @@ enum PreferencesTab: String, Hashable { static let providersWidth: CGFloat = 720 static let windowHeight: CGFloat = 580 + var title: String { + switch self { + case .general: "General" + case .providers: "Providers" + case .display: "Display" + case .advanced: "Advanced" + case .about: "About" + case .debug: "Debug" + } + } + var preferredWidth: CGFloat { self == .providers ? PreferencesTab.providersWidth : PreferencesTab.defaultWidth } @@ -110,6 +121,24 @@ struct PreferencesView: View { } else { change() } + Self.resizeSettingsWindow(width: tab.preferredWidth, height: tab.preferredHeight, animate: animate) + } + + private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window" + private static let knownTabTitles = Set(PreferencesTab.allCases.map(\.title)) + + private static func resizeSettingsWindow(width: CGFloat, height: CGFloat, animate: Bool) { + guard let window = NSApp.windows.first(where: { + $0.identifier?.rawValue == settingsWindowIdentifier + || knownTabTitles.contains($0.title) + }) else { return } + let toolbarHeight = window.frame.height - window.contentLayoutRect.height + guard toolbarHeight > 0 else { return } + let newSize = NSSize(width: width, height: height + toolbarHeight) + var frame = window.frame + frame.origin.y += frame.size.height - newSize.height + frame.size = newSize + window.setFrame(frame, display: true, animate: animate) } private func ensureValidTabSelection() { From ffe7ed03425cf8cc4cdcda0481b3bdc8b9101b28 Mon Sep 17 00:00:00 2001 From: Chad Neal Date: Fri, 20 Mar 2026 05:18:58 -0600 Subject: [PATCH 22/64] Fix lint: wrap long line to stay within 120-char max width Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/CodexBar/PreferencesProviderSidebarView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index 4e4935921..559c5329e 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -25,7 +25,10 @@ struct ProviderSidebarListView: View { .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(self.selection == provider ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) + .fill( + self.selection == provider + ? Color(nsColor: .selectedContentBackgroundColor) + : Color.clear) .padding(.horizontal, 4)) .contentShape(Rectangle()) .onTapGesture { self.selection = provider } From f10ab99f60d04b09cb3399ead2de794fabb113e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 22:01:16 +0100 Subject: [PATCH 23/64] fix: polish Tahoe settings landing (#580) --- CHANGELOG.md | 3 +++ Scripts/compile_and_run.sh | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8161d73bf..0fbb778a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! +### Menu & Settings +- Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal! + ### Fixes - Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98! diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index 909b41a66..12a39996b 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -31,8 +31,14 @@ delete_keychain_service_items() { # Ensure Swift >= 5.5 (required for --arch flag in swift build) ensure_swift_version() { + local swift_output local swift_ver - swift_ver=$(swift --version 2>&1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1) + swift_output=$(swift --version 2>&1 || true) + if [[ "$swift_output" =~ (Apple[[:space:]]+)?Swift[[:space:]]+version[[:space:]]+([0-9]+)\.([0-9]+)(\.[0-9]+)? ]]; then + swift_ver="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}${BASH_REMATCH[4]}" + else + fail "Swift >= 5.5 required (found ${swift_output:-none}). Install Xcode or update swiftly." + fi local major minor major=$(echo "$swift_ver" | cut -d. -f1) minor=$(echo "$swift_ver" | cut -d. -f2) From 2f7a6752e3097679d5236d032b8daef9c1717313 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 12 Apr 2026 16:09:52 +0300 Subject: [PATCH 24/64] fix(app-group): migrate to team-prefixed shared container --- Scripts/package_app.sh | 7 +- Sources/CodexBar/SettingsStore.swift | 16 +- Sources/CodexBar/StatusItemController.swift | 2 +- Sources/CodexBarCore/AppGroupSupport.swift | 210 ++++++++++++++++++ Sources/CodexBarCore/KeychainAccessGate.swift | 5 +- Sources/CodexBarCore/WidgetSnapshot.swift | 42 +--- .../CodexBarWidgetProvider.swift | 10 +- .../CodexBarTests/AppGroupSupportTests.swift | 88 ++++++++ .../CodexBarWidgetProviderTests.swift | 7 + 9 files changed, 344 insertions(+), 43 deletions(-) create mode 100644 Sources/CodexBarCore/AppGroupSupport.swift create mode 100644 Tests/CodexBarTests/AppGroupSupportTests.swift diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 4a8a6de02..9e62c83dc 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -134,9 +134,10 @@ if [[ "$SIGNING_MODE" == "adhoc" ]]; then AUTO_CHECKS=false fi WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget" -APP_GROUP_ID="group.com.steipete.codexbar" +APP_TEAM_ID="${APP_TEAM_ID:-Y5PE65HELJ}" +APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar" if [[ "$BUNDLE_ID" == *".debug"* ]]; then - APP_GROUP_ID="group.com.steipete.codexbar.debug" + APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar.debug" fi ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" @@ -197,6 +198,7 @@ cat > "$APP/Contents/Info.plist" <SUEnableAutomaticChecks<${AUTO_CHECKS}/> CodexBuildTimestamp${BUILD_TIMESTAMP} CodexGitCommit${GIT_COMMIT} + CodexBarTeamID${APP_TEAM_ID} PLIST @@ -292,6 +294,7 @@ if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then CFBundleShortVersionString${MARKETING_VERSION} CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 + CodexBarTeamID${APP_TEAM_ID} NSExtension NSExtensionPointIdentifiercom.apple.widgetkit-extension diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 68ba707aa..d16439317 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -63,7 +63,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { @MainActor @Observable final class SettingsStore { - static let sharedDefaults = UserDefaults(suiteName: "group.com.steipete.codexbar") + static let sharedDefaults = AppGroupSupport.sharedDefaults() static let mergedOverviewProviderLimit = 3 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment @@ -124,6 +124,20 @@ final class SettingsStore { copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(), tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { + let appGroupID = AppGroupSupport.currentGroupID() + let appGroupMigration = AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults) + let sharedDefaultsAvailable = Self.sharedDefaults != nil + if !Self.isRunningTests { + CodexBarLog.logger(LogCategories.settings).info( + "App group resolved", + metadata: [ + "groupID": appGroupID, + "sharedDefaultsAvailable": sharedDefaultsAvailable ? "1" : "0", + "migrationStatus": appGroupMigration.status.rawValue, + "migratedSnapshot": appGroupMigration.copiedSnapshot ? "1" : "0", + ]) + } + let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil let hadExistingConfig = (try? configStore.load()) != nil let legacyStores = CodexBarConfigMigrator.LegacyStores( diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index b77865f96..2f13726bd 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -238,8 +238,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin // Status items for individual providers are now created lazily in updateVisibility() super.init() self.wireBindings() - self.updateIcons() self.updateVisibility() + self.updateIcons() NotificationCenter.default.addObserver( self, selector: #selector(self.handleDebugReplayNotification(_:)), diff --git a/Sources/CodexBarCore/AppGroupSupport.swift b/Sources/CodexBarCore/AppGroupSupport.swift new file mode 100644 index 000000000..fa2237668 --- /dev/null +++ b/Sources/CodexBarCore/AppGroupSupport.swift @@ -0,0 +1,210 @@ +import Foundation +#if os(macOS) +import Security +#endif + +public enum AppGroupSupport { + public static let defaultTeamID = "Y5PE65HELJ" + public static let teamIDInfoKey = "CodexBarTeamID" + public static let legacyReleaseGroupID = "group.com.steipete.codexbar" + public static let legacyDebugGroupID = "group.com.steipete.codexbar.debug" + public static let widgetSnapshotFilename = "widget-snapshot.json" + public static let migrationVersion = 1 + public static let migrationVersionKey = "appGroupMigrationVersion" + + public struct MigrationResult: Sendable { + public enum Status: String, Sendable { + case alreadyCompleted + case targetUnavailable + case noChangesNeeded + case migrated + } + + public let status: Status + public let copiedSnapshot: Bool + + public init(status: Status, copiedSnapshot: Bool = false) { + self.status = status + self.copiedSnapshot = copiedSnapshot + } + } + + public static func currentGroupID(for bundleID: String? = Bundle.main.bundleIdentifier) -> String { + self.currentGroupID(teamID: self.resolvedTeamID(), bundleID: bundleID) + } + + static func currentGroupID(teamID: String, bundleID: String?) -> String { + let base = "\(teamID).com.steipete.codexbar" + return self.isDebugBundleID(bundleID) ? "\(base).debug" : base + } + + public static func resolvedTeamID(bundle: Bundle = .main) -> String { + self.resolvedTeamID( + infoDictionaryOverride: bundle.infoDictionary, + bundleURLOverride: bundle.bundleURL) + } + + static func resolvedTeamID( + infoDictionaryOverride: [String: Any]?, + bundleURLOverride: URL?) -> String + { + if let teamID = self.codeSignatureTeamID(bundleURL: bundleURLOverride) { + return teamID + } + if let teamID = infoDictionaryOverride?[self.teamIDInfoKey] as? String, + !teamID.isEmpty + { + return teamID + } + return self.defaultTeamID + } + + public static func legacyGroupID(for bundleID: String? = Bundle.main.bundleIdentifier) -> String { + self.isDebugBundleID(bundleID) ? self.legacyDebugGroupID : self.legacyReleaseGroupID + } + + public static func sharedDefaults( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default) + -> UserDefaults? + { + guard self.currentContainerURL(bundleID: bundleID, fileManager: fileManager) != nil else { return nil } + return UserDefaults(suiteName: self.currentGroupID(for: bundleID)) + } + + public static func currentContainerURL( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default) + -> URL? + { + #if os(macOS) + fileManager.containerURL(forSecurityApplicationGroupIdentifier: self.currentGroupID(for: bundleID)) + #else + nil + #endif + } + + public static func snapshotURL( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + if let container = self.currentContainerURL(bundleID: bundleID, fileManager: fileManager) { + return container.appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + } + + let directory = self.localFallbackDirectory(fileManager: fileManager, homeDirectory: homeDirectory) + return directory.appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + } + + public static func localFallbackDirectory( + fileManager: FileManager = .default, + homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let directory = base.appendingPathComponent("CodexBar", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + public static func legacyContainerCandidateURL( + bundleID: String? = Bundle.main.bundleIdentifier, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + homeDirectory + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Group Containers", isDirectory: true) + .appendingPathComponent(self.legacyGroupID(for: bundleID), isDirectory: true) + } + + public static func migrateLegacyDataIfNeeded( + bundleID: String? = Bundle.main.bundleIdentifier, + standardDefaults: UserDefaults = .standard, + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + currentDefaultsOverride: UserDefaults? = nil, + currentSnapshotURLOverride: URL? = nil, + legacySnapshotURLOverride: URL? = nil) + -> MigrationResult + { + if standardDefaults.integer(forKey: self.migrationVersionKey) >= self.migrationVersion { + return MigrationResult(status: .alreadyCompleted) + } + + guard currentDefaultsOverride ?? self.sharedDefaults(bundleID: bundleID, fileManager: fileManager) != nil else { + return MigrationResult(status: .targetUnavailable) + } + + let currentSnapshotURL = currentSnapshotURLOverride + ?? self.currentContainerURL(bundleID: bundleID, fileManager: fileManager)? + .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + let legacySnapshotURL = legacySnapshotURLOverride + ?? self.legacyContainerCandidateURL(bundleID: bundleID, homeDirectory: homeDirectory) + .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + + let copiedSnapshot = { + guard let currentSnapshotURL else { return false } + guard !fileManager.fileExists(atPath: currentSnapshotURL.path), + fileManager.fileExists(atPath: legacySnapshotURL.path) + else { + return false + } + do { + try fileManager.createDirectory( + at: currentSnapshotURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try fileManager.copyItem(at: legacySnapshotURL, to: currentSnapshotURL) + return true + } catch { + return false + } + }() + + let result = if copiedSnapshot { + MigrationResult(status: .migrated, copiedSnapshot: true) + } else { + MigrationResult(status: .noChangesNeeded) + } + + standardDefaults.set(self.migrationVersion, forKey: self.migrationVersionKey) + return result + } + + private static func isDebugBundleID(_ bundleID: String?) -> Bool { + guard let bundleID, !bundleID.isEmpty else { return false } + return bundleID.contains(".debug") + } + + private static func codeSignatureTeamID(bundleURL: URL?) -> String? { + #if os(macOS) + guard let bundleURL else { return nil } + + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let code = staticCode + else { + return nil + } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation( + code, + SecCSFlags(rawValue: kSecCSSigningInformation), + &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any], + let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String, + !teamID.isEmpty + else { + return nil + } + return teamID + #else + _ = bundleURL + return nil + #endif + } +} diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 548a4add4..86451a75d 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -5,7 +5,6 @@ import SweetCookieKit public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" - private static let appGroupID = "group.com.steipete.codexbar" @TaskLocal private static var taskOverrideValue: Bool? private nonisolated(unsafe) static var overrideValue: Bool? @@ -19,9 +18,7 @@ public enum KeychainAccessGate { #endif if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } - if let shared = UserDefaults(suiteName: Self.appGroupID), - shared.bool(forKey: Self.flagKey) - { + if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true } return false diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 0dc371f02..d87c4dcca 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -114,17 +114,16 @@ public struct WidgetSnapshot: Codable, Sendable { } public enum WidgetSnapshotStore { - public static let appGroupID = "group.com.steipete.codexbar" - private static let filename = "widget-snapshot.json" + private static let filename = AppGroupSupport.widgetSnapshotFilename public static func load(bundleID: String? = Bundle.main.bundleIdentifier) -> WidgetSnapshot? { - guard let url = self.snapshotURL(bundleID: bundleID) else { return nil } + let url = self.snapshotURL(bundleID: bundleID) guard let data = try? Data(contentsOf: url) else { return nil } return try? self.decoder.decode(WidgetSnapshot.self, from: data) } public static func save(_ snapshot: WidgetSnapshot, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let url = self.snapshotURL(bundleID: bundleID) else { return } + let url = self.snapshotURL(bundleID: bundleID) do { let data = try self.encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) @@ -133,32 +132,12 @@ public enum WidgetSnapshotStore { } } - private static func snapshotURL(bundleID: String?) -> URL? { - let fm = FileManager.default - let groupID = self.groupID(for: bundleID) - #if os(macOS) - if let groupID, let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupID) { - return container.appendingPathComponent(self.filename, isDirectory: false) - } - #endif - - let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? fm.temporaryDirectory - let dir = base.appendingPathComponent("CodexBar", isDirectory: true) - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent(self.filename, isDirectory: false) + private static func snapshotURL(bundleID: String?) -> URL { + AppGroupSupport.snapshotURL(bundleID: bundleID) } public static func appGroupID(for bundleID: String?) -> String? { - self.groupID(for: bundleID) - } - - private static func groupID(for bundleID: String?) -> String? { - guard let bundleID, !bundleID.isEmpty else { return self.appGroupID } - if bundleID.contains(".debug") { - return "group.com.steipete.codexbar.debug" - } - return self.appGroupID + AppGroupSupport.currentGroupID(for: bundleID) } private static var encoder: JSONEncoder { @@ -178,7 +157,7 @@ public enum WidgetSelectionStore { private static let selectedProviderKey = "widgetSelectedProvider" public static func loadSelectedProvider(bundleID: String? = Bundle.main.bundleIdentifier) -> UsageProvider? { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return nil } + let defaults = self.sharedDefaults(bundleID: bundleID) guard let raw = defaults.string(forKey: self.selectedProviderKey) else { return nil } return UsageProvider(rawValue: raw) } @@ -187,12 +166,11 @@ public enum WidgetSelectionStore { _ provider: UsageProvider, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return } + let defaults = self.sharedDefaults(bundleID: bundleID) defaults.set(provider.rawValue, forKey: self.selectedProviderKey) } - private static func sharedDefaults(bundleID: String?) -> UserDefaults? { - guard let groupID = WidgetSnapshotStore.appGroupID(for: bundleID) else { return nil } - return UserDefaults(suiteName: groupID) + private static func sharedDefaults(bundleID: String?) -> UserDefaults { + AppGroupSupport.sharedDefaults(bundleID: bundleID) ?? .standard } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 73055305f..f5f5187e3 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -184,7 +184,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) @@ -213,7 +213,7 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { } private func makeEntry() -> CodexBarSwitcherEntry { - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let providers = self.availableProviders(from: snapshot) let stored = WidgetSelectionStore.loadSelectedProvider() let selected = providers.first { $0 == stored } ?? providers.first ?? .codex @@ -262,7 +262,7 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), provider: provider, @@ -274,6 +274,10 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } enum WidgetPreviewData { + static func emptySnapshot() -> WidgetSnapshot { + WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) + } + static func snapshot() -> WidgetSnapshot { let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h") let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d") diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift new file mode 100644 index 000000000..249f109c2 --- /dev/null +++ b/Tests/CodexBarTests/AppGroupSupportTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AppGroupSupportTests { + @Test + func `app group identifiers use resolved team-prefixed release and debug variants`() { + #expect( + AppGroupSupport.currentGroupID(teamID: "Y5PE65HELJ", bundleID: "com.steipete.codexbar") + == "Y5PE65HELJ.com.steipete.codexbar") + #expect( + AppGroupSupport.currentGroupID(teamID: "ABCDE12345", bundleID: "com.steipete.codexbar.debug") + == "ABCDE12345.com.steipete.codexbar.debug") + #expect( + AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar") + == "group.com.steipete.codexbar") + #expect( + AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar.debug") + == "group.com.steipete.codexbar.debug") + } + + @Test + func `resolved team id falls back to plist and then default`() { + #expect( + AppGroupSupport.resolvedTeamID( + infoDictionaryOverride: [AppGroupSupport.teamIDInfoKey: "ABCDE12345"], + bundleURLOverride: nil) == "ABCDE12345") + #expect( + AppGroupSupport.resolvedTeamID( + infoDictionaryOverride: nil, + bundleURLOverride: nil) == AppGroupSupport.defaultTeamID) + } + + @Test + func `legacy migration copies snapshot once`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let standardSuite = "AppGroupSupportTests-standard-\(UUID().uuidString)" + let currentSuite = "AppGroupSupportTests-current-\(UUID().uuidString)" + let legacySuite = "AppGroupSupportTests-legacy-\(UUID().uuidString)" + + let standardDefaults = try #require(UserDefaults(suiteName: standardSuite)) + let currentDefaults = try #require(UserDefaults(suiteName: currentSuite)) + let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite)) + standardDefaults.removePersistentDomain(forName: standardSuite) + currentDefaults.removePersistentDomain(forName: currentSuite) + legacyDefaults.removePersistentDomain(forName: legacySuite) + + legacyDefaults.set(true, forKey: "debugDisableKeychainAccess") + legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider") + + let legacySnapshotURL = root.appendingPathComponent( + "legacy/widget-snapshot.json", + isDirectory: false) + try fileManager.createDirectory( + at: legacySnapshotURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("legacy-snapshot".utf8).write(to: legacySnapshotURL) + + let currentSnapshotURL = root.appendingPathComponent("current/widget-snapshot.json", isDirectory: false) + let result = AppGroupSupport.migrateLegacyDataIfNeeded( + bundleID: "com.steipete.codexbar", + standardDefaults: standardDefaults, + currentDefaultsOverride: currentDefaults, + currentSnapshotURLOverride: currentSnapshotURL, + legacySnapshotURLOverride: legacySnapshotURL) + + #expect(result.status == .migrated) + #expect(result.copiedSnapshot) + #expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess")) + #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == nil) + #expect(fileManager.fileExists(atPath: currentSnapshotURL.path)) + #expect( + standardDefaults.integer(forKey: AppGroupSupport.migrationVersionKey) + == AppGroupSupport.migrationVersion) + + let secondResult = AppGroupSupport.migrateLegacyDataIfNeeded( + bundleID: "com.steipete.codexbar", + standardDefaults: standardDefaults, + currentDefaultsOverride: currentDefaults, + currentSnapshotURLOverride: currentSnapshotURL, + legacySnapshotURLOverride: legacySnapshotURL) + #expect(secondResult.status == .alreadyCompleted) + } +} diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 71c6461de..56c376811 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -16,6 +16,13 @@ struct CodexBarWidgetProviderTests { #expect(ProviderChoice.opencodego.provider == .opencodego) } + @Test + func `supported providers fall back to codex when snapshot is empty`() { + let snapshot = WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.codex]) + } + @Test func `supported providers keep alibaba when it is the only enabled provider`() { let now = Date(timeIntervalSince1970: 1_700_000_000) From c317e89f5c4cf1a7a5d2683bce39ee2a230d40ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 22:23:15 +0100 Subject: [PATCH 25/64] fix: migrate app group shared defaults --- CHANGELOG.md | 1 + Sources/CodexBar/SettingsStore.swift | 1 + Sources/CodexBarCore/AppGroupSupport.swift | 45 +++++++++++++++++-- .../CodexBarTests/AppGroupSupportTests.swift | 37 ++++++++++++++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fbb778a9..a5789fcf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! ### Menu & Settings +- Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! - Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal! ### Fixes diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index d16439317..9456af903 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -135,6 +135,7 @@ final class SettingsStore { "sharedDefaultsAvailable": sharedDefaultsAvailable ? "1" : "0", "migrationStatus": appGroupMigration.status.rawValue, "migratedSnapshot": appGroupMigration.copiedSnapshot ? "1" : "0", + "migratedDefaults": "\(appGroupMigration.copiedDefaults)", ]) } diff --git a/Sources/CodexBarCore/AppGroupSupport.swift b/Sources/CodexBarCore/AppGroupSupport.swift index fa2237668..b1b346d34 100644 --- a/Sources/CodexBarCore/AppGroupSupport.swift +++ b/Sources/CodexBarCore/AppGroupSupport.swift @@ -11,6 +11,10 @@ public enum AppGroupSupport { public static let widgetSnapshotFilename = "widget-snapshot.json" public static let migrationVersion = 1 public static let migrationVersionKey = "appGroupMigrationVersion" + private static let sharedDefaultsMigrationKeys = [ + "debugDisableKeychainAccess", + "widgetSelectedProvider", + ] public struct MigrationResult: Sendable { public enum Status: String, Sendable { @@ -22,10 +26,12 @@ public enum AppGroupSupport { public let status: Status public let copiedSnapshot: Bool + public let copiedDefaults: Int - public init(status: Status, copiedSnapshot: Bool = false) { + public init(status: Status, copiedSnapshot: Bool = false, copiedDefaults: Int = 0) { self.status = status self.copiedSnapshot = copiedSnapshot + self.copiedDefaults = copiedDefaults } } @@ -127,6 +133,7 @@ public enum AppGroupSupport { fileManager: FileManager = .default, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, currentDefaultsOverride: UserDefaults? = nil, + legacyDefaultsOverride: UserDefaults? = nil, currentSnapshotURLOverride: URL? = nil, legacySnapshotURLOverride: URL? = nil) -> MigrationResult @@ -135,10 +142,14 @@ public enum AppGroupSupport { return MigrationResult(status: .alreadyCompleted) } - guard currentDefaultsOverride ?? self.sharedDefaults(bundleID: bundleID, fileManager: fileManager) != nil else { + guard let currentDefaults = currentDefaultsOverride ?? self.sharedDefaults( + bundleID: bundleID, + fileManager: fileManager) + else { return MigrationResult(status: .targetUnavailable) } + let legacyDefaults = legacyDefaultsOverride ?? UserDefaults(suiteName: self.legacyGroupID(for: bundleID)) let currentSnapshotURL = currentSnapshotURLOverride ?? self.currentContainerURL(bundleID: bundleID, fileManager: fileManager)? .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) @@ -164,8 +175,15 @@ public enum AppGroupSupport { } }() - let result = if copiedSnapshot { - MigrationResult(status: .migrated, copiedSnapshot: true) + let copiedDefaults = self.copyLegacySharedDefaults( + from: legacyDefaults, + to: currentDefaults) + + let result = if copiedSnapshot || copiedDefaults > 0 { + MigrationResult( + status: .migrated, + copiedSnapshot: copiedSnapshot, + copiedDefaults: copiedDefaults) } else { MigrationResult(status: .noChangesNeeded) } @@ -174,6 +192,25 @@ public enum AppGroupSupport { return result } + private static func copyLegacySharedDefaults( + from legacyDefaults: UserDefaults?, + to currentDefaults: UserDefaults) -> Int + { + guard let legacyDefaults else { return 0 } + + var copied = 0 + for key in self.sharedDefaultsMigrationKeys { + guard currentDefaults.object(forKey: key) == nil, + let legacyValue = legacyDefaults.object(forKey: key) + else { + continue + } + currentDefaults.set(legacyValue, forKey: key) + copied += 1 + } + return copied + } + private static func isDebugBundleID(_ bundleID: String?) -> Bool { guard let bundleID, !bundleID.isEmpty else { return false } return bundleID.contains(".debug") diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift index 249f109c2..ba55d2acd 100644 --- a/Tests/CodexBarTests/AppGroupSupportTests.swift +++ b/Tests/CodexBarTests/AppGroupSupportTests.swift @@ -65,13 +65,15 @@ struct AppGroupSupportTests { bundleID: "com.steipete.codexbar", standardDefaults: standardDefaults, currentDefaultsOverride: currentDefaults, + legacyDefaultsOverride: legacyDefaults, currentSnapshotURLOverride: currentSnapshotURL, legacySnapshotURLOverride: legacySnapshotURL) #expect(result.status == .migrated) #expect(result.copiedSnapshot) - #expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess")) - #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == nil) + #expect(result.copiedDefaults == 2) + #expect(currentDefaults.bool(forKey: "debugDisableKeychainAccess")) + #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == UsageProvider.cursor.rawValue) #expect(fileManager.fileExists(atPath: currentSnapshotURL.path)) #expect( standardDefaults.integer(forKey: AppGroupSupport.migrationVersionKey) @@ -81,8 +83,39 @@ struct AppGroupSupportTests { bundleID: "com.steipete.codexbar", standardDefaults: standardDefaults, currentDefaultsOverride: currentDefaults, + legacyDefaultsOverride: legacyDefaults, currentSnapshotURLOverride: currentSnapshotURL, legacySnapshotURLOverride: legacySnapshotURL) #expect(secondResult.status == .alreadyCompleted) } + + @Test + func `legacy migration preserves existing target shared defaults`() throws { + let standardSuite = "AppGroupSupportTests-standard-existing-\(UUID().uuidString)" + let currentSuite = "AppGroupSupportTests-current-existing-\(UUID().uuidString)" + let legacySuite = "AppGroupSupportTests-legacy-existing-\(UUID().uuidString)" + + let standardDefaults = try #require(UserDefaults(suiteName: standardSuite)) + let currentDefaults = try #require(UserDefaults(suiteName: currentSuite)) + let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite)) + standardDefaults.removePersistentDomain(forName: standardSuite) + currentDefaults.removePersistentDomain(forName: currentSuite) + legacyDefaults.removePersistentDomain(forName: legacySuite) + + currentDefaults.set(false, forKey: "debugDisableKeychainAccess") + currentDefaults.set(UsageProvider.codex.rawValue, forKey: "widgetSelectedProvider") + legacyDefaults.set(true, forKey: "debugDisableKeychainAccess") + legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider") + + let result = AppGroupSupport.migrateLegacyDataIfNeeded( + bundleID: "com.steipete.codexbar", + standardDefaults: standardDefaults, + currentDefaultsOverride: currentDefaults, + legacyDefaultsOverride: legacyDefaults) + + #expect(result.status == .noChangesNeeded) + #expect(result.copiedDefaults == 0) + #expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess")) + #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == UsageProvider.codex.rawValue) + } } From 835caaa0237cc084220686f1cfaf48ae458d2b76 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 14:08:52 +0800 Subject: [PATCH 26/64] checkpoint before checking out cursor/minimax-multi-model-usage-ef73 --- .cursor/hooks/state/continual-learning.json | 8 + .specstory/.project.json | 7 + .specstory/cli/config.toml | 79 +++++ ...19_04-53-42Z-cursor-branch-code-testing.md | 324 ++++++++++++++++++ .specstory/statistics.json | 13 + 5 files changed, 431 insertions(+) create mode 100644 .cursor/hooks/state/continual-learning.json create mode 100644 .specstory/.project.json create mode 100644 .specstory/cli/config.toml create mode 100644 .specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md create mode 100644 .specstory/statistics.json diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json new file mode 100644 index 000000000..8a91eee07 --- /dev/null +++ b/.cursor/hooks/state/continual-learning.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "lastRunAtMs": 0, + "turnsSinceLastRun": 2, + "lastTranscriptMtimeMs": null, + "lastProcessedGenerationId": "d1ca6c4d-0a87-4bff-b994-8f4b64215c5c", + "trialStartedAtMs": null +} diff --git a/.specstory/.project.json b/.specstory/.project.json new file mode 100644 index 000000000..503fb60d0 --- /dev/null +++ b/.specstory/.project.json @@ -0,0 +1,7 @@ +{ + "workspace_id": "a1bf-ca39-9a6f-0f1e", + "workspace_id_at": "2026-04-19T06:04:20Z", + "git_id": "23d9-7492-e24f-7e50", + "git_id_at": "2026-04-19T06:04:20Z", + "project_name": "CodexBar" +} \ No newline at end of file diff --git a/.specstory/cli/config.toml b/.specstory/cli/config.toml new file mode 100644 index 000000000..9336aae89 --- /dev/null +++ b/.specstory/cli/config.toml @@ -0,0 +1,79 @@ +# SpecStory CLI Configuration +# +# This is the project-level config file for SpecStory CLI. +# All settings here apply to this project unless overridden by CLI flags. +# +# Uncomment (remove the #) the line and edit any setting below to change the default behavior. +# For more information, see: https://docs.specstory.com/integrations/terminal-coding-agents/usage + +[local_sync] +# Write markdown files locally. (default: true) +# enabled = false # equivalent to --only-cloud-sync + +# Custom output directory for markdown files. +# Default: ./.specstory/history (relative to the project directory) +# output_dir = "~/.specstory/history" # equivalent to --output-dir "~/.specstory/history" + +# Use local timezone for file name and content timestamps (default: false, UTC) +# local_time_zone = true # equivalent to --local-time-zone + +[cloud_sync] +# Sync session data to SpecStory Cloud. (default: true, when logged in to SpecStory Cloud) +# enabled = false # equivalent to --no-cloud-sync + +[logging] +# Write logs to .specstory/debug/debug.log (default: false) +# log = true # equivalent to --log + +# Debug-level output, requires console or log (default: false) +# debug = true # equivalent to --debug + +# Custom output directory for debug data. +# Default: ./.specstory/debug (relative to the project directory) +# debug_dir = "~/.specstory/debug" # equivalent to --debug-dir "~/.specstory/debug" + +# Error/warn/info output to stdout (default: false) +# console = true # equivalent to --console + +# Suppress all non-error output (default: false) +# silent = true # equivalent to --silent + +[version_check] +# Check for new versions of the CLI on startup. +# Default: true +# enabled = false # equivalent to --no-version-check + +[analytics] +# Send anonymous product usage analytics to help improve SpecStory. +# Default: true +# enabled = false # equivalent to --no-usage-analytics + +[telemetry] +# OTLP gRPC collector endpoint (e.g., "localhost:4317" or "http://localhost:4317") +# endpoint = "localhost:4317" + +# Override the default service name (default: "specstory-cli") +# service_name = "my-service-name" + +# Include user prompt text in telemetry spans (default: true) +# prompts = false + +[providers] +# Agent execution commands by provider (used by specstory run) +# Pass custom flags (e.g. claude_cmd = "claude --allow-dangerously-skip-permissions") +# Use of these is equivalent to -c "custom command" + +# Claude Code command +# claude_cmd = "claude" + +# Codex CLI command +# codex_cmd = "codex" + +# Cursor CLI command +# cursor_cmd = "cursor-agent" + +# Droid CLI command +# droid_cmd = "droid" + +# Gemini CLI command +# gemini_cmd = "gemini" diff --git a/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md b/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md new file mode 100644 index 000000000..021426232 --- /dev/null +++ b/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md @@ -0,0 +1,324 @@ + + +# 2026-04-19 04:53:42Z + + + +_**User**_ + +测试cursor分支的代码,应该如何做 + +--- + +_**Agent (default)**_ + + +正在查看项目结构与测试配置,以便说明如何测试 `cursor` 分支的代码。 + + + +_**Agent (default)**_ + + +
Tool use: **read_file_v2** • Read file: /Users/gc/Dev/Opensource/CodexBar/AGENTS.md + +
+
+ +
+Tool use: **read_file_v2** + +
+ +_**Agent (default)**_ + + +
+Tool use: **glob_file_search** • Searched codebase "" • **1** result in **1** directory + +Directory: **/Users/gc/Dev/Opensource/CodexBar** (1 file) + +| File | +|------| +| `package.json` | + +
+
+ +
+Tool use: **glob_file_search** + +
+ +_**Agent (default)**_ + + +
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git status 2>/dev/null; git branch -a 2>/dev/null | head -30 + +```bash +cd /Users/gc/Dev/Opensource/CodexBar && git status 2>/dev/null; git branch -a 2>/dev/null | head -30 +``` +
+
+ +
+Tool use: **run_terminal_command_v2** + +
+ +_**Agent (default)**_ + +
Thought Process +The git command failed with exit code 69, indicating git may not be installed or the current directory is not a Git repository.
+ +_**Agent (default)**_ + + + + + + +_**Agent (default)**_ + + +
Tool use: **read_file_v2** • Read file: /Users/gc/Dev/Opensource/CodexBar/package.json + +
+
+ +
+Tool use: **read_file_v2** + +
+ +_**Agent (default)**_ + + +### 🧠 分析与说明 + +当前工作区可能**不是 Git 仓库**(本机 `git` 命令未返回分支信息)。若你要测的是**名为 `cursor` 的分支**,通用做法是:先切到该分支,再按本仓库约定跑构建与测试。 + +CodexBar 是 **SwiftPM + XCTest** 项目,`AGENTS.md` 和 `package.json` 里已经写清了推荐流程。 + +--- + +### 1. 切到 `cursor` 分支(在克隆的仓库里) + +在项目根目录执行: + +```bash +git fetch origin +git checkout cursor +# 若本地已有同名分支且需对齐远端: +# git checkout -b cursor origin/cursor +``` + +确认当前分支: + +```bash +git branch --show-current +git log -1 --oneline +``` + +--- + +### 2. 跑自动化测试(推荐顺序) + +| 目的 | 命令 | +|------|------| +| 只跑单元测试 | `pnpm test` 或 `swift test` | +| TTY 相关集成测试 | `pnpm run test:tty` | +| 需真实账号的 live 测试 | `pnpm run test:live`(会设 `LIVE_TEST=1`) | +| 格式/静态检查(提交前) | `pnpm check`(`AGENTS.md` 要求改代码后跑) | + +--- + +### 3. 完整开发闭环(构建 + 测试 + 打包 + 启动 App) + +与文档一致的首选: + +```bash +./Scripts/compile_and_run.sh +``` + +或: + +```bash +pnpm start +``` + +脚本会结束旧进程、`swift build`、`swift test`、打包并重新启动 `CodexBar.app`,适合验证**菜单栏应用**是否按预期工作。 + +--- + +### 4. 若本目录没有 Git / 没有 `cursor` 分支 + +需要先**克隆**带 `cursor` 分支的远程仓库,或让维护者提供该分支的提交/补丁,再在那一套代码上执行上面的 `swift test` / `compile_and_run.sh`。 + +--- + +**小结**:在 `cursor` 分支上,先 `git checkout cursor`,再 **`swift test`(或 `pnpm test`)** 做自动化验证;需要连 UI/真机行为时用 **`./Scripts/compile_and_run.sh`**;提交前跑 **`pnpm check`**。若你希望我根据你本机实际远程名(`origin` 或其它)写一条「从当前状态拉到 `cursor`」的准确命令,可以说一下仓库是 fork 还是主仓、以及 `git remote -v` 的输出。 + +--- + +_**User**_ + +我需要把cursor/minimax-multi-model-usage-ef73分支拉下来应该怎么做 + +--- + +_**Agent (default)**_ + +
Thought Process +To fetch the specified branch, use the command `git fetch origin cursor/minimax-multi-model-usage-ef73`. +If you need to switch to this branch, run `git checkout cursor/minimax-multi-model-usage-ef73`.
+ +_**Agent (default)**_ + + +正在检查仓库的远程与分支情况,以便给出准确命令。 + + + +_**Agent (default)**_ + + +
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git rev-parse --is-inside-work-tree 2>/dev/null; git remote -v 2>/dev/null; git branch -a 2>/dev/null | head -40 + +```bash +cd /Users/gc/Dev/Opensource/CodexBar && git rev-parse --is-inside-work-tree 2>/dev/null; git remote -v 2>/dev/null; git branch -a 2>/dev/null | head -40 +``` + +``` +true +origin https://github.com/cskeleton/CodexBar.git (fetch) +origin https://github.com/cskeleton/CodexBar.git (push) +upstream https://github.com/steipete/CodexBar.git (fetch) +upstream https://github.com/steipete/CodexBar.git (push) +* main + remotes/origin/HEAD -> origin/main + remotes/origin/cursor/minimax-multi-model-usage-ef73 + remotes/origin/main + remotes/upstream/HEAD -> upstream/main + remotes/upstream/antigravity + remotes/upstream/ci/pin-lint-tools-and-format-baseline + remotes/upstream/claude-keychain-refresh-fix + remotes/upstream/co-pilot + remotes/upstream/codex/auto-keychain-prompts-user-action + remotes/upstream/codex/claude-auto-cli-before-web + remotes/upstream/codex/claude-keychain-policy-hard-enforcement + remotes/upstream/codex/claude-keychain-policy-ux + remotes/upstream/codex/claude-oauth-keychain-policy + remotes/upstream/codex/claude-oauth-security-cli-experimental + remotes/upstream/codex/fix-opencode-null-subscription-273 + remotes/upstream/codex/main-debug-reconcile-split + remotes/upstream/codex/menu-open-no-settings-write + remotes/upstream/codex/pr-275-scripts-only + remotes/upstream/codex/pr-286-audit + remotes/upstream/codex/pr-294-warp-provider + remotes/upstream/codex/pr-298-audit + remotes/upstream/codex/pr-310-audit + remotes/upstream/codex/pr-393-audit + remotes/upstream/codex/pr-397-audit + remotes/upstream/codex/pr-409-audit + remotes/upstream/codex/pr-438-audit + remotes/upstream/codex/pr-449-audit + remotes/upstream/codex/pr-453-audit + remotes/upstream/codex/pr-529-audit-20260409-152104 + remotes/upstream/codex/pr-565-audit + remotes/upstream/codex/pr-632-audit + remotes/upstream/codex/pr-673-audit + remotes/upstream/codex/provider-account-identity + remotes/upstream/codex/rat-185-multi-account-foundation + remotes/upstream/codex/rat-186 + remotes/upstream/codex/rat-187 + remotes/upstream/codex/rat-189-codex-parity-lock + remotes/upstream/cost/pr33-ccusage + remotes/upstream/cursor-fixes + +``` +
+
+ +
+Tool use: **run_terminal_command_v2** + +
+ +_**Agent (default)**_ + +
Thought Process +The remote branch exists as `remotes/origin/cursor/minimax-multi-model-usage-ef73`.
+ +_**Agent (default)**_ + + +远程已有该分支:`origin/cursor/minimax-multi-model-usage-ef73`。正在拉取并检出: + + + +_**Agent (default)**_ + + +
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git fetch origin cursor/minimax-multi-model-usage-ef73 && git checkout cursor/minimax-multi-model-usage-ef73 + +```bash +cd /Users/gc/Dev/Opensource/CodexBar && git fetch origin cursor/minimax-multi-model-usage-ef73 && git checkout cursor/minimax-multi-model-usage-ef73 +``` + +``` +From https://github.com/cskeleton/CodexBar + * branch cursor/minimax-multi-model-usage-ef73 -> FETCH_HEAD +Switched to a new branch 'cursor/minimax-multi-model-usage-ef73' +branch 'cursor/minimax-multi-model-usage-ef73' set up to track 'origin/cursor/minimax-multi-model-usage-ef73'. + +``` +
+
+ +
+Tool use: **run_terminal_command_v2** + +
+ +_**Agent (default)**_ + + +### 已在你的仓库里完成操作 + +当前目录已 **`fetch` 并 `checkout`** 到分支 `cursor/minimax-multi-model-usage-ef73`,且已设置跟踪 `origin/cursor/minimax-multi-model-usage-ef73`。 + +--- + +### 以后在别的机器 / 新克隆里可以自己执行 + +**已有克隆、只拉这条分支并切换:** + +```bash +cd /path/to/CodexBar +git fetch origin cursor/minimax-multi-model-usage-ef73 +git checkout cursor/minimax-multi-model-usage-ef73 +``` + +**若本地还没有这个分支,用跟踪分支显式创建(等价写法):** + +```bash +git fetch origin +git checkout -b cursor/minimax-multi-model-usage-ef73 origin/cursor/minimax-multi-model-usage-ef73 +``` + +**全新克隆并直接落在该分支:** + +```bash +git clone -b cursor/minimax-multi-model-usage-ef73 https://github.com/cskeleton/CodexBar.git +``` + +--- + +### 更新分支(之后别人又推了新提交) + +```bash +git pull origin cursor/minimax-multi-model-usage-ef73 +``` + +需要的话可以继续在该分支上跑 `swift test` 或 `./Scripts/compile_and_run.sh` 做验证。 diff --git a/.specstory/statistics.json b/.specstory/statistics.json new file mode 100644 index 000000000..c91f97a4c --- /dev/null +++ b/.specstory/statistics.json @@ -0,0 +1,13 @@ +{ + "sessions": { + "9b5175d8-f354-417b-b218-5c6d9ac572fc": { + "user_message_count": 2, + "agent_message_count": 15, + "start_timestamp": "2026-04-19T12:53:42+08:00", + "end_timestamp": "2026-04-19T12:53:42+08:00", + "markdown_size_bytes": 10037, + "provider": "cursoride", + "last_updated": "2026-04-19T06:06:33Z" + } + } +} From bfa422b3e1d32945eb0ef363872ce7cf1d520f5e Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 17:10:20 +0800 Subject: [PATCH 27/64] Add MiniMax Token Plan API usage and collapsible scrolled UI - Show full MiniMax Token Plan API usage in menu bar card and provider settings; hide weekly cap rows when the plan has no weekly limit (legacy plans).\n- MiniMax menu card: section collapse defaults, in-card scrolling, layout metrics; Preferences mirrors grouped sections with scrolling.\n- Extend MiniMaxUsageFetcher parsing, tests, and docs/minimax.md; note behavior in AGENTS.md. Made-with: Cursor --- AGENTS.md | 8 + Sources/CodexBar/MenuCardView.swift | 314 ++++++------------ Sources/CodexBar/MiniMaxMenuCardViews.swift | 120 +++++++ .../MiniMaxSectionCollapseStore.swift | 33 ++ Sources/CodexBar/MiniMaxUILayoutMetrics.swift | 24 ++ .../PreferencesProviderDetailView.swift | 114 ++++++- .../CodexBar/StatusItemController+Menu.swift | 64 +++- .../StatusItemController+MenuTypes.swift | 13 +- .../UsageMenuCardViewModel+MiniMax.swift | 128 +++++++ .../Antigravity/AntigravityStatusProbe.swift | 12 +- .../MiniMax/MiniMaxUsageFetcher.swift | 19 +- .../CodexBarTests/MiniMaxMenuCardTests.swift | 96 +++++- .../CodexBarTests/MiniMaxProviderTests.swift | 65 ++++ .../MiniMaxUILayoutMetricsTests.swift | 28 ++ ...enAIDashboardNavigationDelegateTests.swift | 7 +- docs/minimax.md | 80 +++-- 16 files changed, 844 insertions(+), 281 deletions(-) create mode 100644 Sources/CodexBar/MiniMaxMenuCardViews.swift create mode 100644 Sources/CodexBar/MiniMaxSectionCollapseStore.swift create mode 100644 Sources/CodexBar/MiniMaxUILayoutMetrics.swift create mode 100644 Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift create mode 100644 Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift diff --git a/AGENTS.md b/AGENTS.md index a4c8e630a..0a5563d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,11 @@ - Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.*** - Claude CLI status line is custom + user-configurable; never rely on it for usage parsing. - Cookie imports: default Chrome-only when possible to avoid other browser prompts; override via browser list when needed. + +## Learned User Preferences +- When extending provider usage models (e.g. MiniMax), mirror existing field and UI patterns; add new fields using the same conventions as neighboring code. + +## Learned Workspace Facts +- MiniMax Coding Plan `model_remains` weekly fields may arrive as both zeros, or with only one of `current_weekly_total_count` / `current_weekly_usage_count` present and zero. CodexBar treats “at least one weekly key present and both sides numerically zero when missing counts as zero” as no weekly cap. Interval window lines that are 0/0 placeholders are suppressed in the menu card so they are not mistaken for weekly limits. +- `swift build -c release` only refreshes the `.build/.../CodexBar` binary. The launchable root `CodexBar.app` is recreated by `Scripts/package_app.sh` or `Scripts/compile_and_run.sh`; if UI behavior looks stale, compare the bundle `CodexGitCommit` in `Contents/Info.plist` with `git rev-parse --short HEAD`. +- MiniMax menu usage is rendered inside one hosted `NSMenuItem`, so height limiting, scrolling, and section collapsing must happen inside that card to keep the bottom app-level menu items visible. Current MiniMax behavior: collapse state is keyed by section title, 5+ row sections default to collapsed, and Preferences mirrors the sections with scrolling only (no collapse). diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 9d87f362d..5236f3e3e 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -126,8 +126,15 @@ struct UsageMenuCardView: View { let model: Model let width: CGFloat + let onMiniMaxLayoutChange: (() -> Void)? @Environment(\.menuItemHighlighted) private var isHighlighted + init(model: Model, width: CGFloat, onMiniMaxLayoutChange: (() -> Void)? = nil) { + self.model = model + self.width = width + self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + } + static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { return "API key limit" @@ -135,6 +142,10 @@ struct UsageMenuCardView: View { return metric.title } + static func miniMaxUsageScrollMaxHeight() -> CGFloat { + MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: NSScreen.main?.visibleFrame.height) + } + var body: some View { VStack(alignment: .leading, spacing: 6) { UsageMenuCardHeaderView(model: self.model) @@ -160,20 +171,43 @@ struct UsageMenuCardView: View { VStack(alignment: .leading, spacing: 12) { if hasUsage { - VStack(alignment: .leading, spacing: 12) { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) - } - if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } - if let sections = self.model.minimaxSections, !sections.isEmpty { - MiniMaxTokenPlanSectionsView( - sections: sections, - progressColor: self.model.progressColor) + Group { + if hasMiniMaxSections { + MiniMaxCappedScrollView(maxHeight: Self.miniMaxUsageScrollMaxHeight()) { + VStack(alignment: .leading, spacing: 12) { + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: Self.popupMetricTitle( + provider: self.model.provider, + metric: metric), + progressColor: self.model.progressColor) + } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } + if let sections = self.model.minimaxSections, !sections.isEmpty { + MiniMaxTokenPlanSectionsView( + sections: sections, + progressColor: self.model.progressColor, + onLayoutChange: self.onMiniMaxLayoutChange) + } + } + } + } else { + VStack(alignment: .leading, spacing: 12) { + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: Self.popupMetricTitle( + provider: self.model.provider, + metric: metric), + progressColor: self.model.progressColor) + } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } + } } } } @@ -371,70 +405,6 @@ private struct ProviderCostContent: View { } } -private struct MiniMaxTokenPlanSectionsView: View { - let sections: [UsageMenuCardView.Model.MiniMaxSection] - let progressColor: Color - @Environment(\.menuItemHighlighted) private var isHighlighted - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - ForEach(Array(self.sections.enumerated()), id: \.offset) { _, section in - VStack(alignment: .leading, spacing: 8) { - Text(section.title) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - ForEach(section.rows) { row in - MiniMaxTokenPlanRowView(row: row, progressColor: self.progressColor) - } - } - } - } - .padding(.top, 4) - } -} - -private struct MiniMaxTokenPlanRowView: View { - let row: UsageMenuCardView.Model.MiniMaxRow - let progressColor: Color - @Environment(\.menuItemHighlighted) private var isHighlighted - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(self.row.title) - .font(.footnote) - .fontWeight(.medium) - if let statusText = self.row.detailText, statusText.isEmpty == false { - Text(statusText) - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - } - UsageProgressBar( - percent: self.row.percent, - tint: self.progressColor, - accessibilityLabel: self.row.percentStyle.accessibilityLabel) - HStack(alignment: .firstTextBaseline) { - Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) - .font(.caption2) - Spacer() - if let reset = self.row.resetText { - Text(reset) - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(2) - } - } - if let secondary = self.row.secondaryLine, !secondary.isEmpty { - Text(secondary) - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(3) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - private struct MetricRow: View { let metric: UsageMenuCardView.Model.Metric let title: String @@ -545,34 +515,32 @@ struct UsageMenuCardUsageSectionView: View { let showBottomDivider: Bool let bottomPadding: CGFloat let width: CGFloat + let onMiniMaxLayoutChange: (() -> Void)? @Environment(\.menuItemHighlighted) private var isHighlighted + init( + model: UsageMenuCardView.Model, + showBottomDivider: Bool, + bottomPadding: CGFloat, + width: CGFloat, + onMiniMaxLayoutChange: (() -> Void)? = nil) + { + self.model = model + self.showBottomDivider = showBottomDivider + self.bottomPadding = bottomPadding + self.width = width + self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + } + var body: some View { let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false VStack(alignment: .leading, spacing: 12) { - if self.model.metrics.isEmpty, !hasMiniMaxSections { - if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } else if let placeholder = self.model.placeholder { - Text(placeholder) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .font(.subheadline) + if hasMiniMaxSections { + MiniMaxCappedScrollView(maxHeight: UsageMenuCardView.miniMaxUsageScrollMaxHeight()) { + self.usageContent(hasMiniMaxSections: hasMiniMaxSections) } } else { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) - } - if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } - } - if let sections = self.model.minimaxSections, !sections.isEmpty { - MiniMaxTokenPlanSectionsView( - sections: sections, - progressColor: self.model.progressColor) + self.usageContent(hasMiniMaxSections: hasMiniMaxSections) } if self.showBottomDivider { Divider() @@ -583,6 +551,35 @@ struct UsageMenuCardUsageSectionView: View { .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } + + @ViewBuilder + private func usageContent(hasMiniMaxSections: Bool) -> some View { + if self.model.metrics.isEmpty, !hasMiniMaxSections { + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) + } + } else { + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), + progressColor: self.model.progressColor) + } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } + } + if let sections = self.model.minimaxSections, !sections.isEmpty { + MiniMaxTokenPlanSectionsView( + sections: sections, + progressColor: self.model.progressColor, + onLayoutChange: self.onMiniMaxLayoutChange) + } + } } struct UsageMenuCardCreditsSectionView: View { @@ -870,123 +867,6 @@ extension UsageMenuCardView.Model { progressColor: Self.progressColor(for: input.provider)) } - private static func miniMaxSections(input: Input) -> [MiniMaxSection]? { - guard input.provider == .minimax, - let models = input.snapshot?.minimaxUsage?.models, - !models.isEmpty - else { - return nil - } - let hasWeeklyDetail = models.contains { $0.weeklyTotal != nil || $0.weeklyRemaining != nil } - guard models.count > 1 || hasWeeklyDetail else { - return nil - } - - let fiveHour = models.filter { if case .fiveHour = $0.window { return true }; return false } - let daily = models.filter { if case .daily = $0.window { return true }; return false } - let weeklyOnly = models.filter { if case .weekly = $0.window { return true }; return false } - let other = models.filter { if case .other = $0.window { return true }; return false } - - var sections: [MiniMaxSection] = [] - if !fiveHour.isEmpty { - sections.append(MiniMaxSection( - title: "5-hour window", - rows: fiveHour.map { Self.miniMaxRow(model: $0, input: input) })) - } - if !daily.isEmpty { - sections.append(MiniMaxSection( - title: "Daily quota", - rows: daily.map { Self.miniMaxRow(model: $0, input: input) })) - } - if !weeklyOnly.isEmpty { - sections.append(MiniMaxSection( - title: "Weekly quota", - rows: weeklyOnly.map { Self.miniMaxRow(model: $0, input: input) })) - } - if !other.isEmpty { - sections.append(MiniMaxSection( - title: "Other windows", - rows: other.map { Self.miniMaxRow(model: $0, input: input) })) - } - return sections.isEmpty ? nil : sections - } - - private static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow { - let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left - let used = model.usedPercent ?? 0 - let barPercent = percentStyle == .used ? used : (100 - used) - let resetText: String? = if let at = model.resetsAt { - UsageFormatter.resetLine( - for: RateWindow( - usedPercent: used, - windowMinutes: model.windowMinutes, - resetsAt: at, - resetDescription: nil), - style: input.resetTimeDisplayStyle, - now: input.now) - } else { - nil - } - let detailText = Self.miniMaxDetailLine(model: model) - let secondaryLine = Self.miniMaxWeeklySecondaryLine(model: model, input: input) - return MiniMaxRow( - id: model.identifier, - title: model.displayName, - percent: Self.clamped(barPercent), - percentStyle: percentStyle, - resetText: resetText, - detailText: detailText, - secondaryLine: secondaryLine) - } - - private static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { - guard let total = model.availablePrompts else { return nil } - let used = model.currentPrompts ?? max(0, total - (model.remainingPrompts ?? 0)) - let remaining = model.remainingPrompts - let usedStr = UsageFormatter.tokenCountString(used) - let totalStr = UsageFormatter.tokenCountString(total) - if let remaining { - let remStr = UsageFormatter.tokenCountString(remaining) - return "\(usedStr)/\(totalStr) (\(remStr) remaining)" - } - return "\(usedStr)/\(totalStr)" - } - - private static func miniMaxWeeklySecondaryLine(model: MiniMaxModelUsage, input: Input) -> String? { - guard model.weeklyTotal != nil || model.weeklyRemaining != nil else { return nil } - let total = model.weeklyTotal - let used = model.weeklyUsed - let remaining = model.weeklyRemaining - let usedStr = used.map { UsageFormatter.tokenCountString($0) } ?? "—" - let totalStr = total.map { UsageFormatter.tokenCountString($0) } ?? "—" - let pctStr: String = if let p = model.weeklyUsedPercent { - String(format: "%.1f%%", p) - } else { - "—" - } - let weeklyReset: String? = if let at = model.weeklyResetsAt { - UsageFormatter.resetLine( - for: RateWindow( - usedPercent: model.weeklyUsedPercent ?? 0, - windowMinutes: 7 * 24 * 60, - resetsAt: at, - resetDescription: nil), - style: input.resetTimeDisplayStyle, - now: input.now) - } else { - nil - } - let remStr = remaining.map { UsageFormatter.tokenCountString($0) } - var line = "↳ Weekly \(usedStr)/\(totalStr) (\(pctStr) used)" - if let remStr { - line += " · \(remStr) remaining" - } - if let weeklyReset { - line += " · \(weeklyReset)" - } - return line - } - private static func usageNotes(input: Input) -> [String] { if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) @@ -1616,7 +1496,7 @@ extension UsageMenuCardView.Model { spendLine: "\(periodLabel): \(used) / \(limit)") } - private static func clamped(_ value: Double) -> Double { + static func clamped(_ value: Double) -> Double { min(100, max(0, value)) } diff --git a/Sources/CodexBar/MiniMaxMenuCardViews.swift b/Sources/CodexBar/MiniMaxMenuCardViews.swift new file mode 100644 index 000000000..ec4327e7a --- /dev/null +++ b/Sources/CodexBar/MiniMaxMenuCardViews.swift @@ -0,0 +1,120 @@ +import CodexBarCore +import SwiftUI + +struct MiniMaxCappedScrollView: View { + let maxHeight: CGFloat + @ViewBuilder let content: () -> Content + + var body: some View { + ScrollView { + self.content() + } + .frame(maxHeight: self.maxHeight, alignment: .top) + } +} + +struct MiniMaxTokenPlanSectionsView: View { + let sections: [UsageMenuCardView.Model.MiniMaxSection] + let progressColor: Color + let onLayoutChange: (() -> Void)? + @Environment(\.menuItemHighlighted) private var isHighlighted + @Bindable private var collapseStore = MiniMaxSectionCollapseStore.shared + + init( + sections: [UsageMenuCardView.Model.MiniMaxSection], + progressColor: Color, + onLayoutChange: (() -> Void)? = nil) + { + self.sections = sections + self.progressColor = progressColor + self.onLayoutChange = onLayoutChange + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(self.sections, id: \.title) { section in + let rowCount = section.rows.count + let collapsed = self.collapseStore.isCollapsed(sectionTitle: section.title, rowCount: rowCount) + VStack(alignment: .leading, spacing: 8) { + Button { + self.collapseStore.toggle(sectionTitle: section.title, rowCount: rowCount) + if let onLayoutChange = self.onLayoutChange { + Task { @MainActor in + await Task.yield() + onLayoutChange() + } + } + } label: { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(section.title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + Spacer(minLength: 8) + if collapsed { + Text("\(rowCount) items") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .monospacedDigit() + } + Image(systemName: collapsed ? "chevron.right" : "chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(collapsed ? "展开 \(section.title)" : "折叠 \(section.title)") + + if !collapsed { + ForEach(section.rows) { row in + MiniMaxTokenPlanRowView(row: row, progressColor: self.progressColor) + } + } + } + } + } + .padding(.top, 4) + } +} + +struct MiniMaxTokenPlanRowView: View { + let row: UsageMenuCardView.Model.MiniMaxRow + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(self.row.title) + .font(.footnote) + .fontWeight(.medium) + if let statusText = self.row.detailText, statusText.isEmpty == false { + Text(statusText) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + UsageProgressBar( + percent: self.row.percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) + .font(.caption2) + Spacer() + if let reset = self.row.resetText { + Text(reset) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(2) + } + } + if let secondary = self.row.secondaryLine, !secondary.isEmpty { + Text(secondary) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/Sources/CodexBar/MiniMaxSectionCollapseStore.swift b/Sources/CodexBar/MiniMaxSectionCollapseStore.swift new file mode 100644 index 000000000..6126ccad6 --- /dev/null +++ b/Sources/CodexBar/MiniMaxSectionCollapseStore.swift @@ -0,0 +1,33 @@ +import Foundation +import Observation + +/// 进程内保存 MiniMax 菜单卡各分组折叠状态;`sectionTitle` 为 key,未覆盖时使用「行数 ≥ 5 默认折叠」。 +@MainActor +@Observable +final class MiniMaxSectionCollapseStore { + static let shared = MiniMaxSectionCollapseStore() + + private var overrides: [String: Bool] = [:] + + private init() {} + + /// - Parameters: + /// - sectionTitle: 分组标题(与 `MiniMaxSection.title` 一致)。 + /// - rowCount: 该分组内行数;≥ `MiniMaxUILayoutMetrics.collapseThreshold` 时默认折叠。 + func isCollapsed(sectionTitle: String, rowCount: Int) -> Bool { + if let stored = self.overrides[sectionTitle] { + return stored + } + return rowCount >= MiniMaxUILayoutMetrics.collapseThreshold + } + + func toggle(sectionTitle: String, rowCount: Int) { + let current = self.isCollapsed(sectionTitle: sectionTitle, rowCount: rowCount) + self.overrides[sectionTitle] = !current + } + + /// 单测重置覆盖,避免用例互相污染。 + func resetOverridesForTesting() { + self.overrides.removeAll() + } +} diff --git a/Sources/CodexBar/MiniMaxUILayoutMetrics.swift b/Sources/CodexBar/MiniMaxUILayoutMetrics.swift new file mode 100644 index 000000000..66536c0c9 --- /dev/null +++ b/Sources/CodexBar/MiniMaxUILayoutMetrics.swift @@ -0,0 +1,24 @@ +import AppKit + +enum MiniMaxUILayoutMetrics { + static let collapseThreshold = 5 + static let settingsEmbeddedScrollThreshold = 6 + static let settingsEmbeddedScrollMaxHeight: CGFloat = 360 + static let settingsTitleWidthReference = "code-plan-search" + static let menuScrollFallbackHeight: CGFloat = 560 + + static func menuUsageScrollMaxHeight(visibleScreenHeight: CGFloat?) -> CGFloat { + guard let height = visibleScreenHeight else { + return self.menuScrollFallbackHeight + } + return min(640, max(320, height - 310)) + } + + static func preferredMenuUsageHeight(contentHeight: CGFloat, visibleScreenHeight: CGFloat?) -> CGFloat { + min(max(1, ceil(contentHeight)), self.menuUsageScrollMaxHeight(visibleScreenHeight: visibleScreenHeight)) + } + + static func settingsTitleWidthCap(font: NSFont = ProviderSettingsMetrics.metricLabelFont()) -> CGFloat { + ProviderSettingsMetrics.labelWidth(for: [self.settingsTitleWidthReference], font: font) + } +} diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 5ecff079d..d37be61a0 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -349,13 +349,14 @@ struct ProviderMetricsInlineView: View { let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasTokenUsage = self.model.tokenUsage != nil + let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false ProviderSettingsSection( title: "Usage", spacing: 8, verticalPadding: 6, horizontalPadding: 0) { - if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage { + if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage, !hasMiniMaxSections { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) @@ -375,6 +376,13 @@ struct ProviderMetricsInlineView: View { alignsWithMetricContent: hasMetrics) } + if let sections = self.model.minimaxSections, !sections.isEmpty { + ProviderMiniMaxSectionsInlineView( + sections: sections, + progressColor: self.model.progressColor, + labelWidth: self.labelWidth) + } + if let credits = self.model.creditsText { ProviderMetricInlineTextRow( title: "Credits", @@ -563,3 +571,107 @@ private struct ProviderMetricInlineCostRow: View { .padding(.vertical, 2) } } + +// MARK: - MiniMax `model_remains[]`(设置页镜像菜单卡分组,无折叠) + +private struct ProviderMiniMaxSectionsInlineView: View { + let sections: [UsageMenuCardView.Model.MiniMaxSection] + let progressColor: Color + let labelWidth: CGFloat + + private var totalRowCount: Int { + self.sections.reduce(0) { $0 + $1.rows.count } + } + + var body: some View { + Group { + if self.totalRowCount >= MiniMaxUILayoutMetrics.settingsEmbeddedScrollThreshold { + ScrollView { + self.sectionStack + } + .frame(maxHeight: MiniMaxUILayoutMetrics.settingsEmbeddedScrollMaxHeight) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(0.06)) + } + } else { + self.sectionStack + } + } + } + + private var sectionStack: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(self.sections, id: \.title) { section in + VStack(alignment: .leading, spacing: 8) { + Text(section.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(section.rows) { row in + ProviderMiniMaxRowInlineView( + row: row, + progressColor: self.progressColor, + labelWidth: self.labelWidth) + } + } + } + } + .padding(.top, 4) + } +} + +private struct ProviderMiniMaxRowInlineView: View { + let row: UsageMenuCardView.Model.MiniMaxRow + let progressColor: Color + let labelWidth: CGFloat + + private static var titleWidthCap: CGFloat { + MiniMaxUILayoutMetrics.settingsTitleWidthCap() + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Text(self.row.title) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .help(self.row.title) + .frame(width: Self.titleWidthCap, alignment: .leading) + + VStack(alignment: .leading, spacing: 4) { + if let detail = self.row.detailText, !detail.isEmpty { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + UsageProgressBar( + percent: self.row.percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + Spacer(minLength: 8) + if let resetText = self.row.resetText, !resetText.isEmpty { + Text(resetText) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if let secondary = self.row.secondaryLine, !secondary.isEmpty { + Text(secondary) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 55ff40b7a..7b5bc86d1 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -66,9 +66,9 @@ extension StatusItemController { if didRefresh { self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) - // Heights are already set during populateMenu, no need to remeasure } self.openMenus[ObjectIdentifier(menu)] = menu + self.refreshMenuCardHeights(in: menu) // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -357,7 +357,10 @@ extension StatusItemController { for (index, row) in rows.enumerated() { let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let item = self.makeMenuCardItem( - OverviewMenuCardRowView(model: row.model, width: menuWidth), + OverviewMenuCardRowView( + model: row.model, + width: menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), id: identifier, width: menuWidth, onClick: { [weak self, weak menu] in @@ -403,14 +406,20 @@ extension StatusItemController { } if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), id: "menuCard", width: context.menuWidth)) menu.addItem(.separator()) } else { for (index, model) in cards.enumerated() { menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), id: "menuCard-\(index)", width: context.menuWidth)) if index < cards.count - 1 { @@ -441,7 +450,10 @@ extension StatusItemController { } menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), id: "menuCard", width: context.menuWidth)) if context.openAIContext.canShowBuyCredits { @@ -803,7 +815,7 @@ extension StatusItemController { let provider = self.menuProvider(for: menu) self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) - // Heights are already set during populateMenu, no need to remeasure + self.refreshMenuCardHeights(in: menu) } } } @@ -842,6 +854,7 @@ extension StatusItemController { guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) + self.refreshMenuCardHeights(in: menu) self.applyIcon(phase: nil) #if DEBUG self._test_openMenuRebuildObserver?(menu) @@ -904,18 +917,13 @@ extension StatusItemController { } private func refreshMenuCardHeights(in menu: NSMenu) { - // Re-measure the menu card height right before display to avoid stale/incorrect sizing when content - // changes (e.g. dashboard error lines causing wrapping). - let cardItems = menu.items.filter { item in - (item.representedObject as? String)?.hasPrefix("menuCard") == true - } - for item in cardItems { - guard let view = item.view else { continue } - let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) - let height = self.menuCardHeight(for: view, width: width) - view.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: height)) + let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) + for item in menu.items { + let isOverviewRow = (item.representedObject as? String)?.hasPrefix(Self.overviewRowIdentifierPrefix) == true + let isMenuCard = (item.representedObject as? String)?.hasPrefix("menuCard") == true + guard isOverviewRow || isMenuCard, let view = item.view else { continue } + let height = self.remeasuredMenuCardHeight(for: view, width: width) + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) } } @@ -1282,6 +1290,26 @@ extension StatusItemController { } } + private func remeasuredMenuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { + let basePadding: CGFloat = 6 + let descenderSafety: CGFloat = 1 + + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + view.layoutSubtreeIfNeeded() + let fitted = view.fittingSize + return max(1, ceil(fitted.height + basePadding + descenderSafety)) + } + + private func makeMiniMaxLayoutRefreshAction(for menu: NSMenu) -> () -> Void { + { [weak self, weak menu] in + Task { @MainActor [weak self, weak menu] in + guard let self, let menu else { return } + await Task.yield() + self.rebuildOpenMenuIfStillVisible(menu, provider: self.menuProvider(for: menu)) + } + } + } + func menuCardModel( for provider: UsageProvider?, snapshotOverride: UsageSnapshot? = nil, diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 3e8c6c3cc..e56199801 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -16,6 +16,13 @@ extension ProviderSwitcherSelection { struct OverviewMenuCardRowView: View { let model: UsageMenuCardView.Model let width: CGFloat + let onMiniMaxLayoutChange: (() -> Void)? + + init(model: UsageMenuCardView.Model, width: CGFloat, onMiniMaxLayoutChange: (() -> Void)? = nil) { + self.model = model + self.width = width + self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -28,14 +35,16 @@ struct OverviewMenuCardRowView: View { model: self.model, showBottomDivider: false, bottomPadding: 6, - width: self.width) + width: self.width, + onMiniMaxLayoutChange: self.onMiniMaxLayoutChange) } } .frame(width: self.width, alignment: .leading) } private var hasUsageBlock: Bool { - !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil + !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil || + (self.model.minimaxSections?.isEmpty == false) } } diff --git a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift new file mode 100644 index 000000000..3b87c284a --- /dev/null +++ b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift @@ -0,0 +1,128 @@ +import CodexBarCore +import Foundation +import SwiftUI + +extension UsageMenuCardView.Model { + static func miniMaxSections(input: Input) -> [MiniMaxSection]? { + guard input.provider == .minimax, + let models = input.snapshot?.minimaxUsage?.models, + !models.isEmpty + else { + return nil + } + let hasWeeklyDetail = models.contains { $0.weeklyTotal != nil || $0.weeklyRemaining != nil } + guard models.count > 1 || hasWeeklyDetail else { + return nil + } + + let fiveHour = models.filter { if case .fiveHour = $0.window { return true }; return false } + let daily = models.filter { if case .daily = $0.window { return true }; return false } + let weeklyOnly = models.filter { if case .weekly = $0.window { return true }; return false } + let other = models.filter { if case .other = $0.window { return true }; return false } + + var sections: [MiniMaxSection] = [] + if !fiveHour.isEmpty { + sections.append(MiniMaxSection( + title: "5-hour window", + rows: fiveHour.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !daily.isEmpty { + sections.append(MiniMaxSection( + title: "Daily quota", + rows: daily.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !weeklyOnly.isEmpty { + sections.append(MiniMaxSection( + title: "Weekly quota", + rows: weeklyOnly.map { Self.miniMaxRow(model: $0, input: input) })) + } + if !other.isEmpty { + sections.append(MiniMaxSection( + title: "Other windows", + rows: other.map { Self.miniMaxRow(model: $0, input: input) })) + } + return sections.isEmpty ? nil : sections + } + + static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow { + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left + let used = model.usedPercent ?? 0 + let barPercent = percentStyle == .used ? used : (100 - used) + let resetText: String? = if let at = model.resetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: used, + windowMinutes: model.windowMinutes, + resetsAt: at, + resetDescription: nil), + style: input.resetTimeDisplayStyle, + now: input.now) + } else { + nil + } + let detailText = Self.miniMaxDetailLine(model: model) + let secondaryLine = Self.miniMaxWeeklySecondaryLine(model: model, input: input) + return MiniMaxRow( + id: model.identifier, + title: model.displayName, + percent: Self.clamped(barPercent), + percentStyle: percentStyle, + resetText: resetText, + detailText: detailText, + secondaryLine: secondaryLine) + } + + static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { + guard let total = model.availablePrompts else { return nil } + let used = model.currentPrompts ?? max(0, total - (model.remainingPrompts ?? 0)) + let remaining = model.remainingPrompts + // 区间额度占位 0/0 时不展示误导性用量(与周限 0/0 同类问题)。 + if total == 0, used == 0, remaining == nil || remaining == 0 { + return nil + } + let usedStr = UsageFormatter.tokenCountString(used) + let totalStr = UsageFormatter.tokenCountString(total) + if let remaining { + let remStr = UsageFormatter.tokenCountString(remaining) + return "\(usedStr)/\(totalStr) (\(remStr) remaining)" + } + return "\(usedStr)/\(totalStr)" + } + + static func miniMaxWeeklySecondaryLine(model: MiniMaxModelUsage, input: Input) -> String? { + guard model.weeklyTotal != nil || model.weeklyRemaining != nil else { return nil } + // 与解析层一致:任一侧为 0、另一侧缺省时按 0 计;全零即无周限,不展示误导性周限行。 + if (model.weeklyTotal ?? 0) == 0, (model.weeklyRemaining ?? 0) == 0 { return nil } + let total = model.weeklyTotal + let used = model.weeklyUsed + let remaining = model.weeklyRemaining + let usedStr = used.map { UsageFormatter.tokenCountString($0) } ?? "—" + let totalStr = total.map { UsageFormatter.tokenCountString($0) } ?? "—" + let pctStr = if let p = model.weeklyUsedPercent { + String(format: "%.1f%%", p) + } else { + "—" + } + let weeklyReset: String? = if let at = model.weeklyResetsAt { + UsageFormatter.resetLine( + for: RateWindow( + usedPercent: model.weeklyUsedPercent ?? 0, + windowMinutes: 7 * 24 * 60, + resetsAt: at, + resetDescription: nil), + style: input.resetTimeDisplayStyle, + now: input.now) + } else { + nil + } + let remStr = remaining.map { UsageFormatter.tokenCountString($0) } + var line = "↳ Weekly \(usedStr)/\(totalStr) (\(pctStr) used)" + if let remStr { + line += " · \(remStr) remaining" + } + if let weeklyReset { + line += " · \(weeklyReset)" + } + return line + } +} diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 359029d91..3d3d50192 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -897,12 +897,10 @@ extension LocalhostSessionDelegate: URLSessionDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + completionHandler(result.disposition, result.credential) } } @@ -911,12 +909,10 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let result = self.challengeResult(challenge) - Task { @MainActor in - completionHandler(result.disposition, result.credential) - } + completionHandler(result.disposition, result.credential) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 62a91c4c1..b7fe788d8 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -418,7 +418,7 @@ struct MiniMaxModelRemains: Decodable { case modelId = "model_id" case modelTitle = "model_title" case displayName = "name" - case title = "title" + case title } init(from decoder: Decoder) throws { @@ -598,8 +598,15 @@ enum MiniMaxUsageParser { nil } - let weeklyTotal = row.currentWeeklyTotalCount - let weeklyRemaining = row.currentWeeklyUsageCount + // API 在无周限套餐上可能返回周限占位 0(仅 total、仅 remaining、或两者均为 0)。双 nil 表示未提供周限字段,不归一化。 + let rawWeeklyTotal = row.currentWeeklyTotalCount + let rawWeeklyRemaining = row.currentWeeklyUsageCount + let hasAnyWeeklyField = rawWeeklyTotal != nil || rawWeeklyRemaining != nil + let noWeeklyCap = hasAnyWeeklyField + && (rawWeeklyTotal ?? 0) == 0 + && (rawWeeklyRemaining ?? 0) == 0 + let weeklyTotal: Int? = noWeeklyCap ? nil : rawWeeklyTotal + let weeklyRemaining: Int? = noWeeklyCap ? nil : rawWeeklyRemaining let weeklyUsed: Int? = if let weeklyTotal, let weeklyRemaining { max(0, weeklyTotal - weeklyRemaining) } else { @@ -607,7 +614,11 @@ enum MiniMaxUsageParser { } let weeklyUsedPercent = self.usedPercent(total: weeklyTotal, remaining: weeklyRemaining) let weeklyEndDate = self.dateFromEpoch(row.weeklyEndTime) - let weeklyResetsAt = self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) + let weeklyResetsAt: Date? = if noWeeklyCap { + nil + } else { + self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) + } let identifier = self.modelIdentifier(row: row, index: index) let displayName = self.modelDisplayName(row: row, identifier: identifier) diff --git a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift index 28a5cbde6..59faa0e02 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift @@ -137,7 +137,99 @@ struct MiniMaxMenuCardTests { let sections = try #require(model.minimaxSections) #expect(sections.count == 1) let row = try #require(sections.first?.rows.first) - #expect(row.secondaryLine?.contains("Weekly") == true) - #expect(row.secondaryLine?.contains("70646") == true) + let weekly = try #require(row.secondaryLine) + #expect(weekly.contains("Weekly")) + #expect(weekly.contains("91.7")) + // Remaining uses UsageFormatter.tokenCountString (e.g. 70646 → "71K"), not raw digits. + #expect(weekly.contains("remaining")) + } + + @Test + func `minimax hides weekly secondary line when weekly quota is zero zero`() throws { + let now = Date() + let models: [MiniMaxModelUsage] = [ + MiniMaxModelUsage( + identifier: "coding", + displayName: "Coding", + availablePrompts: 1000, + currentPrompts: 100, + remainingPrompts: 900, + windowMinutes: 300, + usedPercent: 10, + resetsAt: nil, + weeklyTotal: 0, + weeklyUsed: 0, + weeklyRemaining: 0, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .fiveHour), + ] + let minimax = MiniMaxUsageSnapshot( + planName: "Plan", + availablePrompts: 1000, + currentPrompts: 100, + remainingPrompts: 900, + windowMinutes: 300, + usedPercent: 10, + resetsAt: nil, + updatedAt: now, + models: models) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let sections = try #require(model.minimaxSections) + let row = try #require(sections.first?.rows.first) + #expect(row.secondaryLine == nil) + } + + @Test @MainActor + func `collapse store defaults collapsed when row count at least five`() { + let store = MiniMaxSectionCollapseStore.shared + store.resetOverridesForTesting() + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: 10)) + #expect(!store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold - 1)) + } + + @Test @MainActor + func `collapse store toggle persists until reset`() { + let store = MiniMaxSectionCollapseStore.shared + store.resetOverridesForTesting() + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + store.toggle(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold) + #expect(!store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + store.toggle(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold) + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + store.resetOverridesForTesting() + #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)) + } + + @Test @MainActor + func `collapse store user override beats default for small sections`() { + let store = MiniMaxSectionCollapseStore.shared + store.resetOverridesForTesting() + #expect(!store.isCollapsed(sectionTitle: "Other windows", rowCount: 2)) + store.toggle(sectionTitle: "Other windows", rowCount: 2) + #expect(store.isCollapsed(sectionTitle: "Other windows", rowCount: 2)) } } diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 5cb6e31e0..96e189bdd 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -216,6 +216,71 @@ struct MiniMaxUsageParserTests { #expect(speech?.weeklyUsed == 6354) } + @Test + func `parses weekly zero zero as no weekly cap`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Unlimited Weekly", + "model_remains": [ + { + "model_name": "coding-model", + "current_interval_total_count": 1000, + "current_interval_usage_count": 500, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_end_time": \(start + 7 * 24 * 60 * 60 * 1000), + "weekly_remains_time": 3600000, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let row = try #require(snapshot.models.first { $0.identifier == "coding-model" }) + #expect(row.weeklyTotal == nil) + #expect(row.weeklyRemaining == nil) + #expect(row.weeklyUsed == nil) + #expect(row.weeklyUsedPercent == nil) + #expect(row.weeklyResetsAt == nil) + } + + @Test + func `parses weekly total zero with missing remaining as no weekly cap`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Plan", + "model_remains": [ + { + "model_name": "m1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 500, + "current_weekly_total_count": 0, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let row = try #require(snapshot.models.first) + #expect(row.weeklyTotal == nil) + #expect(row.weeklyRemaining == nil) + #expect(row.weeklyResetsAt == nil) + } + @Test func `parses coding plan from next data`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift b/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift new file mode 100644 index 000000000..d9ad47fa3 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import CodexBar + +struct MiniMaxUILayoutMetricsTests { + @Test + func `preferred menu usage height uses content height when under cap`() { + let height = MiniMaxUILayoutMetrics.preferredMenuUsageHeight( + contentHeight: 180, + visibleScreenHeight: 1000) + #expect(height == 180) + } + + @Test + func `preferred menu usage height clamps to cap when content is taller`() { + let cap = MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: 900) + let height = MiniMaxUILayoutMetrics.preferredMenuUsageHeight( + contentHeight: cap + 240, + visibleScreenHeight: 900) + #expect(height == cap) + } + + @Test + func `menu usage height falls back when screen height unavailable`() { + #expect( + MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: nil) == + MiniMaxUILayoutMetrics.menuScrollFallbackHeight) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 13c31e625..fe2a9c537 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -56,7 +56,12 @@ struct OpenAIDashboardNavigationDelegateTests { box.delegate?.webView(webView, didCommit: nil) #expect(result == nil) - try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) + // postCommitTask 在 sleep 结束后仍要在 MainActor 上执行 completeOnce;全量并行测试时 + // MainActor 积压会导致固定短 sleep 竞态失败,故轮询直到结果就绪或超时。 + let deadline = Date().addingTimeInterval(5.0) + while result == nil, Date() < deadline { + try? await Task.sleep(nanoseconds: 20_000_000) + } box.delegate = nil switch result { diff --git a/docs/minimax.md b/docs/minimax.md index 58d62309c..0fd8f6cf3 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -1,10 +1,10 @@ --- -summary: "MiniMax provider data sources: API token or browser cookies + coding plan remains API." + +## summary: "MiniMax provider data sources: API token or browser cookies + coding plan remains API." read_when: - Debugging MiniMax usage parsing - Updating MiniMax cookie handling or coding plan scraping - Adjusting MiniMax provider UI/menu behavior ---- # MiniMax provider @@ -13,33 +13,30 @@ either a Bearer API token or a session cookie header. ## Data sources + fallback order -1) **API token** (preferred) - - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`) or `MINIMAX_API_KEY`. - - When present, MiniMax uses the API token and ignores cookies entirely. - -2) **Cached cookie header** (automatic, only when no API token) - - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.minimax`). - -3) **Browser cookie import** (automatic) - - Cookie order from provider metadata (default: Safari → Chrome → Firefox). - - Merges Chromium profile cookies across the primary + Network stores before attempting a request. - - Tries each browser source until the Coding Plan API accepts the cookies. - - Domain filters: `platform.minimax.io`, `minimax.io`. - -4) **Browser local storage access token** (Chromium-based) - - Reads `access_token` (and related tokens) from Chromium local storage (LevelDB) to authorize the remains API. - - If decoding fails, falls back to a text-entry scan for `minimax.io` keys/values and filters for MiniMax JWT claims. - - Used automatically; no UI field. - - Also extracts `GroupId` when present (appends query param). - -5) **Manual session cookie header** (optional override) - - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual). - - Accepts a raw `Cookie:` header or a full "Copy as cURL" string. - - When a cURL string is pasted, MiniMax extracts the cookie header plus `Authorization: Bearer …` and - `GroupId=…` for the remains API. - - CLI/runtime env: `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. +1. **API token** (preferred) + - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`) or `MINIMAX_API_KEY`. + - When present, MiniMax uses the API token and ignores cookies entirely. +2. **Cached cookie header** (automatic, only when no API token) + - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.minimax`). +3. **Browser cookie import** (automatic) + - Cookie order from provider metadata (default: Safari → Chrome → Firefox). + - Merges Chromium profile cookies across the primary + Network stores before attempting a request. + - Tries each browser source until the Coding Plan API accepts the cookies. + - Domain filters: `platform.minimax.io`, `minimax.io`. +4. **Browser local storage access token** (Chromium-based) + - Reads `access_token` (and related tokens) from Chromium local storage (LevelDB) to authorize the remains API. + - If decoding fails, falls back to a text-entry scan for `minimax.io` keys/values and filters for MiniMax JWT claims. + - Used automatically; no UI field. + - Also extracts `GroupId` when present (appends query param). +5. **Manual session cookie header** (optional override) + - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual). + - Accepts a raw `Cookie:` header or a full "Copy as cURL" string. + - When a cURL string is pasted, MiniMax extracts the cookie header plus `Authorization: Bearer …` and + `GroupId=…` for the remains API. + - CLI/runtime env: `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. ## Endpoints + - API token endpoint: `https://api.minimax.io/v1/coding_plan/remains` - Requires `Authorization: Bearer `. - Global host (cookies): `https://platform.minimax.io` @@ -57,37 +54,64 @@ either a Bearer API token or a session cookie header. - `MINIMAX_REMAINS_URL=...` (full URL override) ## Cookie capture (optional override) + - Open the Coding Plan page and DevTools → Network. - Select the request to `/v1/api/openplatform/coding_plan/remains`. - Copy the `Cookie` request header (or use “Copy as cURL” and paste the whole line). - Paste into Preferences → Providers → MiniMax only if automatic import fails. ## Notes + - Cookies alone often return status 1004 (“cookie is missing, log in again”); the remains API expects a Bearer token. - MiniMax stores `access_token` in Chromium local storage (LevelDB). Some entries serialize the storage key without a scheme - (ex: `minimax.io`), so origin matching must account for host-only keys. +(ex: `minimax.io`), so origin matching must account for host-only keys. - Raw JWT scan fallback remains as a safety net if Chromium key formats change. - If local storage keys don’t decode (some Chrome builds), the MiniMax-specific text scan avoids a full raw-byte scan. ## Cookie file paths + - Safari: `~/Library/Cookies/Cookies.binarycookies` - Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` - Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite` ## Snapshot mapping + - Primary: percent used from `model_remains` (used/total) or HTML "Available usage". - Window: derived from `start_time`/`end_time` or HTML duration text. - Reset: derived from `remains_time` (fallback to `end_time`) or HTML "Resets in …". - Plan/tier: best-effort from response fields or HTML title. ### Coding Plan multi-model (`model_remains[]`) + - The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. - Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). - Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. +- When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.). - Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**. +### Providers settings mirror (Preferences → Providers → MiniMax) + +- The Providers detail **Usage** section mirrors the same `model_remains[]` grouping as the menu card (**5-hour**, **Daily**, **Weekly**, **Other**), with the same per-row progress bar, `used/total (remaining)` detail line, reset text, and optional weekly secondary line. +- Preferences does **not** reuse the menu card’s collapsible section headers; if the combined row count is **≥ 6**, the block is wrapped in an embedded `ScrollView` (max height ≈ 360 pt) so the window stays manageable. +- MiniMax row titles in Preferences use a **separate fixed-width title column** instead of the global usage label width. The width is the rendered width of `code-plan-search`; longer model names **wrap onto multiple lines inside that fixed column** instead of tail-truncating, so the progress/detail column keeps a stable width without hiding the full name. + +### Menu-bar card layout (MiniMax-only) + +- When `minimaxSections` is present, the card wraps **metrics + usage notes + multi-model sections** in an internal vertical `ScrollView`. The scroll region first **measures the rendered content height** and then applies an explicit frame height of `min(actualContentHeight, min(640, max(320, NSScreen.main.visibleFrame.height − 310)))`. This means the card **shrinks to fit** when collapsed/short and **scrolls only when content exceeds the cap**. The **header** (provider name / account / plan) stays **above** this scroll region so account context remains visible while scrolling. +- Each grouped section (**5-hour window**, **Daily quota**, **Weekly quota**, **Other windows**) has a tappable header with a chevron. **Collapsed** headers show **`N items`** on the right. Default: **collapsed** when that section has **≥ 5** rows; **expanded** otherwise. The user’s toggle is stored in-process in `MiniMaxSectionCollapseStore` (keyed by section title); it resets on app quit. +- Toggling a section invalidates and remeasures the hosting `NSMenuItem` view while the menu is open, so the MiniMax card **shrinks immediately when collapsing** and **grows immediately when expanding** instead of keeping the initial height. +- This layout keeps the total `NSMenu` height bounded so app-level items below the card (e.g. Usage Dashboard, Refresh, Settings) remain reachable without relying on the menu’s own overflow chevrons. + ## Key files + - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` +- `Sources/CodexBar/MiniMaxUILayoutMetrics.swift` +- `Sources/CodexBar/MiniMaxSectionCollapseStore.swift` +- `Sources/CodexBar/MiniMaxMenuCardViews.swift` (分组折叠 + 行视图) +- `Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift` (`model_remains[]` → 菜单模型) +- `Sources/CodexBar/MenuCardView.swift`(MiniMax 卡片区滚动) +- `Sources/CodexBar/PreferencesProviderDetailView.swift` (Providers → MiniMax usage mirror) + From 26d70442138eaa47085e1383070a702c41cf06e5 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 17:29:46 +0800 Subject: [PATCH 28/64] Fix post-merge test isolation and MenuCardView file length - AppGroupSupportTests: override snapshot URLs with temp paths so migration tests do not touch real Group Containers.\n- Move ClickToCopy overlay types to MenuCardClickToCopy.swift to satisfy SwiftLint file_length. Made-with: Cursor --- Sources/CodexBar/MenuCardClickToCopy.swift | 42 +++++++++++++++++++ Sources/CodexBar/MenuCardView.swift | 40 ------------------ .../CodexBarTests/AppGroupSupportTests.swift | 13 +++++- 3 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 Sources/CodexBar/MenuCardClickToCopy.swift diff --git a/Sources/CodexBar/MenuCardClickToCopy.swift b/Sources/CodexBar/MenuCardClickToCopy.swift new file mode 100644 index 000000000..73451d12e --- /dev/null +++ b/Sources/CodexBar/MenuCardClickToCopy.swift @@ -0,0 +1,42 @@ +import AppKit +import SwiftUI + +// MARK: - Copy-on-click overlay + +struct ClickToCopyOverlay: NSViewRepresentable { + let copyText: String + + func makeNSView(context: Context) -> ClickToCopyView { + ClickToCopyView(copyText: self.copyText) + } + + func updateNSView(_ nsView: ClickToCopyView, context: Context) { + nsView.copyText = self.copyText + } +} + +final class ClickToCopyView: NSView { + var copyText: String + + init(copyText: String) { + self.copyText = copyText + super.init(frame: .zero) + self.wantsLayer = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func mouseDown(with event: NSEvent) { + _ = event + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(self.copyText, forType: .string) + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 122d4c12b..89b1e8692 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1599,43 +1599,3 @@ extension UsageMenuCardView.Model { UsageFormatter.resetLine(for: window, style: style, now: now) } } - -// MARK: - Copy-on-click overlay - -private struct ClickToCopyOverlay: NSViewRepresentable { - let copyText: String - - func makeNSView(context: Context) -> ClickToCopyView { - ClickToCopyView(copyText: self.copyText) - } - - func updateNSView(_ nsView: ClickToCopyView, context: Context) { - nsView.copyText = self.copyText - } -} - -private final class ClickToCopyView: NSView { - var copyText: String - - init(copyText: String) { - self.copyText = copyText - super.init(frame: .zero) - self.wantsLayer = false - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func mouseDown(with event: NSEvent) { - _ = event - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) - } -} diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift index ba55d2acd..de5b1e8d6 100644 --- a/Tests/CodexBarTests/AppGroupSupportTests.swift +++ b/Tests/CodexBarTests/AppGroupSupportTests.swift @@ -91,6 +91,15 @@ struct AppGroupSupportTests { @Test func `legacy migration preserves existing target shared defaults`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let currentSnapshotURL = root.appendingPathComponent("current/widget-snapshot.json", isDirectory: false) + let legacySnapshotURL = root.appendingPathComponent("legacy/widget-snapshot.json", isDirectory: false) + // 隔离快照路径,避免在未覆盖时使用本机 Group Container 中的真实文件而误判为已迁移。 + let standardSuite = "AppGroupSupportTests-standard-existing-\(UUID().uuidString)" let currentSuite = "AppGroupSupportTests-current-existing-\(UUID().uuidString)" let legacySuite = "AppGroupSupportTests-legacy-existing-\(UUID().uuidString)" @@ -111,7 +120,9 @@ struct AppGroupSupportTests { bundleID: "com.steipete.codexbar", standardDefaults: standardDefaults, currentDefaultsOverride: currentDefaults, - legacyDefaultsOverride: legacyDefaults) + legacyDefaultsOverride: legacyDefaults, + currentSnapshotURLOverride: currentSnapshotURL, + legacySnapshotURLOverride: legacySnapshotURL) #expect(result.status == .noChangesNeeded) #expect(result.copiedDefaults == 0) From f952a432125d9e7cb40b8aa6ea8a406396b98697 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 18:07:09 +0800 Subject: [PATCH 29/64] Fix MiniMax detail line when interval usage count is omitted Avoid treating missing remaining as zero (full usage). Show em dash over total when neither current nor remaining is available. Adds XCTest coverage. Addresses Codex review on PR #742. Made-with: Cursor --- .../UsageMenuCardViewModel+MiniMax.swift | 21 +++++++-- .../CodexBarTests/MiniMaxMenuCardTests.swift | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift index 3b87c284a..0927e02f0 100644 --- a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift +++ b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift @@ -74,14 +74,29 @@ extension UsageMenuCardView.Model { static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? { guard let total = model.availablePrompts else { return nil } - let used = model.currentPrompts ?? max(0, total - (model.remainingPrompts ?? 0)) + + // 与 MiniMaxUsageFetcher 一致:仅当同时有 total+remaining(或解析出的 current)时才推导已用量; + // remaining 缺省时不得假定为 0,否则会显示成「用尽」且与省略的 current_interval_usage_count 矛盾。 + let used: Int? = if let current = model.currentPrompts { + current + } else if let remaining = model.remainingPrompts { + max(0, total - remaining) + } else { + nil + } + let remaining = model.remainingPrompts // 区间额度占位 0/0 时不展示误导性用量(与周限 0/0 同类问题)。 - if total == 0, used == 0, remaining == nil || remaining == 0 { + if total == 0, (used ?? 0) == 0, remaining == nil || remaining == 0 { return nil } - let usedStr = UsageFormatter.tokenCountString(used) + let totalStr = UsageFormatter.tokenCountString(total) + guard let used else { + return "—/\(totalStr)" + } + + let usedStr = UsageFormatter.tokenCountString(used) if let remaining { let remStr = UsageFormatter.tokenCountString(remaining) return "\(usedStr)/\(totalStr) (\(remStr) remaining)" diff --git a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift index 59faa0e02..dd7d2e7fb 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift @@ -232,4 +232,50 @@ struct MiniMaxMenuCardTests { store.toggle(sectionTitle: "Other windows", rowCount: 2) #expect(store.isCollapsed(sectionTitle: "Other windows", rowCount: 2)) } + + @Test + func `minimax detail line does not infer full usage when interval usage count missing`() { + let row = MiniMaxModelUsage( + identifier: "m", + displayName: "M", + availablePrompts: 1000, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: 300, + usedPercent: 12.0, + resetsAt: nil, + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .fiveHour) + let line = UsageMenuCardView.Model.miniMaxDetailLine(model: row) + let totalStr = UsageFormatter.tokenCountString(1000) + #expect(line == "—/\(totalStr)") + } + + @Test + func `minimax detail line derives used from remaining when current omitted`() { + let row = MiniMaxModelUsage( + identifier: "m", + displayName: "M", + availablePrompts: 1000, + currentPrompts: nil, + remainingPrompts: 250, + windowMinutes: 300, + usedPercent: 75.0, + resetsAt: nil, + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + weeklyUsedPercent: nil, + weeklyResetsAt: nil, + window: .fiveHour) + let line = UsageMenuCardView.Model.miniMaxDetailLine(model: row) + let usedStr = UsageFormatter.tokenCountString(750) + let totalStr = UsageFormatter.tokenCountString(1000) + let remStr = UsageFormatter.tokenCountString(250) + #expect(line == "\(usedStr)/\(totalStr) (\(remStr) remaining)") + } } From 32fc946c9d309a65af092f181c2d86aeedef20a1 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sun, 19 Apr 2026 18:43:42 +0800 Subject: [PATCH 30/64] Stop tracking Cursor hook state and SpecStory local files Remove .cursor/hooks/state and .specstory from the repo tree; add gitignore rules so IDE-local data is not committed again. Made-with: Cursor --- .cursor/hooks/state/continual-learning.json | 8 - .gitignore | 4 + .specstory/.project.json | 7 - .specstory/cli/config.toml | 79 ----- ...19_04-53-42Z-cursor-branch-code-testing.md | 324 ------------------ .specstory/statistics.json | 13 - 6 files changed, 4 insertions(+), 431 deletions(-) delete mode 100644 .cursor/hooks/state/continual-learning.json delete mode 100644 .specstory/.project.json delete mode 100644 .specstory/cli/config.toml delete mode 100644 .specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md delete mode 100644 .specstory/statistics.json diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json deleted file mode 100644 index 8a91eee07..000000000 --- a/.cursor/hooks/state/continual-learning.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": 1, - "lastRunAtMs": 0, - "turnsSinceLastRun": 2, - "lastTranscriptMtimeMs": null, - "lastProcessedGenerationId": "d1ca6c4d-0a87-4bff-b994-8f4b64215c5c", - "trialStartedAtMs": null -} diff --git a/.gitignore b/.gitignore index d44f986f6..0de0767b5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,10 @@ debug_*.swift # Misc .DS_Store .vscode/ + +# Cursor / SpecStory local state (never commit) +.cursor/hooks/state/ +.specstory/ .codex/environments/ .swiftpm-cache/ diff --git a/.specstory/.project.json b/.specstory/.project.json deleted file mode 100644 index 503fb60d0..000000000 --- a/.specstory/.project.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "workspace_id": "a1bf-ca39-9a6f-0f1e", - "workspace_id_at": "2026-04-19T06:04:20Z", - "git_id": "23d9-7492-e24f-7e50", - "git_id_at": "2026-04-19T06:04:20Z", - "project_name": "CodexBar" -} \ No newline at end of file diff --git a/.specstory/cli/config.toml b/.specstory/cli/config.toml deleted file mode 100644 index 9336aae89..000000000 --- a/.specstory/cli/config.toml +++ /dev/null @@ -1,79 +0,0 @@ -# SpecStory CLI Configuration -# -# This is the project-level config file for SpecStory CLI. -# All settings here apply to this project unless overridden by CLI flags. -# -# Uncomment (remove the #) the line and edit any setting below to change the default behavior. -# For more information, see: https://docs.specstory.com/integrations/terminal-coding-agents/usage - -[local_sync] -# Write markdown files locally. (default: true) -# enabled = false # equivalent to --only-cloud-sync - -# Custom output directory for markdown files. -# Default: ./.specstory/history (relative to the project directory) -# output_dir = "~/.specstory/history" # equivalent to --output-dir "~/.specstory/history" - -# Use local timezone for file name and content timestamps (default: false, UTC) -# local_time_zone = true # equivalent to --local-time-zone - -[cloud_sync] -# Sync session data to SpecStory Cloud. (default: true, when logged in to SpecStory Cloud) -# enabled = false # equivalent to --no-cloud-sync - -[logging] -# Write logs to .specstory/debug/debug.log (default: false) -# log = true # equivalent to --log - -# Debug-level output, requires console or log (default: false) -# debug = true # equivalent to --debug - -# Custom output directory for debug data. -# Default: ./.specstory/debug (relative to the project directory) -# debug_dir = "~/.specstory/debug" # equivalent to --debug-dir "~/.specstory/debug" - -# Error/warn/info output to stdout (default: false) -# console = true # equivalent to --console - -# Suppress all non-error output (default: false) -# silent = true # equivalent to --silent - -[version_check] -# Check for new versions of the CLI on startup. -# Default: true -# enabled = false # equivalent to --no-version-check - -[analytics] -# Send anonymous product usage analytics to help improve SpecStory. -# Default: true -# enabled = false # equivalent to --no-usage-analytics - -[telemetry] -# OTLP gRPC collector endpoint (e.g., "localhost:4317" or "http://localhost:4317") -# endpoint = "localhost:4317" - -# Override the default service name (default: "specstory-cli") -# service_name = "my-service-name" - -# Include user prompt text in telemetry spans (default: true) -# prompts = false - -[providers] -# Agent execution commands by provider (used by specstory run) -# Pass custom flags (e.g. claude_cmd = "claude --allow-dangerously-skip-permissions") -# Use of these is equivalent to -c "custom command" - -# Claude Code command -# claude_cmd = "claude" - -# Codex CLI command -# codex_cmd = "codex" - -# Cursor CLI command -# cursor_cmd = "cursor-agent" - -# Droid CLI command -# droid_cmd = "droid" - -# Gemini CLI command -# gemini_cmd = "gemini" diff --git a/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md b/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md deleted file mode 100644 index 021426232..000000000 --- a/.specstory/history/2026-04-19_04-53-42Z-cursor-branch-code-testing.md +++ /dev/null @@ -1,324 +0,0 @@ - - -# 2026-04-19 04:53:42Z - - - -_**User**_ - -测试cursor分支的代码,应该如何做 - ---- - -_**Agent (default)**_ - - -正在查看项目结构与测试配置,以便说明如何测试 `cursor` 分支的代码。 - - - -_**Agent (default)**_ - - -
Tool use: **read_file_v2** • Read file: /Users/gc/Dev/Opensource/CodexBar/AGENTS.md - -
-
- -
-Tool use: **read_file_v2** - -
- -_**Agent (default)**_ - - -
-Tool use: **glob_file_search** • Searched codebase "" • **1** result in **1** directory - -Directory: **/Users/gc/Dev/Opensource/CodexBar** (1 file) - -| File | -|------| -| `package.json` | - -
-
- -
-Tool use: **glob_file_search** - -
- -_**Agent (default)**_ - - -
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git status 2>/dev/null; git branch -a 2>/dev/null | head -30 - -```bash -cd /Users/gc/Dev/Opensource/CodexBar && git status 2>/dev/null; git branch -a 2>/dev/null | head -30 -``` -
-
- -
-Tool use: **run_terminal_command_v2** - -
- -_**Agent (default)**_ - -
Thought Process -The git command failed with exit code 69, indicating git may not be installed or the current directory is not a Git repository.
- -_**Agent (default)**_ - - - - - - -_**Agent (default)**_ - - -
Tool use: **read_file_v2** • Read file: /Users/gc/Dev/Opensource/CodexBar/package.json - -
-
- -
-Tool use: **read_file_v2** - -
- -_**Agent (default)**_ - - -### 🧠 分析与说明 - -当前工作区可能**不是 Git 仓库**(本机 `git` 命令未返回分支信息)。若你要测的是**名为 `cursor` 的分支**,通用做法是:先切到该分支,再按本仓库约定跑构建与测试。 - -CodexBar 是 **SwiftPM + XCTest** 项目,`AGENTS.md` 和 `package.json` 里已经写清了推荐流程。 - ---- - -### 1. 切到 `cursor` 分支(在克隆的仓库里) - -在项目根目录执行: - -```bash -git fetch origin -git checkout cursor -# 若本地已有同名分支且需对齐远端: -# git checkout -b cursor origin/cursor -``` - -确认当前分支: - -```bash -git branch --show-current -git log -1 --oneline -``` - ---- - -### 2. 跑自动化测试(推荐顺序) - -| 目的 | 命令 | -|------|------| -| 只跑单元测试 | `pnpm test` 或 `swift test` | -| TTY 相关集成测试 | `pnpm run test:tty` | -| 需真实账号的 live 测试 | `pnpm run test:live`(会设 `LIVE_TEST=1`) | -| 格式/静态检查(提交前) | `pnpm check`(`AGENTS.md` 要求改代码后跑) | - ---- - -### 3. 完整开发闭环(构建 + 测试 + 打包 + 启动 App) - -与文档一致的首选: - -```bash -./Scripts/compile_and_run.sh -``` - -或: - -```bash -pnpm start -``` - -脚本会结束旧进程、`swift build`、`swift test`、打包并重新启动 `CodexBar.app`,适合验证**菜单栏应用**是否按预期工作。 - ---- - -### 4. 若本目录没有 Git / 没有 `cursor` 分支 - -需要先**克隆**带 `cursor` 分支的远程仓库,或让维护者提供该分支的提交/补丁,再在那一套代码上执行上面的 `swift test` / `compile_and_run.sh`。 - ---- - -**小结**:在 `cursor` 分支上,先 `git checkout cursor`,再 **`swift test`(或 `pnpm test`)** 做自动化验证;需要连 UI/真机行为时用 **`./Scripts/compile_and_run.sh`**;提交前跑 **`pnpm check`**。若你希望我根据你本机实际远程名(`origin` 或其它)写一条「从当前状态拉到 `cursor`」的准确命令,可以说一下仓库是 fork 还是主仓、以及 `git remote -v` 的输出。 - ---- - -_**User**_ - -我需要把cursor/minimax-multi-model-usage-ef73分支拉下来应该怎么做 - ---- - -_**Agent (default)**_ - -
Thought Process -To fetch the specified branch, use the command `git fetch origin cursor/minimax-multi-model-usage-ef73`. -If you need to switch to this branch, run `git checkout cursor/minimax-multi-model-usage-ef73`.
- -_**Agent (default)**_ - - -正在检查仓库的远程与分支情况,以便给出准确命令。 - - - -_**Agent (default)**_ - - -
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git rev-parse --is-inside-work-tree 2>/dev/null; git remote -v 2>/dev/null; git branch -a 2>/dev/null | head -40 - -```bash -cd /Users/gc/Dev/Opensource/CodexBar && git rev-parse --is-inside-work-tree 2>/dev/null; git remote -v 2>/dev/null; git branch -a 2>/dev/null | head -40 -``` - -``` -true -origin https://github.com/cskeleton/CodexBar.git (fetch) -origin https://github.com/cskeleton/CodexBar.git (push) -upstream https://github.com/steipete/CodexBar.git (fetch) -upstream https://github.com/steipete/CodexBar.git (push) -* main - remotes/origin/HEAD -> origin/main - remotes/origin/cursor/minimax-multi-model-usage-ef73 - remotes/origin/main - remotes/upstream/HEAD -> upstream/main - remotes/upstream/antigravity - remotes/upstream/ci/pin-lint-tools-and-format-baseline - remotes/upstream/claude-keychain-refresh-fix - remotes/upstream/co-pilot - remotes/upstream/codex/auto-keychain-prompts-user-action - remotes/upstream/codex/claude-auto-cli-before-web - remotes/upstream/codex/claude-keychain-policy-hard-enforcement - remotes/upstream/codex/claude-keychain-policy-ux - remotes/upstream/codex/claude-oauth-keychain-policy - remotes/upstream/codex/claude-oauth-security-cli-experimental - remotes/upstream/codex/fix-opencode-null-subscription-273 - remotes/upstream/codex/main-debug-reconcile-split - remotes/upstream/codex/menu-open-no-settings-write - remotes/upstream/codex/pr-275-scripts-only - remotes/upstream/codex/pr-286-audit - remotes/upstream/codex/pr-294-warp-provider - remotes/upstream/codex/pr-298-audit - remotes/upstream/codex/pr-310-audit - remotes/upstream/codex/pr-393-audit - remotes/upstream/codex/pr-397-audit - remotes/upstream/codex/pr-409-audit - remotes/upstream/codex/pr-438-audit - remotes/upstream/codex/pr-449-audit - remotes/upstream/codex/pr-453-audit - remotes/upstream/codex/pr-529-audit-20260409-152104 - remotes/upstream/codex/pr-565-audit - remotes/upstream/codex/pr-632-audit - remotes/upstream/codex/pr-673-audit - remotes/upstream/codex/provider-account-identity - remotes/upstream/codex/rat-185-multi-account-foundation - remotes/upstream/codex/rat-186 - remotes/upstream/codex/rat-187 - remotes/upstream/codex/rat-189-codex-parity-lock - remotes/upstream/cost/pr33-ccusage - remotes/upstream/cursor-fixes - -``` -
-
- -
-Tool use: **run_terminal_command_v2** - -
- -_**Agent (default)**_ - -
Thought Process -The remote branch exists as `remotes/origin/cursor/minimax-multi-model-usage-ef73`.
- -_**Agent (default)**_ - - -远程已有该分支:`origin/cursor/minimax-multi-model-usage-ef73`。正在拉取并检出: - - - -_**Agent (default)**_ - - -
Tool use: **run_terminal_command_v2** • Run command: cd /Users/gc/Dev/Opensource/CodexBar && git fetch origin cursor/minimax-multi-model-usage-ef73 && git checkout cursor/minimax-multi-model-usage-ef73 - -```bash -cd /Users/gc/Dev/Opensource/CodexBar && git fetch origin cursor/minimax-multi-model-usage-ef73 && git checkout cursor/minimax-multi-model-usage-ef73 -``` - -``` -From https://github.com/cskeleton/CodexBar - * branch cursor/minimax-multi-model-usage-ef73 -> FETCH_HEAD -Switched to a new branch 'cursor/minimax-multi-model-usage-ef73' -branch 'cursor/minimax-multi-model-usage-ef73' set up to track 'origin/cursor/minimax-multi-model-usage-ef73'. - -``` -
-
- -
-Tool use: **run_terminal_command_v2** - -
- -_**Agent (default)**_ - - -### 已在你的仓库里完成操作 - -当前目录已 **`fetch` 并 `checkout`** 到分支 `cursor/minimax-multi-model-usage-ef73`,且已设置跟踪 `origin/cursor/minimax-multi-model-usage-ef73`。 - ---- - -### 以后在别的机器 / 新克隆里可以自己执行 - -**已有克隆、只拉这条分支并切换:** - -```bash -cd /path/to/CodexBar -git fetch origin cursor/minimax-multi-model-usage-ef73 -git checkout cursor/minimax-multi-model-usage-ef73 -``` - -**若本地还没有这个分支,用跟踪分支显式创建(等价写法):** - -```bash -git fetch origin -git checkout -b cursor/minimax-multi-model-usage-ef73 origin/cursor/minimax-multi-model-usage-ef73 -``` - -**全新克隆并直接落在该分支:** - -```bash -git clone -b cursor/minimax-multi-model-usage-ef73 https://github.com/cskeleton/CodexBar.git -``` - ---- - -### 更新分支(之后别人又推了新提交) - -```bash -git pull origin cursor/minimax-multi-model-usage-ef73 -``` - -需要的话可以继续在该分支上跑 `swift test` 或 `./Scripts/compile_and_run.sh` 做验证。 diff --git a/.specstory/statistics.json b/.specstory/statistics.json deleted file mode 100644 index c91f97a4c..000000000 --- a/.specstory/statistics.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "sessions": { - "9b5175d8-f354-417b-b218-5c6d9ac572fc": { - "user_message_count": 2, - "agent_message_count": 15, - "start_timestamp": "2026-04-19T12:53:42+08:00", - "end_timestamp": "2026-04-19T12:53:42+08:00", - "markdown_size_bytes": 10037, - "provider": "cursoride", - "last_updated": "2026-04-19T06:06:33Z" - } - } -} From 2bea4f045b6590f98da0e464c147cb109d81b986 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 20 Apr 2026 00:43:42 +0530 Subject: [PATCH 31/64] Fix OpenAI web dashboard handoff --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 1 + ...OpenAIDashboardBrowserCookieImporter.swift | 66 ++-- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 44 ++- .../OpenAIDashboardWebViewCache.swift | 284 ++++++++++++++++-- .../Codex/CodexWebDashboardStrategy.swift | 48 ++- Tests/CodexBarTests/CLIWebFallbackTests.swift | 10 + ...enAIDashboardFetcherCreditsWaitTests.swift | 37 +++ .../OpenAIDashboardWebViewCacheTests.swift | 100 ++++++ 8 files changed, 518 insertions(+), 72 deletions(-) diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 08a15e4a6..53b6e2016 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -921,6 +921,7 @@ extension UsageStore { result = try await importer.importBestCookies( intoAccountEmail: normalizedTarget, allowAnyAccount: allowAnyAccount, + preferCachedCookieHeader: !force, cacheScope: cacheScope, logger: log) case .off: diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 200193f62..bf36e53cf 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -105,6 +105,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importBestCookies( intoAccountEmail targetEmail: String?, allowAnyAccount: Bool = false, + preferCachedCookieHeader: Bool = true, cacheScope: CookieHeaderCache.Scope? = nil, logger: ((String) -> Void)? = nil) async throws -> ImportResult { @@ -130,27 +131,31 @@ public struct OpenAIDashboardBrowserCookieImporter { var diagnostics = ImportDiagnostics() - if let cached = CookieHeaderCache.load(provider: .codex, scope: cacheScope), - !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - log("Using cached cookie header from \(cached.sourceLabel)") - do { - return try await self.importManualCookies( - cookieHeader: cached.cookieHeader, - intoAccountEmail: context.targetEmail, - allowAnyAccount: context.allowAnyAccount, - cacheScope: cacheScope, - logger: log) - } catch let error as ImportError { - switch error { - case .manualCookieHeaderInvalid, .noMatchingAccount, .dashboardStillRequiresLogin: - CookieHeaderCache.clear(provider: .codex, scope: cacheScope) - default: + if preferCachedCookieHeader { + if let cached = CookieHeaderCache.load(provider: .codex, scope: cacheScope), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + log("Using cached cookie header from \(cached.sourceLabel)") + do { + return try await self.importManualCookies( + cookieHeader: cached.cookieHeader, + intoAccountEmail: context.targetEmail, + allowAnyAccount: context.allowAnyAccount, + cacheScope: cacheScope, + logger: log) + } catch let error as ImportError { + switch error { + case .manualCookieHeaderInvalid, .noMatchingAccount, .dashboardStillRequiresLogin: + CookieHeaderCache.clear(provider: .codex, scope: cacheScope) + default: + throw error + } + } catch { throw error } - } catch { - throw error } + } else { + log("Skipping cached cookie header; forcing fresh browser import") } // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts @@ -606,15 +611,11 @@ public struct OpenAIDashboardBrowserCookieImporter { // Validate against the persistent store (login + email sync). do { - defer { - // The probe is only a validation step. Start the real dashboard scrape with a - // fresh WKWebView instead of reusing the probe instance. - OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) - } let probe = try await OpenAIDashboardFetcher().probeUsagePage( websiteDataStore: persistent, logger: logger, - timeout: 20) + timeout: 20, + preserveLoadedPageForReuse: true) let signed = probe.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) let matches = signed?.lowercased() == targetEmail.lowercased() logger("Persistent session signed in as: \(signed ?? "unknown")") @@ -630,8 +631,12 @@ public struct OpenAIDashboardBrowserCookieImporter { signedInEmail: signed, matchesCodexEmail: matches) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) logger("Selected \(candidate.label) but dashboard still requires login.") throw ImportError.dashboardStillRequiresLogin + } catch { + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) + throw error } } @@ -644,15 +649,11 @@ public struct OpenAIDashboardBrowserCookieImporter { await self.setCookies(candidate.cookies, into: persistent) do { - defer { - // The probe is only a validation step. Start the real dashboard scrape with a - // fresh WKWebView instead of reusing the probe instance. - OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) - } let probe = try await OpenAIDashboardFetcher().probeUsagePage( websiteDataStore: persistent, logger: logger, - timeout: 20) + timeout: 20, + preserveLoadedPageForReuse: true) let signed = probe.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) logger("Persistent session signed in as: \(signed ?? "unknown")") return ImportResult( @@ -661,8 +662,12 @@ public struct OpenAIDashboardBrowserCookieImporter { signedInEmail: signed, matchesCodexEmail: false) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) logger("Selected \(candidate.label) but dashboard still requires login.") throw ImportError.dashboardStillRequiresLogin + } catch { + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent) + throw error } } @@ -816,6 +821,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importBestCookies( intoAccountEmail _: String?, allowAnyAccount _: Bool = false, + preferCachedCookieHeader _: Bool = true, cacheScope _: CookieHeaderCache.Scope? = nil, logger _: ((String) -> Void)? = nil) async throws -> ImportResult { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index fe654bfab..71ab76628 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -19,7 +19,7 @@ public struct OpenAIDashboardFetcher { } } - private let usageURL = URL(string: "https://chatgpt.com/codex/settings/usage")! + private let usageURL = URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")! public init() {} @@ -97,6 +97,22 @@ public struct OpenAIDashboardFetcher { } } + nonisolated static func shouldPreserveLoadedPageAfterProbe(_ result: ProbeResult) -> Bool { + guard !result.loginRequired, !result.workspacePicker, !result.cloudflareInterstitial else { + return false + } + + guard self.isUsageRoute(result.href) else { return false } + + guard let signedInEmail = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines), + !signedInEmail.isEmpty + else { + return false + } + + return true + } + public func loadLatestDashboard( accountEmail: String?, logger: ((String) -> Void)? = nil, @@ -342,13 +358,15 @@ public struct OpenAIDashboardFetcher { public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, - timeout: TimeInterval = 30) async throws -> ProbeResult + timeout: TimeInterval = 30, + preserveLoadedPageForReuse: Bool = false) async throws -> ProbeResult { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, - timeout: Self.remainingTimeout(until: deadline)) + timeout: Self.remainingTimeout(until: deadline), + preserveLoadedPageOnRelease: preserveLoadedPageForReuse) defer { lease.release() } let webView = lease.webView let log = lease.log @@ -410,23 +428,28 @@ public struct OpenAIDashboardFetcher { continue } - return ProbeResult( + let result = ProbeResult( href: scrape.href, loginRequired: scrape.loginRequired, workspacePicker: scrape.workspacePicker, cloudflareInterstitial: scrape.cloudflareInterstitial, signedInEmail: normalizedEmail, bodyText: scrape.bodyText) + lease.setPreserveLoadedPageOnRelease( + preserveLoadedPageForReuse && Self.shouldPreserveLoadedPageAfterProbe(result)) + return result } log("Probe timed out (href=\(lastHref ?? "nil"))") - return ProbeResult( + let result = ProbeResult( href: lastHref, loginRequired: false, workspacePicker: false, cloudflareInterstitial: false, signedInEmail: nil, bodyText: lastBody) + lease.setPreserveLoadedPageOnRelease(false) + return result } // MARK: - JS scrape @@ -530,13 +553,15 @@ public struct OpenAIDashboardFetcher { private func makeWebView( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, - timeout: TimeInterval) async throws -> OpenAIDashboardWebViewLease + timeout: TimeInterval, + preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { try await OpenAIDashboardWebViewCache.shared.acquire( websiteDataStore: websiteDataStore, usageURL: self.usageURL, logger: logger, - navigationTimeout: timeout) + navigationTimeout: timeout, + preserveLoadedPageOnRelease: preserveLoadedPageOnRelease) } nonisolated static func sanitizedTimeout(_ timeout: TimeInterval) -> TimeInterval { @@ -556,7 +581,10 @@ public struct OpenAIDashboardFetcher { guard let href, !href.isEmpty else { return false } let path = (URL(string: href)?.path ?? href) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return path.hasSuffix("codex/settings/usage") || path.hasSuffix("codex/cloud/settings/usage") + return path.hasSuffix("codex/settings/usage") + || path.hasSuffix("codex/cloud/settings/usage") + || path.hasSuffix("codex/settings/analytics") + || path.hasSuffix("codex/cloud/settings/analytics") } private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 397e41138..1a90d2816 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -6,6 +6,7 @@ import WebKit struct OpenAIDashboardWebViewLease { let webView: WKWebView let log: (String) -> Void + let setPreserveLoadedPageOnRelease: (Bool) -> Void let release: () -> Void } @@ -14,17 +15,67 @@ final class OpenAIDashboardWebViewCache { static let shared = OpenAIDashboardWebViewCache() fileprivate static let log = CodexBarLog.logger(LogCategories.openAIWebview) + private final class ReleaseState { + var preserveLoadedPageOnRelease: Bool + + init(preserveLoadedPageOnRelease: Bool) { + self.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease + } + } + + private struct AcquireOptions { + let allowTimeoutRetry: Bool + let preserveLoadedPageOnRelease: Bool + } + private final class Entry { let webView: WKWebView let host: OffscreenWebViewHost var lastUsedAt: Date var isBusy: Bool - - init(webView: WKWebView, host: OffscreenWebViewHost, lastUsedAt: Date, isBusy: Bool) { + var preservedPageExpiresAt: Date? + var preservedPageExpiryTask: Task? + + init( + webView: WKWebView, + host: OffscreenWebViewHost, + lastUsedAt: Date, + isBusy: Bool, + preservedPageExpiresAt: Date? = nil) + { self.webView = webView self.host = host self.lastUsedAt = lastUsedAt self.isBusy = isBusy + self.preservedPageExpiresAt = preservedPageExpiresAt + } + + func armPreservedPage(until expiry: Date) { + self.preservedPageExpiresAt = expiry + } + + func setPreservedPageExpiryTask(_ task: Task?) { + self.preservedPageExpiryTask?.cancel() + self.preservedPageExpiryTask = task + } + + func clearPreservedPage() { + self.preservedPageExpiresAt = nil + self.preservedPageExpiryTask?.cancel() + self.preservedPageExpiryTask = nil + } + + func consumePreservedPageReuseIfAvailable(now: Date) -> Bool { + guard let preservedPageExpiresAt else { return false } + self.preservedPageExpiresAt = nil + self.preservedPageExpiryTask?.cancel() + self.preservedPageExpiryTask = nil + return preservedPageExpiresAt > now + } + + func hasExpiredPreservedPage(now: Date) -> Bool { + guard let preservedPageExpiresAt else { return false } + return preservedPageExpiresAt <= now } } @@ -32,19 +83,41 @@ final class OpenAIDashboardWebViewCache { /// Keep the WebView alive only long enough for immediate retries/menu reopens. /// Long-lived hidden ChatGPT tabs still consume noticeable energy on some setups. private let idleTimeout: TimeInterval = 60 + /// Reuse the validated analytics page only for the immediate next handoff. + private let preservedPageHandoffTimeout: TimeInterval = 5 private let blankURL = URL(string: "about:blank")! - - private func releaseCachedEntry(_ entry: Entry) { + private let reusablePageResetScript = """ + (() => { + try { + delete window.__codexbarDidScrollToCredits; + delete window.__codexbarUsageBreakdownJSON; + delete window.__codexbarUsageBreakdownDebug; + return true; + } catch { + return false; + } + })(); + """ + + private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) { entry.isBusy = false entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) + self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage) + self.prepareCachedWebViewForIdle( + entry.webView, + host: entry.host, + preserveLoadedPage: preserveLoadedPage) self.prune(now: Date()) } - private func releaseNewEntry(_ entry: Entry, webView: WKWebView) { + private func releaseNewEntry(_ entry: Entry, webView: WKWebView, preserveLoadedPage: Bool) { entry.isBusy = false entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(webView, host: entry.host) + self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage) + self.prepareCachedWebViewForIdle( + webView, + host: entry.host, + preserveLoadedPage: preserveLoadedPage) self.prune(now: Date()) } @@ -71,6 +144,31 @@ final class OpenAIDashboardWebViewCache { self.idleTimeout } + var preservedPageHandoffTimeoutForTesting: TimeInterval { + self.preservedPageHandoffTimeout + } + + func hasPreservedPageForTesting(for websiteDataStore: WKWebsiteDataStore) -> Bool { + let key = ObjectIdentifier(websiteDataStore) + return self.entries[key]?.preservedPageExpiresAt != nil + } + + func markPreservedPageForTesting( + websiteDataStore: WKWebsiteDataStore, + expiresAt: Date = .init().addingTimeInterval(5)) + { + let key = ObjectIdentifier(websiteDataStore) + guard let entry = self.entries[key] else { return } + entry.armPreservedPage(until: expiresAt) + self.schedulePreservedPageExpiry(for: key, entry: entry, expiresAt: expiresAt) + } + + func consumePreservedPageForTesting(websiteDataStore: WKWebsiteDataStore, now: Date = Date()) -> Bool { + let key = ObjectIdentifier(websiteDataStore) + guard let entry = self.entries[key] else { return false } + return entry.consumePreservedPageReuseIfAvailable(now: now) + } + /// Seed a cached entry without navigating a real page (for test stability). @discardableResult func cacheEntryForTesting( @@ -97,17 +195,23 @@ final class OpenAIDashboardWebViewCache { /// Clear all cached entries (for test isolation). func clearAllForTesting() { for (_, entry) in self.entries { + entry.clearPreservedPage() entry.host.close() } self.entries.removeAll() } + + func resetReusablePageStateForTesting(_ webView: WKWebView) async -> Bool { + await self.resetReusablePageState(webView) + } #endif func acquire( websiteDataStore: WKWebsiteDataStore, usageURL: URL, logger: ((String) -> Void)?, - navigationTimeout: TimeInterval = 15) async throws -> OpenAIDashboardWebViewLease + navigationTimeout: TimeInterval = 15, + preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { let deadline = Date().addingTimeInterval(max(navigationTimeout, 1)) return try await self.acquire( @@ -115,7 +219,9 @@ final class OpenAIDashboardWebViewCache { usageURL: usageURL, logger: logger, deadline: deadline, - allowTimeoutRetry: true) + options: .init( + allowTimeoutRetry: true, + preserveLoadedPageOnRelease: preserveLoadedPageOnRelease)) } private func acquire( @@ -123,7 +229,7 @@ final class OpenAIDashboardWebViewCache { usageURL: URL, logger: ((String) -> Void)?, deadline: Date, - allowTimeoutRetry: Bool) async throws -> OpenAIDashboardWebViewLease + options: AcquireOptions) async throws -> OpenAIDashboardWebViewLease { let now = Date() self.prune(now: now) @@ -140,9 +246,13 @@ final class OpenAIDashboardWebViewCache { let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore) host.show() do { - try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) + try await self.prepareWebView( + webView, + usageURL: usageURL, + timeout: remainingTimeout, + canReuseLoadedPage: false) } catch { - if allowTimeoutRetry, Self.isPrepareTimeout(error) { + if options.allowTimeoutRetry, Self.isPrepareTimeout(error) { host.close() log("Temporary OpenAI WebView timed out; retrying with a fresh WebView.") return try await self.acquireTemporaryWebView( @@ -157,18 +267,26 @@ final class OpenAIDashboardWebViewCache { return OpenAIDashboardWebViewLease( webView: webView, log: log, + setPreserveLoadedPageOnRelease: { _ in }, release: { host.close() }) } entry.isBusy = true entry.lastUsedAt = now + let canReuseLoadedPage = entry.consumePreservedPageReuseIfAvailable(now: now) + let releaseState = ReleaseState(preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease) entry.host.show() do { - try await self.prepareWebView(entry.webView, usageURL: usageURL, timeout: remainingTimeout) + try await self.prepareWebView( + entry.webView, + usageURL: usageURL, + timeout: remainingTimeout, + canReuseLoadedPage: canReuseLoadedPage) } catch { - if allowTimeoutRetry, Self.isPrepareTimeout(error) { + if options.allowTimeoutRetry, Self.isPrepareTimeout(error) { entry.isBusy = false entry.lastUsedAt = Date() + entry.clearPreservedPage() entry.host.close() self.entries.removeValue(forKey: key) log("Cached OpenAI WebView timed out; recreating it.") @@ -177,10 +295,13 @@ final class OpenAIDashboardWebViewCache { usageURL: usageURL, logger: logger, deadline: deadline, - allowTimeoutRetry: false) + options: .init( + allowTimeoutRetry: false, + preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease)) } entry.isBusy = false entry.lastUsedAt = Date() + entry.clearPreservedPage() entry.host.close() self.entries.removeValue(forKey: key) Self.log.warning("OpenAI webview prepare failed") @@ -190,9 +311,14 @@ final class OpenAIDashboardWebViewCache { return OpenAIDashboardWebViewLease( webView: entry.webView, log: log, + setPreserveLoadedPageOnRelease: { preserveLoadedPageOnRelease in + releaseState.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease + }, release: { [weak self, weak entry] in guard let self, let entry else { return } - self.releaseCachedEntry(entry) + self.releaseCachedEntry( + entry, + preserveLoadedPage: releaseState.preserveLoadedPageOnRelease) }) } @@ -200,11 +326,16 @@ final class OpenAIDashboardWebViewCache { let entry = Entry(webView: webView, host: host, lastUsedAt: now, isBusy: true) self.entries[key] = entry host.show() + let releaseState = ReleaseState(preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease) do { - try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) + try await self.prepareWebView( + webView, + usageURL: usageURL, + timeout: remainingTimeout, + canReuseLoadedPage: false) } catch { - if allowTimeoutRetry, Self.isPrepareTimeout(error) { + if options.allowTimeoutRetry, Self.isPrepareTimeout(error) { self.entries.removeValue(forKey: key) host.close() log("OpenAI WebView timed out during prepare; retrying once.") @@ -213,7 +344,9 @@ final class OpenAIDashboardWebViewCache { usageURL: usageURL, logger: logger, deadline: deadline, - allowTimeoutRetry: false) + options: .init( + allowTimeoutRetry: false, + preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease)) } self.entries.removeValue(forKey: key) host.close() @@ -224,15 +357,22 @@ final class OpenAIDashboardWebViewCache { return OpenAIDashboardWebViewLease( webView: webView, log: log, + setPreserveLoadedPageOnRelease: { preserveLoadedPageOnRelease in + releaseState.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease + }, release: { [weak self, weak entry] in guard let self, let entry else { return } - self.releaseNewEntry(entry, webView: webView) + self.releaseNewEntry( + entry, + webView: webView, + preserveLoadedPage: releaseState.preserveLoadedPageOnRelease) }) } func evict(websiteDataStore: WKWebsiteDataStore) { let key = ObjectIdentifier(websiteDataStore) guard let entry = self.entries.removeValue(forKey: key) else { return } + entry.clearPreservedPage() Self.log.debug("OpenAI webview evicted") entry.host.close() } @@ -241,6 +381,7 @@ final class OpenAIDashboardWebViewCache { let existing = self.entries self.entries.removeAll() for (_, entry) in existing { + entry.clearPreservedPage() entry.host.close() } if !existing.isEmpty { @@ -248,17 +389,35 @@ final class OpenAIDashboardWebViewCache { } } - private func prepareCachedWebViewForIdle(_ webView: WKWebView, host: OffscreenWebViewHost) { + private func prepareCachedWebViewForIdle( + _ webView: WKWebView, + host: OffscreenWebViewHost, + preserveLoadedPage: Bool) + { + webView.navigationDelegate = nil + webView.codexNavigationDelegate = nil + if preserveLoadedPage { + host.hide() + return + } + // Detach the heavyweight ChatGPT SPA as soon as a scrape completes. Keeping the WebView object around // still helps with immediate reuse, but letting chatgpt.com remain the active document is too expensive. webView.stopLoading() - webView.navigationDelegate = nil - webView.codexNavigationDelegate = nil _ = webView.load(URLRequest(url: self.blankURL)) host.hide() } private func prune(now: Date) { + for entry in self.entries.values where !entry.isBusy && entry.hasExpiredPreservedPage(now: now) { + entry.clearPreservedPage() + self.prepareCachedWebViewForIdle( + entry.webView, + host: entry.host, + preserveLoadedPage: false) + Self.log.debug("OpenAI webview preserved page expired") + } + let expired = self.entries.filter { _, entry in !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout } @@ -281,7 +440,12 @@ final class OpenAIDashboardWebViewCache { return (webView, host) } - private func prepareWebView(_ webView: WKWebView, usageURL: URL, timeout: TimeInterval) async throws { + private func prepareWebView( + _ webView: WKWebView, + usageURL: URL, + timeout: TimeInterval, + canReuseLoadedPage: Bool) async throws + { #if DEBUG if usageURL.absoluteString == "about:blank" { _ = webView.loadHTMLString("", baseURL: nil) @@ -289,6 +453,17 @@ final class OpenAIDashboardWebViewCache { } #endif + if canReuseLoadedPage, + let currentURL = webView.url?.absoluteString, + OpenAIDashboardFetcher.isUsageRoute(currentURL) + { + if await self.resetReusablePageState(webView) { + return + } + + Self.log.debug("OpenAI preserved page reset failed; reloading usage URL") + } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in let delegate = NavigationDelegate { result in cont.resume(with: result) @@ -310,7 +485,11 @@ final class OpenAIDashboardWebViewCache { let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore) host.show() do { - try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) + try await self.prepareWebView( + webView, + usageURL: usageURL, + timeout: remainingTimeout, + canReuseLoadedPage: false) } catch { host.close() throw error @@ -318,6 +497,7 @@ final class OpenAIDashboardWebViewCache { return OpenAIDashboardWebViewLease( webView: webView, log: log, + setPreserveLoadedPageOnRelease: { _ in }, release: { host.close() }) } @@ -325,6 +505,60 @@ final class OpenAIDashboardWebViewCache { let nsError = error as NSError return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut } + + private func updatePreservedPageState(for entry: Entry, preserveLoadedPage: Bool) { + if preserveLoadedPage { + let expiresAt = Date().addingTimeInterval(self.preservedPageHandoffTimeout) + entry.armPreservedPage(until: expiresAt) + if let key = self.entries.first(where: { $0.value === entry })?.key { + self.schedulePreservedPageExpiry(for: key, entry: entry, expiresAt: expiresAt) + } + } else { + entry.clearPreservedPage() + } + } + + private func schedulePreservedPageExpiry( + for key: ObjectIdentifier, + entry: Entry, + expiresAt: Date) + { + let delay = max(0, expiresAt.timeIntervalSinceNow) + let task = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + self?.expirePreservedPageIfNeeded(for: key, expectedExpiry: expiresAt) + } + entry.setPreservedPageExpiryTask(task) + } + + private func expirePreservedPageIfNeeded(for key: ObjectIdentifier, expectedExpiry: Date) { + guard let entry = self.entries[key], + !entry.isBusy, + let preservedPageExpiresAt = entry.preservedPageExpiresAt, + preservedPageExpiresAt == expectedExpiry, + preservedPageExpiresAt <= Date() + else { + return + } + + entry.clearPreservedPage() + self.prepareCachedWebViewForIdle( + entry.webView, + host: entry.host, + preserveLoadedPage: false) + Self.log.debug("OpenAI webview preserved page expired") + self.prune(now: Date()) + } + + private func resetReusablePageState(_ webView: WKWebView) async -> Bool { + do { + let any = try await webView.evaluateJavaScript(self.reusablePageResetScript) + return (any as? Bool) ?? true + } catch { + return false + } + } } @MainActor diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index 9f7b6b93e..b613a54e4 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -150,15 +150,43 @@ extension CodexWebDashboardStrategy { let log: @MainActor (String) -> Void = { line in logger.append(line) } - let result = try await Self.fetchOpenAIWebDashboard( - context: context, - options: options, - browserDetection: browserDetection, - logger: log) - return try Self.makeAuthorizedDashboardResult( - dashboard: result.dashboard, - context: context, - routingTargetEmail: result.routingTargetEmail) + do { + let result = try await Self.fetchOpenAIWebDashboard( + context: context, + options: options, + browserDetection: browserDetection, + preferCachedCookieHeader: true, + logger: log) + return try Self.makeAuthorizedDashboardResult( + dashboard: result.dashboard, + context: context, + routingTargetEmail: result.routingTargetEmail) + } catch { + guard Self.shouldRetryWithFreshBrowserImport(after: error) else { + throw error + } + log("Retrying OpenAI web dashboard with a fresh browser cookie import.") + let result = try await Self.fetchOpenAIWebDashboard( + context: context, + options: options, + browserDetection: browserDetection, + preferCachedCookieHeader: false, + logger: log) + return try Self.makeAuthorizedDashboardResult( + dashboard: result.dashboard, + context: context, + routingTargetEmail: result.routingTargetEmail) + } + } + + nonisolated static func shouldRetryWithFreshBrowserImport(after error: Error) -> Bool { + if error is OpenAIWebCodexError { + return error as? OpenAIWebCodexError == .missingUsage + } + if case OpenAIDashboardFetcher.FetchError.noDashboardData = error { + return true + } + return false } @MainActor @@ -222,6 +250,7 @@ extension CodexWebDashboardStrategy { context: ProviderFetchContext, options: OpenAIWebOptions, browserDetection: BrowserDetection, + preferCachedCookieHeader: Bool, logger: @MainActor @escaping (String) -> Void) async throws -> OpenAIWebDashboardFetchResult { let auth = context.fetcher.loadAuthBackedCodexAccount() @@ -232,6 +261,7 @@ extension CodexWebDashboardStrategy { .importBestCookies( intoAccountEmail: routingTargetEmail, allowAnyAccount: allowAnyAccount, + preferCachedCookieHeader: preferCachedCookieHeader, logger: logger) let effectiveEmail = routingTargetEmail ?? importResult.signedInEmail? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/CodexBarTests/CLIWebFallbackTests.swift b/Tests/CodexBarTests/CLIWebFallbackTests.swift index a3809c5b3..df93a6f0a 100644 --- a/Tests/CodexBarTests/CLIWebFallbackTests.swift +++ b/Tests/CodexBarTests/CLIWebFallbackTests.swift @@ -62,6 +62,16 @@ struct CLIWebFallbackTests { context: context)) } + @Test + func `codex retries fresh browser import for missing usage and no data`() { + #expect(CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport( + after: OpenAIWebCodexError.missingUsage)) + #expect(CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport( + after: OpenAIDashboardFetcher.FetchError.noDashboardData(body: "missing"))) + #expect(!CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport( + after: OpenAIDashboardFetcher.FetchError.loginRequired)) + } + @Test func `codex display only falls back in auto`() { let strategy = CodexWebDashboardStrategy() diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index 85713405a..b234a2c32 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -108,6 +108,32 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(shouldWait == false) } + @Test + func `probe handoff preserves page only after confirmed signed in email`() { + let result = OpenAIDashboardFetcher.ProbeResult( + href: "https://chatgpt.com/codex/cloud/settings/analytics#usage", + loginRequired: false, + workspacePicker: false, + cloudflareInterstitial: false, + signedInEmail: "user@example.com", + bodyText: "Credits remaining 42") + + #expect(OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result)) + } + + @Test + func `probe handoff does not preserve timed out usage page without email`() { + let result = OpenAIDashboardFetcher.ProbeResult( + href: "https://chatgpt.com/codex/cloud/settings/analytics#usage", + loginRequired: false, + workspacePicker: false, + cloudflareInterstitial: false, + signedInEmail: nil, + bodyText: "Codex Analytics") + + #expect(!OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result)) + } + @Test func `probe grace restarts after route reload resets readiness timestamps`() { let now = Date() @@ -167,10 +193,21 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage")) } + @Test + func `usage route matcher accepts analytics route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics")) + } + + @Test + func `usage route matcher accepts analytics usage hash route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics#usage")) + } + @Test func `usage route matcher accepts trailing slash variants`() { #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage/")) #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage/")) + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics/")) } @Test diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index f6558b810..1b361879d 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -138,6 +138,106 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test + func `Preserved page handoff is consumed only once`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting)) + + #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") + #expect(cache.consumePreservedPageForTesting(websiteDataStore: store), "First acquire should reuse handoff") + #expect( + !cache.consumePreservedPageForTesting(websiteDataStore: store), + "Second acquire should not keep reusing preserved page") + + cache.clearAllForTesting() + } + + @Test + func `Expired preserved page is cleared before idle eviction`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(1)) + + let afterExpiry = Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting + 1) + cache.pruneForTesting(now: afterExpiry) + + #expect(!cache.hasPreservedPageForTesting(for: store), "Expired preserved page should be cleared") + #expect(cache.hasCachedEntry(for: store), "Entry should remain cached after page handoff expires") + + cache.clearAllForTesting() + } + + @Test + func `Preserved page expiry is scheduled without future cache activity`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let webView = cache.cacheEntryForTesting(websiteDataStore: store) + + _ = webView.loadHTMLString("alive", baseURL: nil) + try? await Task.sleep(for: .milliseconds(150)) + + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(0.2)) + + #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") + + try? await Task.sleep(for: .milliseconds(450)) + + let bodyText = try await webView.evaluateJavaScript( + "document.body ? String(document.body.innerText || '') : ''") as? String + + #expect(!cache.hasPreservedPageForTesting(for: store), "Expected scheduled expiry to clear preserved page") + #expect(bodyText?.isEmpty == true, "Expected scheduled expiry to detach the preserved page to about:blank") + + cache.clearAllForTesting() + } + + @Test + func `Reused page reset clears one shot scraper globals`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let lease = try await cache.acquire( + websiteDataStore: store, + usageURL: url, + logger: nil) + + _ = try await lease.webView.evaluateJavaScript( + """ + window.__codexbarDidScrollToCredits = true; + window.__codexbarUsageBreakdownJSON = '[{"day":"2026-04-19"}]'; + window.__codexbarUsageBreakdownDebug = 'debug'; + true; + """) + + #expect(await cache.resetReusablePageStateForTesting(lease.webView)) + + let reset = try await lease.webView.evaluateJavaScript( + """ + typeof window.__codexbarDidScrollToCredits === 'undefined' && + typeof window.__codexbarUsageBreakdownJSON === 'undefined' && + typeof window.__codexbarUsageBreakdownDebug === 'undefined' + """) as? Bool + + #expect(reset == true, "Expected one-shot scraper globals to be cleared before reuse") + + lease.release() + cache.clearAllForTesting() + } + // MARK: - Eviction Tests @Test From 1f58bb8cd5a43bd6e3a08a06556630531632c4d8 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 20 Apr 2026 01:09:48 +0530 Subject: [PATCH 32/64] docs: update changelog with OpenAI web dashboard fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5789fcf1..7fc134b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael! - Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe! - Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang! +- Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. @ratulsarna ### Menu & Settings - Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! From 94d20eb2d95d769bd7f63a77e6b3644f26ed8639 Mon Sep 17 00:00:00 2001 From: Anirudh Venkatachalam <50367124+anirudhvee@users.noreply.github.com> Date: Sat, 18 Apr 2026 04:28:10 -0700 Subject: [PATCH 33/64] feat: add menu keyboard shortcuts --- .../CodexBar/StatusItemController+Menu.swift | 17 +++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 55ff40b7a..88cb625a5 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -16,6 +16,19 @@ extension StatusItemController { static let costHistoryChartID = "costHistoryChart" static let usageHistoryChartID = "usageHistoryChart" + private func shortcut(for action: MenuDescriptor.MenuAction) -> (key: String, modifiers: NSEvent.ModifierFlags)? { + switch action { + case .refresh: + ("r", [.command]) + case .settings: + (",", [.command]) + case .quit: + ("q", [.command]) + default: + nil + } + } + private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { _ = menu return Self.menuCardBaseWidth @@ -506,6 +519,10 @@ extension StatusItemController { let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") item.target = self item.representedObject = represented + if let shortcut = self.shortcut(for: action) { + item.keyEquivalent = shortcut.key + item.keyEquivalentModifierMask = shortcut.modifiers + } if let iconName = action.systemImageName, let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index c40134467..c103bc7a3 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -522,6 +522,21 @@ struct StatusMenuTests { #expect(titles.contains("Settings...")) #expect(titles.contains("About CodexBar")) #expect(titles.contains("Quit")) + + let refreshItem = menu.items.first { $0.title == "Refresh" } + #expect(refreshItem != nil) + #expect(refreshItem?.keyEquivalent == "r") + #expect(refreshItem?.keyEquivalentModifierMask == [.command]) + + let settingsItem = menu.items.first { $0.title == "Settings..." } + #expect(settingsItem != nil) + #expect(settingsItem?.keyEquivalent == ",") + #expect(settingsItem?.keyEquivalentModifierMask == [.command]) + + let quitItem = menu.items.first { $0.title == "Quit" } + #expect(quitItem != nil) + #expect(quitItem?.keyEquivalent == "q") + #expect(quitItem?.keyEquivalentModifierMask == [.command]) } } From 31242990b2342658c266b70ce42df6d64e78493b Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 21 Apr 2026 01:21:51 +0530 Subject: [PATCH 34/64] chore: update changelog with new features and fixes for the upcoming release - Added highlights for Codex, Synthetic, Antigravity, Menu, and Widgets improvements. - Included details on menu keyboard shortcuts for better user experience. - Acknowledged contributions from various collaborators. --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc134b42..6e0fb39fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 0.22 — Unreleased +### Highlights +- Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. +- Synthetic: parse live quota payloads for five-hour, weekly, and search limits, including continuous reset/regeneration details (#732). Thanks @baanish! +- Antigravity: restore account/quota probing across newer localhost endpoint/token layouts and retry paths (#727). Thanks @icey-zhang! +- Menu: add standard shortcuts for Refresh, Settings, and Quit while the status menu is open (#737). Thanks @anirudhvee! +- Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! + ### Providers & Usage - Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish! - Antigravity: restore localhost probing with async TLS challenge handling, extension-token fallback, and best-effort port selection (#727). Thanks @icey-zhang! @@ -11,6 +18,7 @@ - Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. @ratulsarna ### Menu & Settings +- Menu: show and handle standard shortcuts for Refresh (⌘R), Settings (⌘,), and Quit (⌘Q) while the status menu is open (#737). Thanks @anirudhvee! - Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman! - Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal! From 8736b0a4460c5b4284b595f7c68863edd0f55aab Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 21 Apr 2026 02:48:43 +0530 Subject: [PATCH 35/64] Preserve keychain cache on temporary lock --- Sources/CodexBarCore/CookieHeaderCache.swift | 3 + Sources/CodexBarCore/KeychainCacheStore.swift | 22 +- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 46 +++- ...ialsStoreTemporaryKeychainCacheTests.swift | 237 ++++++++++++++++++ .../CookieHeaderCacheTests.swift | 65 +++++ .../KeychainCacheStoreTests.swift | 41 ++- 6 files changed, 396 insertions(+), 18 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift diff --git a/Sources/CodexBarCore/CookieHeaderCache.swift b/Sources/CodexBarCore/CookieHeaderCache.swift index 0d24cde93..dae36ebf2 100644 --- a/Sources/CodexBarCore/CookieHeaderCache.swift +++ b/Sources/CodexBarCore/CookieHeaderCache.swift @@ -36,6 +36,9 @@ public enum CookieHeaderCache { case let .found(entry): self.log.debug("Cookie cache hit", metadata: ["provider": provider.rawValue]) return entry + case .temporarilyUnavailable: + self.log.debug("Cookie cache temporarily unavailable", metadata: ["provider": provider.rawValue]) + return nil case .invalid: self.log.warning("Cookie cache invalid; clearing", metadata: ["provider": provider.rawValue]) KeychainCacheStore.clear(key: key) diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 03060d9c2..ad5b9db37 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -21,6 +21,7 @@ public enum KeychainCacheStore { public enum LoadResult { case found(Entry) case missing + case temporarilyUnavailable case invalid } @@ -29,6 +30,9 @@ public enum KeychainCacheStore { private static let cacheLabel = "CodexBar Cache" private nonisolated(unsafe) static var globalServiceOverride: String? @TaskLocal private static var serviceOverride: String? + #if DEBUG && os(macOS) + @TaskLocal private static var loadFailureStatusOverride: OSStatus? + #endif private static let testStoreLock = NSLock() private struct TestStoreKey: Hashable { let service: String @@ -42,6 +46,11 @@ public enum KeychainCacheStore { key: Key, as type: Entry.Type = Entry.self) -> LoadResult { + #if DEBUG && os(macOS) + if let status = self.loadFailureStatusOverride { + return self.loadResultForKeychainReadFailure(status: status, key: key) + } + #endif if let testResult = loadFromTestStore(key: key, as: type) { return testResult } @@ -170,6 +179,17 @@ public enum KeychainCacheStore { self.serviceOverride } + #if DEBUG && os(macOS) + public static func withLoadFailureStatusOverrideForTesting( + _ status: OSStatus?, + operation: () throws -> T) rethrows -> T + { + try self.$loadFailureStatusOverride.withValue(status) { + try operation() + } + } + #endif + static func setTestStoreForTesting(_ enabled: Bool) { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } @@ -213,7 +233,7 @@ public enum KeychainCacheStore { case errSecInteractionNotAllowed: // Keychain is temporarily locked, e.g. immediately after wake from sleep. self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access") - return .missing + return .temporarilyUnavailable default: self.log.error("Keychain cache read failed (\(key.account)): \(status)") return .invalid diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 35dfdc274..6388be800 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -207,6 +207,7 @@ public enum ClaudeOAuthCredentialsStore { var lastError: Error? var expiredRecord: ClaudeOAuthCredentialRecord? + var cacheTemporarilyUnavailable = false switch KeychainCacheStore.load(key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) { case let .found(entry): @@ -239,6 +240,8 @@ public enum ClaudeOAuthCredentialsStore { } case .invalid: KeychainCacheStore.clear(key: ClaudeOAuthCredentialsStore.cacheKey) + case .temporarilyUnavailable: + cacheTemporarilyUnavailable = true case .missing: break } @@ -259,7 +262,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: Date()) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(fileData, owner: .claudeCLI) + if !cacheTemporarilyUnavailable { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(fileData, owner: .claudeCLI) + } return record } } catch let error as ClaudeOAuthCredentialsError { @@ -274,7 +279,8 @@ public enum ClaudeOAuthCredentialsStore { if allowClaudeKeychainRepairWithoutPrompt, !allowKeychainPrompt { if let repaired = recovery.repairFromClaudeKeychainWithoutPromptIfAllowed( now: Date(), - respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) + respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes, + allowCacheKeychainWrite: !cacheTemporarilyUnavailable) { return repaired } @@ -283,6 +289,7 @@ public enum ClaudeOAuthCredentialsStore { if let prompted = self.loadFromClaudeKeychainWithPromptIfAllowed( allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: respectKeychainPromptCooldown, + allowCacheKeychainWrite: !cacheTemporarilyUnavailable, lastError: &lastError) { return prompted @@ -299,6 +306,7 @@ public enum ClaudeOAuthCredentialsStore { private func loadFromClaudeKeychainWithPromptIfAllowed( allowKeychainPrompt: Bool, respectKeychainPromptCooldown: Bool, + allowCacheKeychainWrite: Bool, lastError: inout Error?) -> ClaudeOAuthCredentialRecord? { let shouldApplyPromptCooldown = @@ -355,7 +363,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: Date()) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + if allowCacheKeychainWrite { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + } return record } @@ -404,7 +414,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: Date()) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + if allowCacheKeychainWrite { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + } return record } catch let error as ClaudeOAuthCredentialsError { if case .notFound = error { @@ -423,24 +435,28 @@ public enum ClaudeOAuthCredentialsStore { let current = ClaudeOAuthCredentialsStore.currentFileFingerprint() let stored = ClaudeOAuthCredentialsStore.loadFileFingerprint() guard current != stored else { return false } - ClaudeOAuthCredentialsStore.saveFileFingerprint(current) ClaudeOAuthCredentialsStore.log.info("Claude OAuth credentials file changed; invalidating cache") ClaudeOAuthCredentialsStore.writeMemoryCache(record: nil, timestamp: nil) var shouldClearKeychainCache = false + var shouldSaveFileFingerprint = true if let current { if let modifiedAtMs = current.modifiedAtMs { let modifiedAt = Date(timeIntervalSince1970: TimeInterval(Double(modifiedAtMs) / 1000.0)) - if case let .found(entry) = KeychainCacheStore.load( + switch KeychainCacheStore.load( key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) { + case let .found(entry): if entry.storedAt < modifiedAt { shouldClearKeychainCache = true } - } else { + case .missing, .invalid: shouldClearKeychainCache = true + case .temporarilyUnavailable: + shouldClearKeychainCache = false + shouldSaveFileFingerprint = false } } else { shouldClearKeychainCache = true @@ -452,6 +468,9 @@ public enum ClaudeOAuthCredentialsStore { if shouldClearKeychainCache { ClaudeOAuthCredentialsStore.clearCacheKeychain() } + if shouldSaveFileFingerprint { + ClaudeOAuthCredentialsStore.saveFileFingerprint(current) + } return true } } @@ -507,6 +526,8 @@ public enum ClaudeOAuthCredentialsStore { owner: entry.owner ?? .claudeCLI, source: .cacheKeychain) return isRefreshableOrValid(record) + case .temporarilyUnavailable: + return true default: break } @@ -697,7 +718,8 @@ public enum ClaudeOAuthCredentialsStore { func repairFromClaudeKeychainWithoutPromptIfAllowed( now: Date, - respectKeychainPromptCooldown: Bool) -> ClaudeOAuthCredentialRecord? + respectKeychainPromptCooldown: Bool, + allowCacheKeychainWrite: Bool = true) -> ClaudeOAuthCredentialRecord? { #if os(macOS) let mode = ClaudeOAuthKeychainPromptPreference.current() @@ -735,7 +757,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(securityData, owner: .claudeCLI) + if allowCacheKeychainWrite { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(securityData, owner: .claudeCLI) + } ClaudeOAuthCredentialsStore.log.info( "Claude keychain credentials loaded without prompt; syncing OAuth cache", @@ -773,7 +797,9 @@ public enum ClaudeOAuthCredentialsStore { owner: .claudeCLI, source: .memoryCache), timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI) + if allowCacheKeychainWrite { + ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI) + } ClaudeOAuthCredentialsStore.log.info( "Claude keychain credentials loaded without prompt; syncing OAuth cache", diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift new file mode 100644 index 000000000..b9e0630f5 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift @@ -0,0 +1,237 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { + private struct WrongCacheEntry: Codable { + let value: String + } + + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let refreshField: String = { + guard let refreshToken else { return "" } + return ",\n \"refreshToken\": \"\(refreshToken)\"" + }() + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"]\(refreshField) + } + } + """ + return Data(json.utf8) + } + + #if os(macOS) + @Test + func `credentials file invalidation preserves keychain cache when temporarily unavailable`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let firstFile = self.makeCredentialsData( + accessToken: "first-file", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try firstFile.write(to: fileURL) + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(), + owner: .claudeCLI)) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let updatedFile = self.makeCredentialsData( + accessToken: "updated-file-token-longer", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try updatedFile.write(to: fileURL) + + KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + } + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected temporary unavailability not to clear Claude cache") + } + + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected pending invalidation to clear stale Claude cache") + } + } + } + } + } + } + + @Test + func `temporary keychain cache unavailability does not overwrite cache from credentials file fallback`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fileData = self.makeCredentialsData( + accessToken: "file-fallback-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try fileData.write(to: fileURL) + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(), + owner: .claudeCLI)) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let loaded = try KeychainCacheStore.withLoadFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + #expect(loaded.accessToken == "file-fallback-token") + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "cached-token") + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected file fallback not to overwrite unavailable cache") + } + } + } + } + } + } + } + + @Test + func `has cached credentials treats temporary keychain cache unavailability as present`() { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let hasCached = KeychainCacheStore.withLoadFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) + } + + #expect(hasCached == true) + } + } + } + #endif + + @Test + func `invalid keychain cache is cleared by load`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store(key: cacheKey, entry: WrongCacheEntry(value: "wrong-shape")) + + do { + _ = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected invalid Claude cache to be cleared") + } + } + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/CookieHeaderCacheTests.swift b/Tests/CodexBarTests/CookieHeaderCacheTests.swift index 3a8a7b50d..de1247694 100644 --- a/Tests/CodexBarTests/CookieHeaderCacheTests.swift +++ b/Tests/CodexBarTests/CookieHeaderCacheTests.swift @@ -4,6 +4,10 @@ import Testing @Suite(.serialized) struct CookieHeaderCacheTests { + private struct WrongEntry: Codable { + let value: String + } + @Test func `stores and loads entry`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -105,4 +109,65 @@ struct CookieHeaderCacheTests { let loadedAgain = CookieHeaderCache.load(provider: provider) #expect(loadedAgain?.cookieHeader == "auth=legacy") } + + #if os(macOS) + @Test + func `temporary keychain unavailability returns nil without migrating legacy file`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) + defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + + let provider: UsageProvider = .codex + let legacyURL = legacyBase.appendingPathComponent("\(provider.rawValue)-cookie.json") + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: legacyURL) + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + + let loaded = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.load(provider: provider) + } + + #expect(loaded == nil) + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + + switch KeychainCacheStore.load(key: .cookie(provider: provider), as: CookieHeaderCache.Entry.self) { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected temporary miss not to migrate legacy cache") + } + } + #endif + + @Test + func `invalid keychain cache is cleared`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) + defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + + let provider: UsageProvider = .codex + let key = KeychainCacheStore.Key.cookie(provider: provider) + KeychainCacheStore.store(key: key, entry: WrongEntry(value: "not-a-cookie-entry")) + + #expect(CookieHeaderCache.load(provider: provider) == nil) + + switch KeychainCacheStore.load(key: key, as: CookieHeaderCache.Entry.self) { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected invalid cookie cache to be cleared") + } + } } diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 41bd82728..12145ea6a 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -24,7 +24,7 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case let .found(loaded): #expect(loaded == entry) - case .missing, .invalid: + case .missing, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected keychain cache entry") } } @@ -45,7 +45,7 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case let .found(loaded): #expect(loaded == second) - case .missing, .invalid: + case .missing, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected overwritten keychain cache entry") } } @@ -64,24 +64,51 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case .missing: #expect(true) - case .found, .invalid: + case .found, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected keychain cache entry to be cleared") } } #if os(macOS) @Test - func `interaction not allowed is treated as temporarily missing`() { + func `interaction not allowed is treated as temporarily unavailable`() { let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) let result: KeychainCacheStore.LoadResult = KeychainCacheStore.loadResultForKeychainReadFailure( status: errSecInteractionNotAllowed, key: key) switch result { - case .missing: + case .temporarilyUnavailable: #expect(true) - case .found, .invalid: - #expect(Bool(false), "Expected temporary keychain lock to preserve cache") + case .found, .missing, .invalid: + #expect(Bool(false), "Expected temporary keychain lock to be retry-later") + } + } + + @Test + func `load failure override bypasses test store without affecting store or clear`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let entry = TestEntry(value: "stored", storedAt: Date(timeIntervalSince1970: 0)) + KeychainCacheStore.store(key: key, entry: entry) + defer { KeychainCacheStore.clear(key: key) } + + KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case .temporarilyUnavailable: + #expect(true) + case .found, .missing, .invalid: + #expect(Bool(false), "Expected override to run before test store") + } + } + + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case let .found(loaded): + #expect(loaded == entry) + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected override not to mutate test store") } } #endif From d9c82bfd52c2447869b9ebe22ecd622b1a180b39 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 00:48:09 +0100 Subject: [PATCH 36/64] docs: finalize changelog for 0.22 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0fb39fa..1892c880c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.22 — Unreleased +## 0.22 — 2026-04-21 ### Highlights - Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. From afe15bd2a156fcbc3b3ecebb966c27fecd9fa347 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 00:54:37 +0100 Subject: [PATCH 37/64] build: bump 0.22 build number --- version.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.env b/version.env index f2f277da7..0423e7bb1 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ MARKETING_VERSION=0.22 -BUILD_NUMBER=56 +BUILD_NUMBER=57 From cd5899a748cca5345030d0be3facb6c36d69397a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 01:12:55 +0100 Subject: [PATCH 38/64] docs: update appcast for 0.22 --- appcast.xml | 66 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/appcast.xml b/appcast.xml index 0585c6e37..635565eac 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,45 @@ CodexBar + + 0.22 + Tue, 21 Apr 2026 01:12:52 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 57 + 0.22 + 14.0 + CodexBar 0.22 +

Highlights

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

Providers & Usage

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

Menu & Settings

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

Fixes

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

View full changelog

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

Highlights

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

Providers & Usage

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

Menu & Settings

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

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 532d0191c6261bf82976669f03cd4ebb39e1dd6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 02:14:46 +0100 Subject: [PATCH 39/64] test: isolate Gemini fnm CLI fixture --- Tests/CodexBarTests/GeminiStatusProbeAPITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 61cb8b0f5..921ebe1e4 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -231,7 +231,7 @@ struct GeminiStatusProbeAPITests { setenv("PATH", pathValue, 1) let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - unsetenv("GEMINI_CLI_PATH") + setenv("GEMINI_CLI_PATH", binURL.path, 1) defer { if let previousPath { setenv("PATH", previousPath, 1) From a7d81d3fb31b36a63fc68d8565472ee36fde1226 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 04:51:41 +0100 Subject: [PATCH 40/64] fix: report CLI version from app bundle --- Sources/CodexBarCLI/CLIIO.swift | 37 +++++++++++++++++- Tests/CodexBarTests/CLIEntryTests.swift | 52 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index fe42751cb..160d0ef13 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -12,7 +12,7 @@ extension CodexBarCLI { } static func printVersion() -> Never { - if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + if let version = currentVersion() { print("CodexBar \(version)") } else { print("CodexBar") @@ -21,7 +21,7 @@ extension CodexBarCLI { } static func printHelp(for command: String?) -> Never { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let version = self.currentVersion() ?? "unknown" switch command { case "usage": print(Self.usageHelp(version: version)) @@ -35,6 +35,39 @@ extension CodexBarCLI { Self.platformExit(0) } + static func currentVersion( + bundle: Bundle = .main, + executablePath: String? = CommandLine.arguments.first) -> String? + { + if let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String { + return version + } + guard let executablePath, !executablePath.isEmpty else { return nil } + + let executableURL = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath() + return Self.containingAppVersion(for: executableURL) + } + + static func containingAppVersion(for executableURL: URL) -> String? { + var currentURL = executableURL.deletingLastPathComponent() + let fileManager = FileManager.default + + while currentURL.path != currentURL.deletingLastPathComponent().path { + if currentURL.pathExtension == "app" { + let infoURL = currentURL + .appendingPathComponent("Contents") + .appendingPathComponent("Info.plist") + guard let data = fileManager.contents(atPath: infoURL.path), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + else { return nil } + return plist["CFBundleShortVersionString"] as? String + } + currentURL.deleteLastPathComponent() + } + + return nil + } + static func platformExit(_ code: Int32) -> Never { #if canImport(Darwin) Darwin.exit(code) diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index daa8f174f..0aa92f786 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -44,6 +44,58 @@ struct CLIEntryTests { #expect(header.contains("cli")) } + @Test + func `CLI version falls back to containing app bundle`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cli-version-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true) + try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true) + + let infoURL = contentsURL.appendingPathComponent("Info.plist") + let plist: [String: Any] = ["CFBundleShortVersionString": "9.8.7"] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: infoURL) + + let helperURL = helpersURL.appendingPathComponent("CodexBarCLI") + try Data().write(to: helperURL) + + #expect(CodexBarCLI.containingAppVersion(for: helperURL) == "9.8.7") + } + + @Test + func `CLI version follows symlinked helper`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cli-version-symlink-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true) + let emptyBundleURL = root.appendingPathComponent("Empty.bundle", isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true) + let binURL = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: emptyBundleURL, withIntermediateDirectories: true) + + let infoURL = contentsURL.appendingPathComponent("Info.plist") + let plist: [String: Any] = ["CFBundleShortVersionString": "2.4.6"] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: infoURL) + + let helperURL = helpersURL.appendingPathComponent("CodexBarCLI") + try Data().write(to: helperURL) + + let symlinkURL = binURL.appendingPathComponent("codexbar") + try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: helperURL) + + let emptyBundle = try #require(Bundle(url: emptyBundleURL)) + #expect(CodexBarCLI.currentVersion(bundle: emptyBundle, executablePath: symlinkURL.path) == "2.4.6") + } + @Test func `render open AI web dashboard text includes summary`() { let event = CreditEvent( From e44161fe44f012a53d840b5149b17b220e66b17a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 05:05:55 +0100 Subject: [PATCH 41/64] chore: start 0.23 development --- CHANGELOG.md | 5 +++++ version.env | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1892c880c..0a017183c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.23 — Unreleased + +### Changes +- Development ongoing. + ## 0.22 — 2026-04-21 ### Highlights diff --git a/version.env b/version.env index 0423e7bb1..073e2844f 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.22 -BUILD_NUMBER=57 +MARKETING_VERSION=0.23 +BUILD_NUMBER=58 From d640a0fc765bbcb745b7bee349574429b01540c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 19:01:56 +0100 Subject: [PATCH 42/64] fix: clean up cached CLI probe processes --- CHANGELOG.md | 3 +++ .../CodexBarCore/Host/PTY/TTYCommandRunner.swift | 13 +++++++++++++ .../Providers/Claude/ClaudeCLISession.swift | 13 +++++++++++++ .../Providers/Codex/CodexCLISession.swift | 13 +++++++++++++ Tests/CodexBarTests/TTYCommandRunnerTests.swift | 13 +++++++++++++ 5 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a017183c..1b9c8893e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changes - Development ongoing. +### Fixes +- Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. + ## 0.22 — 2026-04-21 ### Highlights diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index d55bff859..7608ef11b 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -175,6 +175,19 @@ public struct TTYCommandRunner { } } + @discardableResult + static func registerActiveProcessForAppShutdown(pid: pid_t, binary: String) -> Bool { + TTYCommandRunnerActiveProcessRegistry.register(pid: pid, binary: binary) + } + + static func updateActiveProcessGroupForAppShutdown(pid: pid_t, processGroup: pid_t?) { + TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: processGroup) + } + + static func unregisterActiveProcessForAppShutdown(pid: pid_t) { + TTYCommandRunnerActiveProcessRegistry.unregister(pid: pid) + } + private static func resolveShutdownTargets( _ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)], hostProcessGroup: pid_t, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index 9ae1bc052..b96908e11 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -298,9 +298,21 @@ actor ClaudeCLISession { } let pid = proc.processIdentifier + guard TTYCommandRunner.registerActiveProcessForAppShutdown( + pid: pid, + binary: URL(fileURLWithPath: binary).lastPathComponent) + else { + proc.terminate() + kill(pid, SIGKILL) + try? primaryHandle.close() + try? secondaryHandle.close() + throw SessionError.launchFailed("App shutdown in progress") + } + var processGroup: pid_t? if setpgid(pid, pid) == 0 { processGroup = pid + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: processGroup) } self.process = proc @@ -354,6 +366,7 @@ actor ClaudeCLISession { } kill(proc.processIdentifier, SIGKILL) } + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier) } self.process = nil diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift index 8a6c811e8..2238fc0b2 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift @@ -300,9 +300,21 @@ actor CodexCLISession { } let pid = proc.processIdentifier + guard TTYCommandRunner.registerActiveProcessForAppShutdown( + pid: pid, + binary: resolvedURL.lastPathComponent) + else { + proc.terminate() + kill(pid, SIGKILL) + try? primaryHandle.close() + try? secondaryHandle.close() + throw SessionError.launchFailed("App shutdown in progress") + } + var processGroup: pid_t? if setpgid(pid, pid) == 0 { processGroup = pid + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: processGroup) } self.process = proc @@ -341,6 +353,7 @@ actor CodexCLISession { } kill(proc.processIdentifier, SIGKILL) } + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier) } self.process = nil diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index 78c6419ea..2bfcebc85 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -35,6 +35,19 @@ struct TTYCommandRunnerEnvTests { #expect(TTYCommandRunner._test_trackedProcessCount() == 0) } + @Test + func `cached CLI sessions share shutdown tracking`() { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } + + #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex")) + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 1) + + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } + @Test func `tracked process helpers ignore invalid PID`() { TTYCommandRunner._test_resetTrackedProcesses() From d4cb96778edda6078cdbf077ed56d98550d9a9da Mon Sep 17 00:00:00 2001 From: Chenhao Yang Date: Tue, 21 Apr 2026 18:43:01 -0500 Subject: [PATCH 43/64] Fix widget App Intents packaging --- Scripts/package_app.sh | 81 +++++++++++++++++++ .../CodexBarWidgetProvider.swift | 28 ++++--- .../CodexBarWidgetProviderTests.swift | 11 +++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 9e62c83dc..ac8842dab 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -98,6 +98,86 @@ path.write_text(text) PY } +generate_widget_appintents_metadata() { + local widget_resources_dir="$1" + local xcode_conf + local host_arch + local derived_dir + local build_dir + local object_dir + local source_file_list + local const_values_list + local dependency_metadata + local static_dependency_metadata + local appintents_tool + local sdk_root + local swiftc_path + local toolchain_dir + local xcode_version + + xcode_conf="Release" + if [[ "$LOWER_CONF" == "debug" ]]; then + xcode_conf="Debug" + fi + + host_arch=$(uname -m) + derived_dir="$ROOT/.build/xcode-widget-metadata-${LOWER_CONF}" + build_dir="$derived_dir/Build/Intermediates.noindex/CodexBar.build/${xcode_conf}/CodexBarWidget.build" + object_dir="$build_dir/Objects-normal/${host_arch}" + source_file_list="$object_dir/CodexBarWidget.SwiftFileList" + const_values_list="$object_dir/CodexBarWidget.SwiftConstValuesFileList" + dependency_metadata="$build_dir/CodexBarWidget.DependencyMetadataFileList" + static_dependency_metadata="$build_dir/CodexBarWidget.DependencyStaticMetadataFileList" + + appintents_tool=$(xcrun --find appintentsmetadataprocessor) + sdk_root=$(xcrun --sdk macosx --show-sdk-path) + swiftc_path=$(xcrun --find swiftc) + toolchain_dir=$(dirname "$(dirname "$(dirname "$swiftc_path")")") + xcode_version=$(xcodebuild -version | awk '/Build version/ { print $3 }') + + rm -rf "$derived_dir" + xcodebuild \ + -workspace "$ROOT/.swiftpm/xcode/package.xcworkspace" \ + -scheme CodexBarWidget \ + -configuration "$xcode_conf" \ + -destination "platform=macOS,arch=${host_arch}" \ + -derivedDataPath "$derived_dir" \ + build >/dev/null + + if [[ ! -f "$source_file_list" ]]; then + echo "ERROR: Missing App Intents metadata inputs for CodexBarWidget." >&2 + exit 1 + fi + + find "$object_dir" -name '*.swiftconstvalues' | sort > "$const_values_list" + if [[ ! -s "$const_values_list" ]]; then + echo "ERROR: Missing App Intents const-values outputs for CodexBarWidget." >&2 + exit 1 + fi + rm -rf "$widget_resources_dir/Metadata.appintents" + mkdir -p "$widget_resources_dir" + + "$appintents_tool" \ + --output "$widget_resources_dir" \ + --toolchain-dir "$toolchain_dir" \ + --module-name CodexBarWidget \ + --sdk-root "$sdk_root" \ + --xcode-version "$xcode_version" \ + --platform-family macOS \ + --deployment-target 14.0 \ + --target-triple "${host_arch}-apple-macos14.0" \ + --source-file-list "$source_file_list" \ + --swift-const-vals-list "$const_values_list" \ + --metadata-file-list "$dependency_metadata" \ + --static-metadata-file-list "$static_dependency_metadata" \ + --force >/dev/null + + if [[ ! -f "$widget_resources_dir/Metadata.appintents/extract.actionsdata" ]]; then + echo "ERROR: Failed to generate App Intents metadata for CodexBarWidget." >&2 + exit 1 + fi +} + KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then swift build -c "$CONF" --arch "${ARCH_LIST[0]}" @@ -304,6 +384,7 @@ if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then PLIST install_binary "CodexBarWidget" "$WIDGET_APP/Contents/MacOS/CodexBarWidget" + generate_widget_appintents_metadata "$WIDGET_APP/Contents/Resources" fi # Embed Sparkle.framework if [[ -d ".build/$CONF/Sparkle.framework" ]]; then diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index f5f5187e3..b7a44dace 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -100,7 +100,7 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { static let description = IntentDescription("Select the provider to display in the widget.") @Parameter(title: "Provider") - var provider: ProviderChoice + var provider: ProviderChoice? init() { self.provider = .codex @@ -132,10 +132,10 @@ struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { static let description = IntentDescription("Select the provider and metric to display.") @Parameter(title: "Provider") - var provider: ProviderChoice + var provider: ProviderChoice? @Parameter(title: "Metric") - var metric: CompactMetric + var metric: CompactMetric? init() { self.provider = .codex @@ -149,6 +149,14 @@ struct CodexBarWidgetEntry: TimelineEntry { let snapshot: WidgetSnapshot } +func resolvedWidgetProvider(_ choice: ProviderChoice?) -> UsageProvider { + choice?.provider ?? .codex +} + +func resolvedCompactMetric(_ metric: CompactMetric?) -> CompactMetric { + metric ?? .credits +} + struct CodexBarCompactEntry: TimelineEntry { let date: Date let provider: UsageProvider @@ -172,7 +180,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { } func snapshot(for configuration: ProviderSelectionIntent, in context: Context) async -> CodexBarWidgetEntry { - let provider = configuration.provider.provider + let provider = resolvedWidgetProvider(configuration.provider) return CodexBarWidgetEntry( date: Date(), provider: provider, @@ -183,7 +191,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { for configuration: ProviderSelectionIntent, in context: Context) async -> Timeline { - let provider = configuration.provider.provider + let provider = resolvedWidgetProvider(configuration.provider) let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) @@ -249,11 +257,12 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry { - let provider = configuration.provider.provider + let provider = resolvedWidgetProvider(configuration.provider) + let metric = resolvedCompactMetric(configuration.metric) return CodexBarCompactEntry( date: Date(), provider: provider, - metric: configuration.metric, + metric: metric, snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()) } @@ -261,12 +270,13 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { for configuration: CompactMetricSelectionIntent, in context: Context) async -> Timeline { - let provider = configuration.provider.provider + let provider = resolvedWidgetProvider(configuration.provider) + let metric = resolvedCompactMetric(configuration.metric) let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), provider: provider, - metric: configuration.metric, + metric: metric, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 56c376811..e5039f217 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -104,4 +104,15 @@ struct CodexBarWidgetProviderTests { #expect(rows == [WidgetUsageRow(id: "weekly", title: "Weekly", percentLeft: 75)]) } + + @Test + func `widget provider resolution falls back to codex when provider intent is nil`() { + #expect(resolvedWidgetProvider(nil) == UsageProvider.codex) + } + + @Test + func `compact widget resolution falls back to defaults when intent values are nil`() { + #expect(resolvedWidgetProvider(nil) == UsageProvider.codex) + #expect(resolvedCompactMetric(nil) == CompactMetric.credits) + } } From 592cfd56a57971ed77fe2decfba49deb0f0ec1e3 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Wed, 22 Apr 2026 09:17:58 +0300 Subject: [PATCH 44/64] fix(widget): use app intent parameter defaults --- .../CodexBarWidgetProvider.swift | 32 +++++++------------ .../CodexBarWidgetProviderTests.swift | 13 ++++---- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index b7a44dace..01075cabe 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -99,8 +99,8 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Provider" static let description = IntentDescription("Select the provider to display in the widget.") - @Parameter(title: "Provider") - var provider: ProviderChoice? + @Parameter(title: "Provider", default: .codex) + var provider: ProviderChoice init() { self.provider = .codex @@ -131,11 +131,11 @@ struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Provider + Metric" static let description = IntentDescription("Select the provider and metric to display.") - @Parameter(title: "Provider") - var provider: ProviderChoice? + @Parameter(title: "Provider", default: .codex) + var provider: ProviderChoice - @Parameter(title: "Metric") - var metric: CompactMetric? + @Parameter(title: "Metric", default: .credits) + var metric: CompactMetric init() { self.provider = .codex @@ -149,14 +149,6 @@ struct CodexBarWidgetEntry: TimelineEntry { let snapshot: WidgetSnapshot } -func resolvedWidgetProvider(_ choice: ProviderChoice?) -> UsageProvider { - choice?.provider ?? .codex -} - -func resolvedCompactMetric(_ metric: CompactMetric?) -> CompactMetric { - metric ?? .credits -} - struct CodexBarCompactEntry: TimelineEntry { let date: Date let provider: UsageProvider @@ -180,7 +172,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { } func snapshot(for configuration: ProviderSelectionIntent, in context: Context) async -> CodexBarWidgetEntry { - let provider = resolvedWidgetProvider(configuration.provider) + let provider = configuration.provider.provider return CodexBarWidgetEntry( date: Date(), provider: provider, @@ -191,7 +183,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { for configuration: ProviderSelectionIntent, in context: Context) async -> Timeline { - let provider = resolvedWidgetProvider(configuration.provider) + let provider = configuration.provider.provider let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) @@ -257,8 +249,8 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry { - let provider = resolvedWidgetProvider(configuration.provider) - let metric = resolvedCompactMetric(configuration.metric) + let provider = configuration.provider.provider + let metric = configuration.metric return CodexBarCompactEntry( date: Date(), provider: provider, @@ -270,8 +262,8 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { for configuration: CompactMetricSelectionIntent, in context: Context) async -> Timeline { - let provider = resolvedWidgetProvider(configuration.provider) - let metric = resolvedCompactMetric(configuration.metric) + let provider = configuration.provider.provider + let metric = configuration.metric let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index e5039f217..e951d59db 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -106,13 +106,12 @@ struct CodexBarWidgetProviderTests { } @Test - func `widget provider resolution falls back to codex when provider intent is nil`() { - #expect(resolvedWidgetProvider(nil) == UsageProvider.codex) - } + func `widget configuration intents default to codex and credits`() { + let providerIntent = ProviderSelectionIntent() + let compactIntent = CompactMetricSelectionIntent() - @Test - func `compact widget resolution falls back to defaults when intent values are nil`() { - #expect(resolvedWidgetProvider(nil) == UsageProvider.codex) - #expect(resolvedCompactMetric(nil) == CompactMetric.credits) + #expect(providerIntent.provider == .codex) + #expect(compactIntent.provider == .codex) + #expect(compactIntent.metric == .credits) } } From 88dee3cbf36ee0ed3bacb6de6af67876b04be651 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Wed, 22 Apr 2026 09:27:50 +0300 Subject: [PATCH 45/64] docs(changelog): credit widget metadata fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b9c8893e..e82ccd864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixes - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. +- Widgets: package `Metadata.appintents` for the widget extension and use App Intent parameter defaults so configurable widgets load correctly in WidgetKit. Thanks @vincentyangch! ## 0.22 — 2026-04-21 From 06e732e29fa14799f90400250ef2c5da25c0ddce Mon Sep 17 00:00:00 2001 From: AISupplyGuy Date: Wed, 22 Apr 2026 14:22:02 -0700 Subject: [PATCH 46/64] Add Claude Design and Routines usage bars (#740) * Add Claude Design and Routines usage bars * Polish Claude labels and OpenAI cookie access errors * Prefer populated alias over null in OAuth extra usage decoding Per Codex review on PR #1: decodeWindowWithSource returned on the first matching key in the payload even when its value was null, so a response with seven_day_design: null followed by seven_day_omelette: {...} picked the null alias and rendered a 0% bar. Scan all aliases, prefer a populated one, and fall back to the first null-valued key only to keep the bar visible when the API returns a known key with null payload. * Fix Claude usage lint cleanup * Drop OpenAI cookie import changes * Place Claude Sonnet bar before extras --------- Co-authored-by: Ratul Sarna --- Sources/CodexBar/MenuCardView.swift | 45 +++++-- .../ClaudeOAuth/ClaudeOAuthUsageFetcher.swift | 95 ++++++++++++-- .../Claude/ClaudeProviderDescriptor.swift | 1 + .../Providers/Claude/ClaudeUsageFetcher.swift | 61 ++++++++- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 38 ++++-- .../ClaudeWebExtraRateWindowParser.swift | 118 ++++++++++++++++++ Sources/CodexBarCore/UsageFetcher.swift | 19 +++ Tests/CodexBarTests/ClaudeOAuthTests.swift | 61 +++++++++ .../ClaudeWebUsageExtraWindowTests.swift | 48 +++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 68 ++++++++++ 10 files changed, 524 insertions(+), 30 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift create mode 100644 Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 6356c51a6..18b77df05 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -965,18 +965,6 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, zaiTimeDetail: zaiTimeDetail)) } - if input.provider == .kilo, - metrics.contains(where: { $0.id == "primary" }), - metrics.contains(where: { $0.id == "secondary" }) - { - metrics.sort { lhs, rhs in - let kiloOrder: [String: Int] = [ - "secondary": 0, - "primary": 1, - ] - return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max) - } - } if input.metadata.supportsOpus, let opus = snapshot.tertiary { var tertiaryDetailText: String? if input.provider == .alibaba, @@ -1004,6 +992,39 @@ extension UsageMenuCardView.Model { pacePercent: nil, paceOnTop: true)) } + if let extraRateWindows = snapshot.extraRateWindows { + metrics.append(contentsOf: extraRateWindows.map { namedWindow in + Metric( + id: namedWindow.id, + title: namedWindow.title, + percent: Self.clamped( + input.usageBarsShowUsed + ? namedWindow.window.usedPercent + : namedWindow.window.remainingPercent), + percentStyle: percentStyle, + resetText: Self.resetText( + for: namedWindow.window, + style: input.resetTimeDisplayStyle, + now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + }) + } + if input.provider == .kilo, + metrics.contains(where: { $0.id == "primary" }), + metrics.contains(where: { $0.id == "secondary" }) + { + metrics.sort { lhs, rhs in + let kiloOrder: [String: Int] = [ + "secondary": 0, + "primary": 1, + ] + return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max) + } + } if let codexProjection = input.codexProjection, codexProjection.supplementalMetrics.contains(.codeReview), diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 22003fb23..33e8677e7 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -114,17 +114,96 @@ struct OAuthUsageResponse: Decodable { let sevenDayOAuthApps: OAuthUsageWindow? let sevenDayOpus: OAuthUsageWindow? let sevenDaySonnet: OAuthUsageWindow? + let sevenDayDesign: OAuthUsageWindow? + let sevenDayRoutines: OAuthUsageWindow? + let sevenDayDesignSourceKey: String? + let sevenDayRoutinesSourceKey: String? let iguanaNecktie: OAuthUsageWindow? let extraUsage: OAuthExtraUsage? - enum CodingKeys: String, CodingKey { - case fiveHour = "five_hour" - case sevenDay = "seven_day" - case sevenDayOAuthApps = "seven_day_oauth_apps" - case sevenDayOpus = "seven_day_opus" - case sevenDaySonnet = "seven_day_sonnet" - case iguanaNecktie = "iguana_necktie" - case extraUsage = "extra_usage" + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.fiveHour = Self.decodeWindow(in: container, keys: ["five_hour"]) + self.sevenDay = Self.decodeWindow(in: container, keys: ["seven_day"]) + self.sevenDayOAuthApps = Self.decodeWindow(in: container, keys: ["seven_day_oauth_apps"]) + self.sevenDayOpus = Self.decodeWindow(in: container, keys: ["seven_day_opus"]) + self.sevenDaySonnet = Self.decodeWindow(in: container, keys: ["seven_day_sonnet"]) + let design = Self.decodeWindowWithSource(in: container, keys: [ + "seven_day_design", + "seven_day_claude_design", + "claude_design", + "design", + "seven_day_omelette", + "omelette", + "omelette_promotional", + ]) + self.sevenDayDesign = design.window + self.sevenDayDesignSourceKey = design.sourceKey + let routines = Self.decodeWindowWithSource(in: container, keys: [ + "seven_day_routines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "cowork", + ]) + self.sevenDayRoutines = routines.window + self.sevenDayRoutinesSourceKey = routines.sourceKey + self.iguanaNecktie = Self.decodeWindow(in: container, keys: ["iguana_necktie"]) + self.extraUsage = Self.decodeValue(in: container, keys: ["extra_usage"]) + } + + private static func decodeWindow( + in container: KeyedDecodingContainer, + keys: [String]) -> OAuthUsageWindow? + { + self.decodeValue(in: container, keys: keys) + } + + private static func decodeWindowWithSource( + in container: KeyedDecodingContainer, + keys: [String]) -> (window: OAuthUsageWindow?, sourceKey: String?) + { + var firstNullKey: String? + for keyName in keys { + guard let key = DynamicCodingKey(stringValue: keyName) else { continue } + guard container.contains(key) else { continue } + if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) { + return (value, keyName) + } + if firstNullKey == nil { + firstNullKey = keyName + } + } + return (nil, firstNullKey) + } + + private static func decodeValue( + in container: KeyedDecodingContainer, + keys: [String]) -> T? + { + for keyName in keys { + guard let key = DynamicCodingKey(stringValue: keyName) else { continue } + if let value = try? container.decodeIfPresent(T.self, forKey: key) { + return value + } + } + return nil + } +} + +private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + nil } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 50a54a301..20db61312 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -303,6 +303,7 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { primary: usage.primary, secondary: usage.secondary, tertiary: usage.opus, + extraRateWindows: usage.extraRateWindows.isEmpty ? nil : usage.extraRateWindows, providerCost: usage.providerCost, updatedAt: usage.updatedAt, identity: identity) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index dd4848a53..82d919865 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -10,6 +10,7 @@ public struct ClaudeUsageSnapshot: Sendable { public let primary: RateWindow public let secondary: RateWindow? public let opus: RateWindow? + public let extraRateWindows: [NamedRateWindow] public let providerCost: ProviderCostSnapshot? public let updatedAt: Date public let accountEmail: String? @@ -21,6 +22,7 @@ public struct ClaudeUsageSnapshot: Sendable { primary: RateWindow, secondary: RateWindow?, opus: RateWindow?, + extraRateWindows: [NamedRateWindow] = [], providerCost: ProviderCostSnapshot? = nil, updatedAt: Date, accountEmail: String?, @@ -31,6 +33,7 @@ public struct ClaudeUsageSnapshot: Sendable { self.primary = primary self.secondary = secondary self.opus = opus + self.extraRateWindows = extraRateWindows self.providerCost = providerCost self.updatedAt = updatedAt self.accountEmail = accountEmail @@ -841,6 +844,7 @@ extension ClaudeUsageFetcher { let modelSpecific = makeWindow( usage.sevenDaySonnet ?? usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) + let extraRateWindows = Self.oauthExtraRateWindows(from: usage) let loginMethod = ClaudePlan.oauthLoginMethod(rateLimitTier: credentials.rateLimitTier) let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod) @@ -849,6 +853,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: weekly, opus: modelSpecific, + extraRateWindows: extraRateWindows, providerCost: providerCost, updatedAt: Date(), accountEmail: nil, @@ -887,6 +892,50 @@ extension ClaudeUsageFetcher { (used: used / 100.0, limit: limit / 100.0) } + private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { + let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [ + ( + id: "claude-design", + title: "Designs", + window: usage.sevenDayDesign, + sourceKey: usage.sevenDayDesignSourceKey), + ( + id: "claude-routines", + title: "Daily Routines", + window: usage.sevenDayRoutines, + sourceKey: usage.sevenDayRoutinesSourceKey), + ] + if let designKey = usage.sevenDayDesignSourceKey { + Self.log.debug("Claude OAuth extra usage key matched: design=\(designKey)") + } + if let routinesKey = usage.sevenDayRoutinesSourceKey { + Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)") + } + return definitions.compactMap { definition in + let utilization: Double + let resetDate: Date? + if let window = definition.window, let parsedUtilization = window.utilization { + utilization = parsedUtilization + resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) + } else if definition.sourceKey != nil { + // Keep product bars visible when the API returns a known key with null payload. + utilization = 0 + resetDate = nil + } else { + return nil + } + let resetDescription = resetDate.map(Self.formatResetDate) + return NamedRateWindow( + id: definition.id, + title: definition.title, + window: RateWindow( + usedPercent: utilization, + windowMinutes: Self.weeklyWindowMinutes, + resetsAt: resetDate, + resetDescription: resetDescription)) + } + } + // MARK: - Web API path (uses browser cookies) private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot { @@ -927,6 +976,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: secondary, opus: opus, + extraRateWindows: webData.extraRateWindows, providerCost: webData.extraUsageCost, updatedAt: Date(), accountEmail: webData.accountEmail, @@ -986,6 +1036,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: weekly, opus: opus, + extraRateWindows: [], providerCost: nil, updatedAt: Date(), accountEmail: snap.accountEmail, @@ -1009,13 +1060,17 @@ extension ClaudeUsageFetcher { Self.log.debug(msg) } } - // Only merge cost extras; keep identity fields from the primary data source. - if snapshot.providerCost == nil, let extra = webData.extraUsageCost { + // Only merge usage/cost extras; keep identity fields from the primary data source. + let mergedExtraRateWindows = snapshot.extraRateWindows.isEmpty ? webData.extraRateWindows : snapshot + .extraRateWindows + let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost + if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { return ClaudeUsageSnapshot( primary: snapshot.primary, secondary: snapshot.secondary, opus: snapshot.opus, - providerCost: extra, + extraRateWindows: mergedExtraRateWindows, + providerCost: mergedProviderCost, updatedAt: snapshot.updatedAt, accountEmail: snapshot.accountEmail, accountOrganization: snapshot.accountOrganization, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index c350d30c4..6fb3f41ea 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -84,6 +84,7 @@ public enum ClaudeWebAPIFetcher { public let weeklyPercentUsed: Double? public let weeklyResetsAt: Date? public let opusPercentUsed: Double? + public let extraRateWindows: [NamedRateWindow] public let extraUsageCost: ProviderCostSnapshot? public let accountOrganization: String? public let accountEmail: String? @@ -95,6 +96,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: Double?, weeklyResetsAt: Date?, opusPercentUsed: Double?, + extraRateWindows: [NamedRateWindow], extraUsageCost: ProviderCostSnapshot?, accountOrganization: String?, accountEmail: String?, @@ -105,6 +107,7 @@ public enum ClaudeWebAPIFetcher { self.weeklyPercentUsed = weeklyPercentUsed self.weeklyResetsAt = weeklyResetsAt self.opusPercentUsed = opusPercentUsed + self.extraRateWindows = extraRateWindows self.extraUsageCost = extraUsageCost self.accountOrganization = accountOrganization self.accountEmail = accountEmail @@ -195,6 +198,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: extra, accountOrganization: usage.accountOrganization, accountEmail: usage.accountEmail, @@ -207,6 +211,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: usage.extraUsageCost, accountOrganization: usage.accountOrganization, accountEmail: account.email, @@ -219,6 +224,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: usage.extraUsageCost, accountOrganization: name, accountEmail: usage.accountEmail, @@ -439,7 +445,7 @@ public enum ClaudeWebAPIFetcher { switch httpResponse.statusCode { case 200: - return try self.parseUsageResponse(data) + return try self.parseUsageResponse(data, logger: logger) case 401, 403: throw FetchError.unauthorized default: @@ -447,7 +453,7 @@ public enum ClaudeWebAPIFetcher { } } - private static func parseUsageResponse(_ data: Data) throws -> WebUsageData { + private static func parseUsageResponse(_ data: Data, logger: ((String) -> Void)? = nil) throws -> WebUsageData { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw FetchError.invalidResponse } @@ -480,12 +486,19 @@ public enum ClaudeWebAPIFetcher { } } - // Parse seven_day_opus (Opus-specific weekly) usage + // Parse seven_day_sonnet (preferred) / seven_day_opus usage var opusPercent: Double? - if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] { - if let utilization = sevenDayOpus["utilization"] as? Int { - opusPercent = Double(utilization) - } + if let sevenDaySonnet = json["seven_day_sonnet"] as? [String: Any] { + opusPercent = Self.percentValue(from: sevenDaySonnet["utilization"]) + } else if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] { + opusPercent = Self.percentValue(from: sevenDayOpus["utilization"]) + } + let extraRateParse = ClaudeWebExtraRateWindowParser.parse(from: json) + if let sourceKey = extraRateParse.sourceKeys["claude-design"] { + logger?("Usage API extra window key matched: design=\(sourceKey)") + } + if let sourceKey = extraRateParse.sourceKeys["claude-routines"] { + logger?("Usage API extra window key matched: routines=\(sourceKey)") } return WebUsageData( @@ -494,12 +507,23 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: weeklyPercent, weeklyResetsAt: weeklyResets, opusPercentUsed: opusPercent, + extraRateWindows: extraRateParse.windows, extraUsageCost: nil, accountOrganization: nil, accountEmail: nil, loginMethod: nil) } + private static func percentValue(from value: Any?) -> Double? { + if let intValue = value as? Int { + return Double(intValue) + } + if let doubleValue = value as? Double { + return doubleValue + } + return nil + } + // MARK: - Extra usage cost (Claude "Extra") private struct OverageSpendLimitResponse: Decodable { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift new file mode 100644 index 000000000..b1df6343c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift @@ -0,0 +1,118 @@ +import Foundation + +enum ClaudeWebExtraRateWindowParser { + private static let definitions: [(id: String, title: String, keys: [String])] = [ + ( + id: "claude-design", + title: "Designs", + keys: [ + "seven_day_design", + "seven_day_claude_design", + "claude_design", + "design", + "seven_day_omelette", + "omelette", + "omelette_promotional", + ]), + ( + id: "claude-routines", + title: "Daily Routines", + keys: [ + "seven_day_routines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "cowork", + ]), + ] + + static func parse(from json: [String: Any]) -> (windows: [NamedRateWindow], sourceKeys: [String: String]) { + var windows: [NamedRateWindow] = [] + var sourceKeys: [String: String] = [:] + windows.reserveCapacity(Self.definitions.count) + + for definition in Self.definitions { + if let foundWindow = Self.firstUsageWindow(in: json, keys: definition.keys) { + let rawWindow = foundWindow.window + guard let utilization = Self.percentValue(from: rawWindow["utilization"]) else { continue } + let resetsAt = (rawWindow["resets_at"] as? String).flatMap(Self.parseISO8601Date) + windows.append(Self.namedWindow( + id: definition.id, + title: definition.title, + usedPercent: utilization, + resetsAt: resetsAt)) + sourceKeys[definition.id] = foundWindow.sourceKey + continue + } + + // Some accounts expose the key with null payloads (for example `seven_day_cowork: null`). + // Preserve the bar in that case with a 0% window so the product section remains visible. + if let key = Self.firstUsageKey(in: json, keys: definition.keys) { + windows.append(Self.namedWindow( + id: definition.id, + title: definition.title, + usedPercent: 0, + resetsAt: nil)) + sourceKeys[definition.id] = key + } + } + return (windows, sourceKeys) + } + + private static func namedWindow( + id: String, + title: String, + usedPercent: Double, + resetsAt: Date?) -> NamedRateWindow + { + NamedRateWindow( + id: id, + title: title, + window: RateWindow( + usedPercent: usedPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: resetsAt, + resetDescription: nil)) + } + + private static func firstUsageWindow( + in json: [String: Any], + keys: [String]) -> (window: [String: Any], sourceKey: String)? + { + for key in keys { + if let window = json[key] as? [String: Any] { + return (window, key) + } + } + return nil + } + + private static func firstUsageKey(in json: [String: Any], keys: [String]) -> String? { + for key in keys where json.keys.contains(key) { + return key + } + return nil + } + + private static func percentValue(from value: Any?) -> Double? { + if let intValue = value as? Int { + return Double(intValue) + } + if let doubleValue = value as? Double { + return doubleValue + } + return nil + } + + private static func parseISO8601Date(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 24d413717..22ebd19c7 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -28,6 +28,18 @@ public struct RateWindow: Codable, Equatable, Sendable { } } +public struct NamedRateWindow: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let window: RateWindow + + public init(id: String, title: String, window: RateWindow) { + self.id = id + self.title = title + self.window = window + } +} + public struct ProviderIdentitySnapshot: Codable, Sendable { public let providerID: UsageProvider? public let accountEmail: String? @@ -60,6 +72,7 @@ public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? @@ -72,6 +85,7 @@ public struct UsageSnapshot: Codable, Sendable { case primary case secondary case tertiary + case extraRateWindows case providerCost case openRouterUsage case updatedAt @@ -85,6 +99,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, + extraRateWindows: [NamedRateWindow]? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, @@ -96,6 +111,7 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.extraRateWindows = extraRateWindows self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage @@ -110,6 +126,7 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary) self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) + self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time @@ -140,6 +157,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.primary, forKey: .primary) try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) + try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encode(self.updatedAt, forKey: .updatedAt) @@ -224,6 +242,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: self.primary, secondary: self.secondary, tertiary: self.tertiary, + extraRateWindows: self.extraRateWindows, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index cb3541bc1..d62d74207 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -81,6 +81,67 @@ struct ClaudeOAuthTests { #expect(snap.loginMethod == "Claude Pro") } + @Test + func `maps O auth design and routines usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_design": { "utilization": 44, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_routines": { "utilization": 18, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.count == 2) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.title == "Designs") + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 44) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.title == "Daily Routines") + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 18) + } + + @Test + func `maps O auth omelette and cowork usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_cowork": { "utilization": 9, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.count == 2) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 29) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 9) + } + + @Test + func `maps O auth null cowork as zero routines window`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_cowork": null + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + } + + @Test + func `prefers populated alias over null alias in mixed payload`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_design": null, + "seven_day_omelette": { "utilization": 37, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_routines": null, + "seven_day_cowork": { "utilization": 14, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 37) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 14) + } + @Test func `maps O auth extra usage`() throws { // OAuth API returns values in cents (minor units), same as Web API. diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift new file mode 100644 index 000000000..324332297 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeWebUsageExtraWindowTests { + @Test + func `parses claude web API sonnet usage response`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 6, "resets_at": "2025-12-30T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.opusPercentUsed == 6) + } + + @Test + func `parses claude web API omelette and cowork usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": { "utilization": 11, "resets_at": "2025-12-31T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.count == 2) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 26) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11) + } + + @Test + func `parses claude web API cowork null as zero routines window`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": null + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index b4e3b7522..ee8fce91a 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -124,6 +124,74 @@ struct MenuCardModelTests { #expect(model.planText == "Max") } + @Test + func `claude model includes design and routines bars when present`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 8, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 16, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7800), + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "claude-design", + title: "Designs", + window: RateWindow( + usedPercent: 31, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(8200), + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 7, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(9200), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "plus"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Designs", "Daily Routines"]) + } + @Test func `shows error subtitle when present`() throws { let metadata = try #require(ProviderDefaults.metadata[.codex]) From a5100c3f4ea30a17b2ae3928cdb01e844b136226 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 23 Apr 2026 11:39:41 +0300 Subject: [PATCH 47/64] fix(ui): align menu width (#784) * fix(ui): align menu card width with menu accessories * fix(scripts): detect signing identities correctly * fix(menu): prevent wrapped status text from widening menu * fix: fail on ambiguous signing identities and rebuild stale-width menus * fix(menu): remove reserved accessory gutter * fix(scripts): revert signing identity changes * fix(menu): align hosted submenus with parent width --- .../StatusItemController+HostedSubmenus.swift | 4 +- .../CodexBar/StatusItemController+Menu.swift | 119 ++++++++++++------ ...tatusItemController+UsageHistoryMenu.swift | 3 +- .../StatusItemControllerMenuTests.swift | 16 +++ Tests/CodexBarTests/StatusMenuTests.swift | 114 ++++++++++++++++- 5 files changed, 214 insertions(+), 42 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 1676db9b1..c716636b2 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -3,8 +3,6 @@ import CodexBarCore import SwiftUI extension StatusItemController { - private static let hostedSubviewWidth: CGFloat = 310 - func makeHostedSubviewPlaceholderMenu(chartID: String, provider: UsageProvider? = nil) -> NSMenu { let submenu = NSMenu() submenu.delegate = self @@ -25,7 +23,7 @@ extension StatusItemController { return } - let width = Self.hostedSubviewWidth + let width = self.renderedMenuWidth(for: menu.supermenu ?? menu) menu.removeAllItems() let didHydrate: Bool = switch chartID { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 88cb625a5..48a295e5e 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -29,9 +29,25 @@ extension StatusItemController { } } - private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { - _ = menu - return Self.menuCardBaseWidth + private func menuCardWidth( + for providers: [UsageProvider], + sections: [MenuDescriptor.Section]) -> CGFloat + { + _ = providers + let baselineWidth = Self.menuCardBaseWidth + return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) + } + + private func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { + let measuringMenu = NSMenu() + measuringMenu.autoenablesItems = false + self.addActionableSections(sections, to: measuringMenu, width: baseWidth) + return ceil(measuringMenu.size.width) + } + + func renderedMenuWidth(for menu: NSMenu) -> CGFloat { + let measuredWidth = ceil(menu.size.width) + return max(measuredWidth, Self.menuCardBaseWidth) } func makeMenu() -> NSMenu { @@ -127,7 +143,6 @@ extension StatusItemController { } else { switcherSelection?.provider ?? provider } - let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu) let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex let codexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider) let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider) @@ -135,6 +150,16 @@ extension StatusItemController { let openAIContext = self.openAIWebContext( currentProvider: currentProvider, showAllTokenAccounts: showAllTokenAccounts) + let descriptor = MenuDescriptor.build( + provider: selectedProvider, + store: self.store, + settings: self.settings, + account: self.account, + managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + updateReady: self.updater.updateStatus.isUpdateReady, + includeContextualActions: !isOverviewSelected) + let menuWidth = self.menuCardWidth(for: enabledProviders, sections: descriptor.sections) let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } @@ -145,6 +170,10 @@ extension StatusItemController { let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && ((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher)) + let reusableRowWidthsMatch = self.reusableFixedWidthRows(in: menu).allSatisfy { item in + guard let view = item.view else { return false } + return abs(view.frame.width - menuWidth) <= 0.5 + } let canSmartUpdate = self.shouldMergeIcons && enabledProviders.count > 1 && !isOverviewSelected && @@ -154,6 +183,7 @@ extension StatusItemController { switcherOverviewAvailabilityMatches && tokenSwitcherCompatible && codexSwitcherCompatible && + reusableRowWidthsMatch && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView @@ -168,22 +198,12 @@ extension StatusItemController { } menu.removeAllItems() - - let descriptor = MenuDescriptor.build( - provider: selectedProvider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, - includeContextualActions: !isOverviewSelected) - self.addProviderSwitcherIfNeeded( to: menu, enabledProviders: enabledProviders, includesOverview: includesOverview, - selection: switcherSelection ?? .provider(currentProvider)) + selection: switcherSelection ?? .provider(currentProvider), + width: menuWidth) // Track which providers the switcher was built with for smart update detection if self.shouldMergeIcons, enabledProviders.count > 1 { self.lastSwitcherProviders = enabledProviders @@ -191,9 +211,9 @@ extension StatusItemController { self.lastMergedSwitcherSelection = switcherSelection self.lastSwitcherIncludesOverview = includesOverview } - self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay) + self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay, width: menuWidth) self.lastCodexAccountMenuDisplay = codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) + self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay, width: menuWidth) let menuContext = MenuCardContext( currentProvider: currentProvider, selectedProvider: selectedProvider, @@ -218,13 +238,36 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) { menu.addItem(.separator()) } } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) } + private func reusableFixedWidthRows(in menu: NSMenu) -> [NSMenuItem] { + guard !menu.items.isEmpty else { return [] } + + var reusableRows: [NSMenuItem] = [] + var index = 0 + if menu.items.first?.view is ProviderSwitcherView { + reusableRows.append(menu.items[0]) + index = 2 + } + if menu.items.count > index, + menu.items[index].view is CodexAccountSwitcherView + { + reusableRows.append(menu.items[index]) + index += 2 + } + if menu.items.count > index, + menu.items[index].view is TokenAccountSwitcherView + { + reusableRows.append(menu.items[index]) + } + return reusableRows + } + /// Smart update: only rebuild content sections when switching providers (keep the switcher intact). private func updateMenuContent( _ menu: NSMenu, @@ -277,7 +320,7 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) { menu.addItem(.separator()) } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) @@ -326,28 +369,30 @@ extension StatusItemController { to menu: NSMenu, enabledProviders: [UsageProvider], includesOverview: Bool, - selection: ProviderSwitcherSelection) + selection: ProviderSwitcherSelection, + width: CGFloat) { guard self.shouldMergeIcons, enabledProviders.count > 1 else { return } let switcherItem = self.makeProviderSwitcherItem( providers: enabledProviders, includesOverview: includesOverview, selected: selection, - menu: menu) + menu: menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } - private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?) { + private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?, width: CGFloat) { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu) + let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu, width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } - private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?) { + private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) { guard let display else { return } - let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu) + let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } @@ -578,7 +623,7 @@ extension StatusItemController { } private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { - let item = NSMenuItem(title: text, action: nil, keyEquivalent: "") + let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") let view = self.makeWrappedSecondaryTextView(text: text) let height = self.menuTextItemHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) @@ -631,13 +676,14 @@ extension StatusItemController { providers: [UsageProvider], includesOverview: Bool, selected: ProviderSwitcherSelection, - menu: NSMenu) -> NSMenuItem + menu: NSMenu, + width: CGFloat) -> NSMenuItem { let view = ProviderSwitcherView( providers: providers, selected: selected, includesOverview: includesOverview, - width: self.menuCardWidth(for: providers, menu: menu), + width: width, showsIcons: self.settings.switcherShowsIcons, iconProvider: { [weak self] provider in self?.switcherIcon(for: provider) ?? NSImage() @@ -672,12 +718,13 @@ extension StatusItemController { private func makeTokenAccountSwitcherItem( display: TokenAccountMenuDisplay, - menu: NSMenu) -> NSMenuItem + menu: NSMenu, + width: CGFloat) -> NSMenuItem { let view = TokenAccountSwitcherView( accounts: display.accounts, selectedIndex: display.activeIndex, - width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), + width: width, onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) @@ -698,12 +745,13 @@ extension StatusItemController { private func makeCodexAccountSwitcherItem( display: CodexAccountMenuDisplay, - menu: NSMenu) -> NSMenuItem + menu: NSMenu, + width: CGFloat) -> NSMenuItem { let view = CodexAccountSwitcherView( accounts: display.accounts, selectedAccountID: display.activeVisibleAccountID, - width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), + width: width, onSelect: { [weak self, weak menu] visibleAccountID in guard let self else { return } self.handleCodexVisibleAccountSelection(visibleAccountID, menu: menu) @@ -928,7 +976,7 @@ extension StatusItemController { } for item in cardItems { guard let view = item.view else { continue } - let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) + let width = self.renderedMenuWidth(for: menu) let height = self.menuCardHeight(for: view, width: width) view.frame = NSRect( origin: .zero, @@ -1287,8 +1335,7 @@ extension StatusItemController { } private func refreshHostedSubviewHeights(in menu: NSMenu) { - let enabledProviders = self.store.enabledProvidersForDisplay() - let width = self.menuCardWidth(for: enabledProviders, menu: menu) + let width = self.renderedMenuWidth(for: menu) for item in menu.items { guard let view = item.view else { continue } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index a2d5b9600..6cfed4206 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -10,9 +10,8 @@ private final class UsageHistoryMenuHostingView: NSHostingView Bool { + func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false } - let width: CGFloat = 310 let item = self.makeMenuCardItem( HStack(spacing: 0) { Text("Subscription Utilization") diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 2df1afef9..f6e2d2870 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -149,4 +150,19 @@ struct StatusItemControllerMenuTests { snapshot: snapshot)) #expect(snapshot.primary?.usedPercent == 10) } + + @Test + @MainActor + func `menu card width stays at base width when menu accessories are present`() { + let shortcutMenu = NSMenu() + let refreshItem = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "r") + shortcutMenu.addItem(refreshItem) + #expect(ceil(shortcutMenu.size.width) < 310) + + let submenuMenu = NSMenu() + let parentItem = NSMenuItem(title: "Session", action: nil, keyEquivalent: "") + parentItem.submenu = NSMenu(title: "Session") + submenuMenu.addItem(parentItem) + #expect(ceil(submenuMenu.size.width) < 310) + } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index c103bc7a3..0d0ec05d2 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -348,6 +348,61 @@ struct StatusMenuTests { } } + @Test + func `merged provider switch rebuilds stale width switcher rows`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(initialSwitcher != nil) + let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init) + initialSwitcher?.frame.size.width = 250 + + let nextProviderButton = self.switcherButtons(in: menu).first(where: { $0.state == .off }) + #expect(nextProviderButton != nil) + nextProviderButton?.performClick(nil) + + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(updatedSwitcher != nil) + if let initialSwitcherID, let updatedSwitcher { + #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher)) + #expect(updatedSwitcher.frame.width == 310) + } + } + @Test func `merged switcher includes overview tab when multiple providers enabled`() { self.disableMenuCardsForTesting() @@ -578,7 +633,7 @@ extension StatusMenuTests { let statusItem = menu.items.first(where: { $0.toolTip == statusText }) #expect(statusItem != nil) #expect(statusItem?.view != nil) - #expect(statusItem?.title == statusText) + #expect(statusItem?.title.isEmpty == true) #expect(statusItem?.view?.frame.width == 310) } @@ -716,6 +771,63 @@ extension StatusMenuTests { #expect(!titles.contains("Usage breakdown")) } + @Test + func `hosted chart submenu matches widened parent menu width`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.menuRefreshEnabled = false + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.menuRefreshEnabled = previousMenuRefresh + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let parentMenu = NSMenu() + parentMenu.autoenablesItems = false + let wideItem = NSMenuItem(title: String(repeating: "W", count: 60), action: nil, keyEquivalent: "") + parentMenu.addItem(wideItem) + + let submenu = controller.makeHostedSubviewPlaceholderMenu(chartID: StatusItemController.usageBreakdownChartID) + let submenuItem = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + submenuItem.submenu = submenu + parentMenu.addItem(submenuItem) + + let parentWidth = ceil(parentMenu.size.width) + #expect(parentWidth > 310) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + + let chartItem = submenu.items.first + #expect(chartItem?.representedObject as? String == StatusItemController.usageBreakdownChartID) + #expect(chartItem?.view != nil) + #expect(abs((chartItem?.view?.frame.width ?? 0) - parentWidth) <= 0.5) + } + @Test func `shows open AI web submenus when history exists`() throws { self.disableMenuCardsForTesting() From c6bde6c312df4c564a5f237ef6463e3af2b2aefd Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 23 Apr 2026 12:17:39 +0300 Subject: [PATCH 48/64] docs(changelog): note menu width alignment fixes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e82ccd864..fdae849f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixes - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. +- Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! - Widgets: package `Metadata.appintents` for the widget extension and use App Intent parameter defaults so configurable widgets load correctly in WidgetKit. Thanks @vincentyangch! ## 0.22 — 2026-04-21 From e7d270bd4bf9339ed60bc4a430ec7d3df2643377 Mon Sep 17 00:00:00 2001 From: Amoranio <122813308+amoranio@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:06:14 +0000 Subject: [PATCH 49/64] Copilot Provider Update Added clear information of the automatic copy/paste of device codes which maybe missed due to window load. This should make things slightly clearer. --- Sources/CodexBar/PreferencesProviderSettingsRows.swift | 7 +++++++ .../Providers/Copilot/CopilotProviderImplementation.swift | 1 + .../Providers/Shared/ProviderSettingsDescriptors.swift | 1 + 3 files changed, 9 insertions(+) diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 414f41c55..514c7e3ce 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -196,6 +196,13 @@ struct ProviderSettingsFieldRowView: View { } } } + + if let footer = self.field.footerText, !footer.isEmpty { + Text(footer) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 986d81f2f..593359329 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -31,6 +31,7 @@ struct CopilotProviderImplementation: ProviderImplementation { id: "copilot-api-token", title: "GitHub Login", subtitle: "Requires authentication via GitHub Device Flow.", + footerText: "Device code is copied to your clipboard — paste with ⌘V.", kind: .secure, placeholder: "Sign in via button below", binding: context.stringBinding(\.copilotAPIToken), diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index d5a85b8f7..81b752b9a 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -76,6 +76,7 @@ struct ProviderSettingsFieldDescriptor: Identifiable { let id: String let title: String let subtitle: String + var footerText: String? = nil let kind: Kind let placeholder: String? let binding: Binding From d8207f20081159cba6fe6e451e3b38aa5124f428 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 23 Apr 2026 21:45:45 +0530 Subject: [PATCH 50/64] Clarify Copilot device code hint --- .../Providers/Copilot/CopilotProviderImplementation.swift | 2 +- .../CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 593359329..6d400417c 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -31,7 +31,7 @@ struct CopilotProviderImplementation: ProviderImplementation { id: "copilot-api-token", title: "GitHub Login", subtitle: "Requires authentication via GitHub Device Flow.", - footerText: "Device code is copied to your clipboard — paste with ⌘V.", + footerText: "The device code is copied to your clipboard. Paste it into the GitHub page with ⌘V.", kind: .secure, placeholder: "Sign in via button below", binding: context.stringBinding(\.copilotAPIToken), diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 81b752b9a..7b408d8da 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -76,7 +76,7 @@ struct ProviderSettingsFieldDescriptor: Identifiable { let id: String let title: String let subtitle: String - var footerText: String? = nil + var footerText: String? let kind: Kind let placeholder: String? let binding: Binding From 73a5b8ffd101cbe354f31e791b580848dd82262d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 23 Apr 2026 21:53:39 +0530 Subject: [PATCH 51/64] Update changelog for Claude bars and widget packaging --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdae849f9..a8b99f313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,12 @@ ## 0.23 — Unreleased ### Changes -- Development ongoing. +- Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! ### Fixes - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. - Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! -- Widgets: package `Metadata.appintents` for the widget extension and use App Intent parameter defaults so configurable widgets load correctly in WidgetKit. Thanks @vincentyangch! +- Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch! ## 0.22 — 2026-04-21 From dab7f7929283727780427dfa201c89c0a32c366f Mon Sep 17 00:00:00 2001 From: Sash Zats Date: Fri, 24 Apr 2026 05:37:07 -0400 Subject: [PATCH 52/64] Add weekly reset confetti overlay (#785) --- Package.resolved | 10 +- Package.swift | 2 + Sources/CodexBar/CodexbarApp.swift | 30 ++ Sources/CodexBar/Notifications+CodexBar.swift | 17 + .../CodexBar/PreferencesAdvancedPane.swift | 4 + .../ScreenConfettiOverlayController.swift | 290 ++++++++++++++++++ Sources/CodexBar/SettingsStore+Defaults.swift | 8 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+Actions.swift | 20 ++ Sources/CodexBar/StatusItemController.swift | 7 + .../CodexBar/UsageStore+PlanUtilization.swift | 144 ++++++++- Sources/CodexBar/UsageStore.swift | 2 + .../CodexBarCore/Logging/LogCategories.swift | 1 + Tests/CodexBarTests/SettingsStoreTests.swift | 26 ++ .../UsageStorePlanUtilizationTests.swift | 223 ++++++++++++++ 17 files changed, 786 insertions(+), 3 deletions(-) create mode 100644 Sources/CodexBar/ScreenConfettiOverlayController.swift diff --git a/Package.resolved b/Package.resolved index 424905644..2dd5f9581 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fc2d77d3435ccf0f5a2d2a8f782cbb3c38264c7542f33838f2409544695bce6e", + "originHash" : "6e0bbde3ad4d9af0981adadaf1f109eb154e54018d06dbe966616f09c3898482", "pins" : [ { "identity" : "commander", @@ -54,6 +54,14 @@ "revision" : "0687f71944021d616d34d922343dcef086855920", "version" : "600.0.1" } + }, + { + "identity" : "vortex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zats/Vortex", + "state" : { + "revision" : "ef5392088d4aeb255c4eee83157dbdafcd31bf07" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 208b857ee..e8e769f33 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log", from: "1.12.0"), .package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"), .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"), + .package(url: "https://github.com/zats/Vortex", revision: "ef5392088d4aeb255c4eee83157dbdafcd31bf07"), sweetCookieKitDependency, ], targets: { @@ -82,6 +83,7 @@ let package = Package( dependencies: [ .product(name: "Sparkle", package: "Sparkle"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "Vortex", package: "Vortex"), "CodexBarMacroSupport", "CodexBarCore", ], diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index cb7fb5be4..ccaad6969 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -278,6 +278,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let updaterController: UpdaterProviding = makeUpdaterController() + private let confettiOverlayController = ScreenConfettiOverlayController() + private let confettiLogger = CodexBarLog.logger(LogCategories.confetti) private var statusController: StatusItemControlling? private var store: UsageStore? private var settings: SettingsStore? @@ -285,6 +287,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var preferencesSelection: PreferencesSelection? private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? + private var hasInstalledWeeklyLimitResetObserver = false func configure(_ dependencies: Dependencies) { self.store = dependencies.store @@ -307,12 +310,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.statusController?.openMenuFromShortcut() } } + if !self.hasInstalledWeeklyLimitResetObserver { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleWeeklyLimitResetNotification(_:)), + name: .codexbarWeeklyLimitReset, + object: nil) + self.hasInstalledWeeklyLimitResetObserver = true + } } func applicationWillTerminate(_ notification: Notification) { + self.confettiOverlayController.dismiss() TTYCommandRunner.terminateActiveProcessesForAppShutdown() } + @objc private func handleWeeklyLimitResetNotification(_ notification: Notification) { + guard let event = notification.object as? WeeklyLimitResetEvent else { return } + guard self.settings?.confettiOnWeeklyLimitResetsEnabled == true else { return } + let origin = self.statusController?.celebrationOriginPoint(for: event.provider) + self.confettiLogger.info( + "Triggering confetti", + metadata: [ + "provider": event.provider.rawValue, + "accountIdentifier": event.accountIdentifier, + "originKnown": origin == nil ? "0" : "1", + ]) + self.confettiOverlayController.play(originInScreen: origin) + } + /// Use the classic (non-Liquid Glass) app icon on macOS versions before 26. private func configureAppIconForMacOSVersion() { if #unavailable(macOS 26) { @@ -382,4 +408,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { fallbackManagedCodexAccountCoordinator, fallbackCodexAccountPromotionCoordinator) } + + deinit { + NotificationCenter.default.removeObserver(self) + } } diff --git a/Sources/CodexBar/Notifications+CodexBar.swift b/Sources/CodexBar/Notifications+CodexBar.swift index 8c0456276..354c63bf1 100644 --- a/Sources/CodexBar/Notifications+CodexBar.swift +++ b/Sources/CodexBar/Notifications+CodexBar.swift @@ -1,7 +1,24 @@ +import CodexBarCore import Foundation extension Notification.Name { static let codexbarOpenSettings = Notification.Name("codexbarOpenSettings") static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow") + static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset") static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange") } + +@MainActor +final class WeeklyLimitResetEvent: NSObject { + let provider: UsageProvider + let accountIdentifier: String + let accountLabel: String? + let usedPercent: Double + + init(provider: UsageProvider, accountIdentifier: String, accountLabel: String?, usedPercent: Double) { + self.provider = provider + self.accountIdentifier = accountIdentifier + self.accountLabel = accountLabel + self.usedPercent = usedPercent + } +} diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f2..9b901c9d8 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -64,6 +64,10 @@ struct AdvancedPane: View { title: "Surprise me", subtitle: "Check if you like your agents having some fun up there.", binding: self.$settings.randomBlinkEnabled) + PreferenceToggleRow( + title: "Weekly limit confetti", + subtitle: "Play full-screen confetti when weekly usage resets.", + binding: self.$settings.confettiOnWeeklyLimitResetsEnabled) } Divider() diff --git a/Sources/CodexBar/ScreenConfettiOverlayController.swift b/Sources/CodexBar/ScreenConfettiOverlayController.swift new file mode 100644 index 000000000..acacceb5e --- /dev/null +++ b/Sources/CodexBar/ScreenConfettiOverlayController.swift @@ -0,0 +1,290 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Vortex + +@MainActor +final class ScreenConfettiOverlayController { + private static let overlayLifetime: TimeInterval = 5 + + private let logger = CodexBarLog.logger(LogCategories.confetti) + private var windows: [NSWindow] = [] + private var dismissalTask: Task? + + func play(originInScreen origin: CGPoint?) { + guard self.windows.isEmpty else { + self.logger.debug("Ignoring confetti trigger while overlay is already active") + return + } + + let screens = NSScreen.screens + guard !screens.isEmpty else { + self.logger.error("Cannot present confetti overlay because no screens were found") + return + } + + let palette = Self.randomPalette() + self.windows = screens.map { screen in + let frame = screen.frame + let localOrigin = Self.localOrigin(in: frame, from: origin) + let contentView = ScreenConfettiOverlayView(origin: localOrigin, colors: palette) + .allowsHitTesting(false) + let hostingView = NSHostingView(rootView: contentView) + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + + let window = ClickThroughOverlayPanel( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false, + screen: screen) + window.contentView = hostingView + window.level = .statusBar + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .stationary] + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false + window.ignoresMouseEvents = true + window.acceptsMouseMovedEvents = false + window.isMovable = false + window.isReleasedWhenClosed = false + window.canHide = false + window.hidesOnDeactivate = false + window.becomesKeyOnlyIfNeeded = false + window.isExcludedFromWindowsMenu = true + window.setFrame(frame, display: false) + return window + } + + self.logger.info( + "Presenting confetti overlay", + metadata: [ + "screenCount": "\(self.windows.count)", + "originKnown": origin == nil ? "0" : "1", + ]) + + for window in self.windows { + window.orderFrontRegardless() + } + + self.dismissalTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.overlayLifetime)) + self?.dismiss() + } + } + + func dismiss() { + self.dismissalTask?.cancel() + self.dismissalTask = nil + + guard !self.windows.isEmpty else { return } + for window in self.windows { + window.orderOut(nil) + window.close() + } + self.windows.removeAll(keepingCapacity: true) + } + + private static func localOrigin(in screenFrame: CGRect, from globalOrigin: CGPoint?) -> CGPoint { + let fallback = CGPoint(x: screenFrame.maxX - 28, y: screenFrame.maxY - 8) + let resolved: CGPoint = if let globalOrigin, screenFrame.contains(globalOrigin) { + globalOrigin + } else { + fallback + } + + let insetFrame = screenFrame.insetBy(dx: 8, dy: 8) + return CGPoint( + x: min(max(resolved.x, insetFrame.minX), insetFrame.maxX) - screenFrame.minX, + y: min(max(resolved.y, insetFrame.minY), insetFrame.maxY) - screenFrame.minY) + } + + private static func randomPalette() -> [Color] { + let hue = Double.random(in: 0...1) + let hueOffsets = [0.0, 0.08, 0.16, 0.5, 0.66, 0.83] + return hueOffsets.map { offset in + Color( + hue: (hue + offset).truncatingRemainder(dividingBy: 1), + saturation: Double.random(in: 0.55...0.95), + brightness: Double.random(in: 0.85...1)) + } + } +} + +private final class ClickThroughOverlayPanel: NSPanel { + override var canBecomeKey: Bool { + false + } + + override var canBecomeMain: Bool { + false + } + + override var acceptsFirstResponder: Bool { + false + } +} + +private struct ScreenConfettiOverlayView: View { + private static let clockwiseRotationAngles: [Double] = [270, 234, 198, 162, 126, 90] + private static let counterclockwiseRotationAngles: [Double] = [90, 126, 162, 198, 234, 270] + + let origin: CGPoint + let colors: [Color] + + @Environment(\.self) private var environment + @State private var visiblePhaseCount = 0 + + var body: some View { + GeometryReader { proxy in + let clockwiseAngles = Array(Self.clockwiseRotationAngles.prefix(self.visiblePhaseCount).enumerated()) + let counterclockwiseAngles = Array( + Self.counterclockwiseRotationAngles.prefix(self.visiblePhaseCount).enumerated()) + ZStack { + ForEach(clockwiseAngles, id: \.offset) { index, angle in + VortexView(self.makeFireworkConfettiSystem( + in: proxy.size, + launchAngle: angle, + phaseIndex: index, + lateralOffset: -12)) + { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(.white) + .frame(width: 10, height: 20) + .tag("confetti-bar") + + Circle() + .fill(.white) + .frame(width: 9, height: 9) + .tag("confetti-dot") + + Capsule(style: .continuous) + .fill(.white) + .frame(width: 8, height: 16) + .rotationEffect(.degrees(30)) + .tag("confetti-pill") + + Circle() + .fill(.white) + .frame(width: 6, height: 6) + .blur(radius: 1) + .tag("confetti-tracer") + } + } + + ForEach(counterclockwiseAngles, id: \.offset) { index, angle in + VortexView(self.makeFireworkConfettiSystem( + in: proxy.size, + launchAngle: angle, + phaseIndex: index, + lateralOffset: 12)) + { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(.white) + .frame(width: 10, height: 20) + .tag("confetti-bar") + + Circle() + .fill(.white) + .frame(width: 9, height: 9) + .tag("confetti-dot") + + Capsule(style: .continuous) + .fill(.white) + .frame(width: 8, height: 16) + .rotationEffect(.degrees(30)) + .tag("confetti-pill") + + Circle() + .fill(.white) + .frame(width: 6, height: 6) + .blur(radius: 1) + .tag("confetti-tracer") + } + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .task { + self.visiblePhaseCount = 1 + for phaseCount in 2...Self.clockwiseRotationAngles.count { + try? await Task.sleep(for: .milliseconds(60)) + self.visiblePhaseCount = phaseCount + } + } + } + } + + private func makeFireworkConfettiSystem( + in size: CGSize, + launchAngle: Double, + phaseIndex: Int, + lateralOffset: CGFloat) + -> VortexSystem + { + let canvasOrigin = self.canvasOrigin(in: size, lateralOffset: lateralOffset) + let normalizedX = size.width > 0 ? canvasOrigin.x / size.width : 1 + let normalizedY = size.height > 0 ? canvasOrigin.y / size.height : 0 + let resolvedColors = self.colors.map { color -> VortexSystem.Color in + let components = color.resolve(in: self.environment) + return VortexSystem.Color( + red: Double(components.red), + green: Double(components.green), + blue: Double(components.blue), + opacity: Double(components.opacity)) + } + + let explosion = VortexSystem( + tags: ["confetti-bar", "confetti-dot", "confetti-pill"], + spawnOccasion: .onDeath, + shape: .point, + birthRate: 24000, + emissionLimit: 42, + emissionDuration: 0.08, + idleDuration: 10, + lifespan: 4.2, + speed: 0.72, + speedVariation: 0.44, + angleRange: .degrees(360), + acceleration: [0, 0.32], + dampingFactor: 0.18, + angularSpeed: [0, 0, 3], + angularSpeedVariation: [2, 2, 14], + colors: .random(resolvedColors), + size: 0.74, + sizeVariation: 0.26, + sizeMultiplierAtDeath: 0.94, + stretchFactor: 0.82) + + return VortexSystem( + tags: ["confetti-tracer"], + secondarySystems: [explosion], + position: [normalizedX, normalizedY], + shape: .point, + birthRate: 18, + emissionLimit: 4, + emissionDuration: 0.22, + idleDuration: 10, + lifespan: 0.58 + (Double(phaseIndex) * 0.03), + speed: 1.36 + (Double(phaseIndex) * 0.04), + speedVariation: 0.12, + angle: .degrees(launchAngle), + angleRange: .degrees(12), + acceleration: [0, 0.12], + dampingFactor: 0.06, + angularSpeed: [0, 0, 6], + angularSpeedVariation: [1, 1, 8], + colors: .single(.white), + size: 0.34, + sizeVariation: 0.08, + sizeMultiplierAtDeath: 0.4, + stretchFactor: 1.3) + } + + private func canvasOrigin(in size: CGSize, lateralOffset: CGFloat = 0) -> CGPoint { + CGPoint( + x: min(max(self.origin.x + lateralOffset, 0), size.width), + y: min(max(size.height - self.origin.y + 18, 0), size.height)) + } +} diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 84aebdd9f..ab32e5797 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -192,6 +192,14 @@ extension SettingsStore { } } + var confettiOnWeeklyLimitResetsEnabled: Bool { + get { self.defaultsState.confettiOnWeeklyLimitResetsEnabled } + set { + self.defaultsState.confettiOnWeeklyLimitResetsEnabled = newValue + self.userDefaults.set(newValue, forKey: "confettiOnWeeklyLimitResetsEnabled") + } + } + var menuBarShowsHighestUsage: Bool { get { self.defaultsState.menuBarShowsHighestUsage } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 5ac7f16f6..73786c47f 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -22,6 +22,7 @@ extension SettingsStore { _ = self.costUsageEnabled _ = self.hidePersonalInfo _ = self.randomBlinkEnabled + _ = self.confettiOnWeeklyLimitResetsEnabled _ = self.claudeOAuthKeychainPromptMode _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 9456af903..5a01847d1 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -255,6 +255,8 @@ extension SettingsStore { let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false + let confettiOnWeeklyLimitResetsEnabled = userDefaults.object( + forKey: "confettiOnWeeklyLimitResetsEnabled") as? Bool ?? false let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") @@ -299,6 +301,7 @@ extension SettingsStore { costUsageEnabled: costUsageEnabled, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, + confettiOnWeeklyLimitResetsEnabled: confettiOnWeeklyLimitResetsEnabled, menuBarShowsHighestUsage: menuBarShowsHighestUsage, claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 69e676032..a65fb45d5 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -21,6 +21,7 @@ struct SettingsDefaultsState { var costUsageEnabled: Bool var hidePersonalInfo: Bool var randomBlinkEnabled: Bool + var confettiOnWeeklyLimitResetsEnabled: Bool var menuBarShowsHighestUsage: Bool var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index b470e049c..e9fcf6f66 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -198,6 +198,26 @@ extension StatusItemController { item.button?.performClick(nil) } + func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? { + let item: NSStatusItem = if self.shouldMergeIcons { + self.statusItem + } else if let provider, let existing = self.statusItems[provider], existing.isVisible { + existing + } else { + self.lazyStatusItem(for: provider ?? .codex) + } + + guard let button = item.button, + let window = button.window + else { + return nil + } + + let buttonFrameInWindow = button.convert(button.bounds, to: nil) + let screenFrame = window.convertToScreen(buttonFrameInWindow) + return CGPoint(x: screenFrame.midX, y: screenFrame.midY) + } + private func openSettings(tab: PreferencesTab) { DispatchQueue.main.async { self.preferencesSelection.tab = tab diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 2f13726bd..dee5431a7 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -9,6 +9,13 @@ import SwiftUI @MainActor protocol StatusItemControlling: AnyObject { func openMenuFromShortcut() + func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? +} + +extension StatusItemControlling { + func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? { + nil + } } @MainActor diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index d45b54276..de23b4782 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -2,6 +2,15 @@ import CodexBarCore import Foundation extension UsageStore { + private nonisolated static let weeklyLimitResetThreshold = 1.0 + private nonisolated static let weeklyLimitResetDetectorDefaultsKey = "weeklyLimitResetDetectorStates" + private nonisolated static let weeklyWindowMinutes = 7 * 24 * 60 + + struct WeeklyLimitResetDetectorState: Codable, Equatable { + let wasAboveThreshold: Bool + let lastObservedAt: Date + } + func supportsPlanUtilizationHistory(for provider: UsageProvider) -> Bool { switch provider { case .codex, .claude: @@ -65,6 +74,22 @@ extension UsageStore { now: Date = Date()) async { + let samples = self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now) + guard !samples.isEmpty else { return } + + let detectorAccountKey = self.planUtilizationAccountKey( + for: provider, + snapshot: snapshot, + preferredAccount: account) + await MainActor.run { + self.postWeeklyLimitResetCelebrationIfNeeded( + provider: provider, + account: account, + snapshot: snapshot, + accountKey: detectorAccountKey, + samples: samples) + } + guard self.supportsPlanUtilizationHistory(for: provider) else { return } guard !self.shouldDeferClaudePlanUtilizationHistory(provider: provider) else { return } @@ -80,7 +105,6 @@ extension UsageStore { shouldAdoptUnscopedHistory: shouldAdoptUnscopedHistory, providerBuckets: &providerBuckets) let histories = providerBuckets.histories(for: accountKey) - let samples = self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now) guard let updatedHistories = Self.updatedPlanUtilizationHistories( existingHistories: histories, @@ -195,6 +219,62 @@ extension UsageStore { return max(0, min(100, value)) } + private func postWeeklyLimitResetCelebrationIfNeeded( + provider: UsageProvider, + account: ProviderTokenAccount?, + snapshot: UsageSnapshot, + accountKey: String?, + samples: [PlanUtilizationSeriesSample]) + { + guard let weeklySample = samples.last(where: { $0.name == .weekly }) else { return } + + let accountIdentifier = self.weeklyLimitResetAccountIdentifier( + provider: provider, + account: account, + snapshot: snapshot, + accountKey: accountKey) + let detectorKey = Self.weeklyLimitResetDetectorStateKey( + provider: provider, + accountIdentifier: accountIdentifier) + let currentUsed = weeklySample.entry.usedPercent + let currentObservedAt = weeklySample.entry.capturedAt + let wasAboveThreshold = currentUsed > Self.weeklyLimitResetThreshold + if let existingState = self.weeklyLimitResetDetectorStates[detectorKey], + currentObservedAt <= existingState.lastObservedAt + { + return + } + + let shouldPost = self.weeklyLimitResetDetectorStates[detectorKey]?.wasAboveThreshold == true + && !wasAboveThreshold + self.weeklyLimitResetDetectorStates[detectorKey] = WeeklyLimitResetDetectorState( + wasAboveThreshold: wasAboveThreshold, + lastObservedAt: currentObservedAt) + self.persistWeeklyLimitResetDetectorStates() + + guard shouldPost else { return } + let accountLabel = self.weeklyLimitResetAccountLabel( + provider: provider, + account: account, + snapshot: snapshot) + let event = WeeklyLimitResetEvent( + provider: provider, + accountIdentifier: accountIdentifier, + accountLabel: accountLabel, + usedPercent: currentUsed) + + CodexBarLog.logger(LogCategories.confetti).info( + "Weekly limit reset", + metadata: [ + "provider": provider.rawValue, + "accountIdentifier": accountIdentifier, + "accountLabel": accountLabel ?? "", + "usedPercent": String(format: "%.2f", currentUsed), + "observedAt": String(format: "%.0f", currentObservedAt.timeIntervalSince1970), + ]) + NotificationCenter.default.post(name: .codexbarWeeklyLimitReset, object: event) + } + private func planUtilizationSeriesSamples( provider: UsageProvider, snapshot: UsageSnapshot, @@ -235,7 +315,10 @@ extension UsageStore { appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .opus) default: - break + for window in [snapshot.primary, snapshot.secondary, snapshot.tertiary] { + guard let window, window.windowMinutes == Self.weeklyWindowMinutes else { continue } + appendWindow(window, name: .weekly) + } } return samplesByKey.values.sorted { lhs, rhs in @@ -426,6 +509,63 @@ extension UsageStore { provider == .claude && self.shouldHidePlanUtilizationMenuItem(for: .claude) } + private func weeklyLimitResetAccountIdentifier( + provider: UsageProvider, + account: ProviderTokenAccount?, + snapshot: UsageSnapshot, + accountKey: String?) -> String + { + let identity = snapshot.identity(for: provider) + return account?.id.uuidString.lowercased() + ?? accountKey + ?? identity?.accountEmail + ?? identity?.accountOrganization + ?? provider.rawValue + } + + private func weeklyLimitResetAccountLabel( + provider: UsageProvider, + account: ProviderTokenAccount?, + snapshot: UsageSnapshot) -> String? + { + let identity = snapshot.identity(for: provider) + return account?.label + ?? identity?.accountEmail + ?? identity?.accountOrganization + } + + private nonisolated static func weeklyLimitResetDetectorStateKey( + provider: UsageProvider, + accountIdentifier: String) -> String + { + "\(provider.rawValue):\(accountIdentifier)" + } + + nonisolated static func loadWeeklyLimitResetDetectorStates(from userDefaults: UserDefaults) + -> [String: WeeklyLimitResetDetectorState] + { + guard let data = userDefaults.data(forKey: self.weeklyLimitResetDetectorDefaultsKey) else { return [:] } + do { + return try JSONDecoder().decode([String: WeeklyLimitResetDetectorState].self, from: data) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to decode weekly limit reset detector state", + metadata: ["error": String(describing: error)]) + return [:] + } + } + + private func persistWeeklyLimitResetDetectorStates() { + do { + let data = try JSONEncoder().encode(self.weeklyLimitResetDetectorStates) + self.settings.userDefaults.set(data, forKey: Self.weeklyLimitResetDetectorDefaultsKey) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to encode weekly limit reset detector state", + metadata: ["error": String(describing: error)]) + } + } + private func resolvePlanUtilizationAccountKey( provider: UsageProvider, snapshot: UsageSnapshot?, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 36cc0bf2a..930778b90 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -205,6 +205,7 @@ final class UsageStore { @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] + @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @@ -256,6 +257,7 @@ final class UsageStore { implementation.makeRuntime().map { (implementation.id, $0) } }) self.planUtilizationHistory = planUtilizationHistoryStore.load() + self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults) self.logStartupState() self.bindSettings() self.pathDebugInfo = PathDebugSnapshot( diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 90a7175d2..fba33c0ce 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -14,6 +14,7 @@ public enum LogCategories { public static let codexRPC = "codex-rpc" public static let configMigration = "config-migration" public static let configStore = "config-store" + public static let confetti = "confetti" public static let cookieCache = "cookie-cache" public static let cookieHeaderStore = "cookie-header-store" public static let copilotTokenStore = "copilot-token-store" diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 0a50a5166..6a85d0c70 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -6,6 +6,7 @@ import Testing @Suite(.serialized) @MainActor +// swiftlint:disable:next type_body_length struct SettingsStoreTests { private final class ObservationFlag: @unchecked Sendable { private let lock = NSLock() @@ -85,6 +86,31 @@ struct SettingsStoreTests { #expect(storeB.refreshFrequency.seconds == 900) } + @Test + func `weekly confetti setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-weekly-confetti" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.confettiOnWeeklyLimitResetsEnabled == false) + storeA.confettiOnWeeklyLimitResetsEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.confettiOnWeeklyLimitResetsEnabled == true) + } + @Test func `persists selected menu provider across instances`() throws { let suite = "SettingsStoreTests-selectedMenuProvider" diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 17732675a..afcc9050d 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +// swiftlint:disable:next type_body_length struct UsageStorePlanUtilizationTests { @Test func `coalesces changed usage within hour into single entry`() throws { @@ -651,6 +652,228 @@ struct UsageStorePlanUtilizationTests { #expect(findSeries(histories, name: .opus, windowMinutes: 10080)?.entries.last?.usedPercent == 30) } + @MainActor + @Test + func `weekly quota celebration posts when weekly usage resets to zero`() async { + let store = Self.makeStore() + var events: [WeeklyLimitResetEvent] = [] + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { notification in + if let event = notification.object as? WeeklyLimitResetEvent { + events.append(event) + } + } + defer { NotificationCenter.default.removeObserver(token) } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + + #expect(events.count == 1) + #expect(events[0].provider == .claude) + #expect(events[0].accountLabel == "alice@example.com") + #expect(events[0].usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration posts when reset lands mid hour without history split`() async { + let store = Self.makeStore() + var events: [WeeklyLimitResetEvent] = [] + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { notification in + if let event = notification.object as? WeeklyLimitResetEvent { + events.append(event) + } + } + defer { NotificationCenter.default.removeObserver(token) } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_700_100_000), + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_700_100_030), + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_001_800), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + + let histories = store.planUtilizationHistory(for: .claude) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40) + #expect(events.count == 1) + #expect(events[0].usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration ignores first seen reset sample`() async { + let store = Self.makeStore() + var eventCount = 0 + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { _ in + eventCount += 1 + } + defer { NotificationCenter.default.removeObserver(token) } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: snapshot.updatedAt) + + #expect(eventCount == 0) + } + + @MainActor + @Test + func `weekly quota celebration fires once across repeated low samples`() async { + let store = Self.makeStore() + var events: [WeeklyLimitResetEvent] = [] + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { notification in + if let event = notification.object as? WeeklyLimitResetEvent { + events.append(event) + } + } + defer { NotificationCenter.default.removeObserver(token) } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + let firstLow = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 1, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_001_800), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + let secondLow = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_002_100), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: firstLow, now: firstLow.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: secondLow, now: secondLow.updatedAt) + + #expect(events.count == 1) + #expect(events[0].usedPercent == 1) + } + + @MainActor + @Test + func `weekly quota celebration posts for generic provider weekly lane`() async { + let store = Self.makeStore() + var events: [WeeklyLimitResetEvent] = [] + let token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { notification in + if let event = notification.object as? WeeklyLimitResetEvent { + events.append(event) + } + } + defer { NotificationCenter.default.removeObserver(token) } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: "zai-org", + loginMethod: "pro")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: "zai-org", + loginMethod: "pro")) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + + #expect(events.count == 1) + #expect(events[0].provider == .zai) + #expect(events[0].accountLabel == "zai-org") + #expect(events[0].usedPercent == 0) + } + @MainActor @Test func `concurrent plan history writes coalesce within single hour bucket per series`() async throws { From 8c0a2c87394d14878dc3e12f35a96e21c9abede5 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 24 Apr 2026 15:26:33 +0530 Subject: [PATCH 53/64] Isolate weekly reset notification tests --- .../UsageStorePlanUtilizationTests.swift | 167 +++++++++++------- 1 file changed, 101 insertions(+), 66 deletions(-) diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index afcc9050d..89f2fd40a 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -656,17 +656,9 @@ struct UsageStorePlanUtilizationTests { @Test func `weekly quota celebration posts when weekly usage resets to zero`() async { let store = Self.makeStore() - var events: [WeeklyLimitResetEvent] = [] - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { notification in - if let event = notification.object as? WeeklyLimitResetEvent { - events.append(event) - } - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "reset-zero@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } let before = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -674,7 +666,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) let after = UsageSnapshot( @@ -683,16 +675,17 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_003_600), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + let events = recorder.events #expect(events.count == 1) #expect(events[0].provider == .claude) - #expect(events[0].accountLabel == "alice@example.com") + #expect(events[0].accountLabel == accountLabel) #expect(events[0].usedPercent == 0) } @@ -700,17 +693,9 @@ struct UsageStorePlanUtilizationTests { @Test func `weekly quota celebration posts when reset lands mid hour without history split`() async { let store = Self.makeStore() - var events: [WeeklyLimitResetEvent] = [] - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { notification in - if let event = notification.object as? WeeklyLimitResetEvent { - events.append(event) - } - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "mid-hour-reset@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } let before = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -722,7 +707,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) let after = UsageSnapshot( @@ -735,7 +720,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_001_800), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) @@ -745,6 +730,7 @@ struct UsageStorePlanUtilizationTests { let histories = store.planUtilizationHistory(for: .claude) #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1) #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40) + let events = recorder.events #expect(events.count == 1) #expect(events[0].usedPercent == 0) } @@ -753,15 +739,9 @@ struct UsageStorePlanUtilizationTests { @Test func `weekly quota celebration ignores first seen reset sample`() async { let store = Self.makeStore() - var eventCount = 0 - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { _ in - eventCount += 1 - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "first-seen-reset@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -769,30 +749,22 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: snapshot.updatedAt) - #expect(eventCount == 0) + #expect(recorder.events.isEmpty) } @MainActor @Test func `weekly quota celebration fires once across repeated low samples`() async { let store = Self.makeStore() - var events: [WeeklyLimitResetEvent] = [] - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { notification in - if let event = notification.object as? WeeklyLimitResetEvent { - events.append(event) - } - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "repeated-low@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } let before = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -800,7 +772,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) let firstLow = UsageSnapshot( @@ -809,7 +781,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_001_800), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) let secondLow = UsageSnapshot( @@ -818,7 +790,7 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(timeIntervalSince1970: 1_700_002_100), identity: ProviderIdentitySnapshot( providerID: .claude, - accountEmail: "alice@example.com", + accountEmail: accountLabel, accountOrganization: nil, loginMethod: "max")) @@ -826,6 +798,7 @@ struct UsageStorePlanUtilizationTests { await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: firstLow, now: firstLow.updatedAt) await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: secondLow, now: secondLow.updatedAt) + let events = recorder.events #expect(events.count == 1) #expect(events[0].usedPercent == 1) } @@ -834,17 +807,9 @@ struct UsageStorePlanUtilizationTests { @Test func `weekly quota celebration posts for generic provider weekly lane`() async { let store = Self.makeStore() - var events: [WeeklyLimitResetEvent] = [] - let token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { notification in - if let event = notification.object as? WeeklyLimitResetEvent { - events.append(event) - } - } - defer { NotificationCenter.default.removeObserver(token) } + let accountLabel = "zai-reset-org" + let recorder = WeeklyLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel) + defer { recorder.invalidate() } let before = UsageSnapshot( primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), @@ -853,7 +818,7 @@ struct UsageStorePlanUtilizationTests { identity: ProviderIdentitySnapshot( providerID: .zai, accountEmail: nil, - accountOrganization: "zai-org", + accountOrganization: accountLabel, loginMethod: "pro")) let after = UsageSnapshot( primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), @@ -862,15 +827,16 @@ struct UsageStorePlanUtilizationTests { identity: ProviderIdentitySnapshot( providerID: .zai, accountEmail: nil, - accountOrganization: "zai-org", + accountOrganization: accountLabel, loginMethod: "pro")) await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + let events = recorder.events #expect(events.count == 1) #expect(events[0].provider == .zai) - #expect(events[0].accountLabel == "zai-org") + #expect(events[0].accountLabel == accountLabel) #expect(events[0].usedPercent == 0) } @@ -1065,6 +1031,75 @@ func findSeries( histories.first { $0.name == name && $0.windowMinutes == windowMinutes } } +private final class WeeklyLimitResetEventRecorder: @unchecked Sendable { + struct Event: Sendable { + let provider: UsageProvider + let accountLabel: String? + let usedPercent: Double + } + + private let provider: UsageProvider + private let accountLabel: String? + private let lock = NSLock() + private var observedEvents: [Event] = [] + private var token: NSObjectProtocol? + + init(provider: UsageProvider, accountLabel: String?) { + self.provider = provider + self.accountLabel = accountLabel + self.token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { [weak self] notification in + guard let self, + let event = notification.object as? WeeklyLimitResetEvent + else { + return + } + + let recorded = MainActor.assumeIsolated { () -> Event? in + guard event.provider == self.provider, + event.accountLabel == self.accountLabel + else { + return nil + } + return Event( + provider: event.provider, + accountLabel: event.accountLabel, + usedPercent: event.usedPercent) + } + guard let recorded else { return } + + self.lock.lock() + self.observedEvents.append(recorded) + self.lock.unlock() + } + } + + var events: [Event] { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents.count + } + + func invalidate() { + guard let token else { return } + NotificationCenter.default.removeObserver(token) + self.token = nil + } + + deinit { + self.invalidate() + } +} + func formattedBoundary(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") From 4ae0c3b5422a1f42e41ae0506e07874e129af021 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 24 Apr 2026 18:51:47 +0530 Subject: [PATCH 54/64] Update changelog to include opt-in confetti celebration for weekly limit resets (#785). Thanks @zats! --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b99f313..b409ce036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! +- Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! ### Fixes - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. From a6b034284bbd211cb5fe559b7df2d986e1f43b5d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 24 Apr 2026 19:28:39 +0530 Subject: [PATCH 55/64] Add Cursor extra usage menu bar metric Co-Authored-By: Ye Hui <39940860+huiye98@users.noreply.github.com> --- .../MenuBarMetricWindowResolver.swift | 12 +++ .../CodexBar/PreferencesProvidersPane.swift | 6 ++ .../SettingsStore+MenuPreferences.swift | 26 +++++- Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/StatusItemController.swift | 2 + .../Providers/Cursor/CursorStatusProbe.swift | 2 +- .../CursorStatusProbeTests.swift | 1 + .../MenuBarMetricWindowResolverTests.swift | 21 +++++ .../ProvidersPaneCoverageTests.swift | 42 ++++++++++ .../SettingsStoreAdditionalTests.swift | 8 ++ .../StatusItemExtraUsageMetricTests.swift | 83 +++++++++++++++++++ 11 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 9857f39a1..f013d636e 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -17,6 +17,8 @@ enum MenuBarMetricWindowResolver { { guard let snapshot else { return nil } switch preference { + case .extraUsage: + return Self.extraUsageWindow(snapshot: snapshot) case .tertiary: return Self.window(in: snapshot, following: Self.tertiaryOrder(for: provider)) case .primary: @@ -141,4 +143,14 @@ enum MenuBarMetricWindowResolver { guard !windows.isEmpty else { return nil } return windows.max(by: { $0.usedPercent < $1.usedPercent }) } + + private static func extraUsageWindow(snapshot: UsageSnapshot?) -> RateWindow? { + guard let cost = snapshot?.providerCost, cost.limit > 0 else { return nil } + let usedPercent = max(0, min(100, (cost.used / cost.limit) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: cost.resetsAt, + resetDescription: nil) + } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7e39ee0e2..a5738086c 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -465,6 +465,7 @@ struct ProvidersPane: View { let snapshot = self.store.snapshot(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) let supportsTertiary = self.settings.menuBarMetricSupportsTertiary(for: provider, snapshot: snapshot) + let supportsExtraUsage = self.settings.menuBarMetricSupportsExtraUsage(for: provider, snapshot: snapshot) var metricOptions: [ProviderSettingsPickerOption] = [ ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), ProviderSettingsPickerOption( @@ -480,6 +481,11 @@ struct ProvidersPane: View { id: MenuBarMetricPreference.tertiary.rawValue, title: "Tertiary (\(tertiaryTitle))")) } + if supportsExtraUsage { + metricOptions.append(ProviderSettingsPickerOption( + id: MenuBarMetricPreference.extraUsage.rawValue, + title: MenuBarMetricPreference.extraUsage.label)) + } if supportsAverage { metricOptions.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index fa62c073f..f4b136e86 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -9,7 +9,7 @@ extension SettingsStore { switch preference { case .automatic, .primary: return preference - case .secondary, .average, .tertiary: + case .secondary, .average, .tertiary, .extraUsage: return .automatic } } @@ -21,6 +21,9 @@ extension SettingsStore { if preference == .tertiary, !self.menuBarMetricSupportsTertiary(for: provider) { return .automatic } + if preference == .extraUsage, !self.menuBarMetricSupportsExtraUsage(for: provider) { + return .automatic + } return preference } @@ -29,7 +32,7 @@ extension SettingsStore { switch preference { case .automatic, .primary: self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue - case .secondary, .average, .tertiary: + case .secondary, .average, .tertiary, .extraUsage: self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue } return @@ -38,6 +41,10 @@ extension SettingsStore { self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue return } + if preference == .extraUsage, !self.menuBarMetricSupportsExtraUsage(for: provider) { + self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue + return + } self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue } @@ -56,6 +63,16 @@ extension SettingsStore { return self.menuBarMetricSupportsTertiary(for: provider) } + func menuBarMetricSupportsExtraUsage(for provider: UsageProvider) -> Bool { + provider == .cursor + } + + func menuBarMetricSupportsExtraUsage(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool { + guard self.menuBarMetricSupportsExtraUsage(for: provider) else { return false } + guard let cost = snapshot?.providerCost else { return false } + return cost.limit > 0 + } + func menuBarMetricPreference(for provider: UsageProvider, snapshot: UsageSnapshot?) -> MenuBarMetricPreference { let preference = self.menuBarMetricPreference(for: provider) if preference == .tertiary, @@ -63,6 +80,11 @@ extension SettingsStore { { return .automatic } + if preference == .extraUsage, + !self.menuBarMetricSupportsExtraUsage(for: provider, snapshot: snapshot) + { + return .automatic + } return preference } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 5a01847d1..b006f6d0e 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -43,6 +43,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { case primary case secondary case tertiary + case extraUsage case average var id: String { @@ -55,6 +56,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { case .primary: "Primary" case .secondary: "Secondary" case .tertiary: "Tertiary" + case .extraUsage: "Extra usage" case .average: "Average" } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dee5431a7..210fd8530 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -192,6 +192,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin switch preference { case .secondary, .tertiary: return second ?? first + case .extraUsage: + return first case .average: guard self.settings.menuBarMetricSupportsAverage(for: .codex), let primary = first, diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index b94a35115..4dee402a2 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -407,7 +407,7 @@ public struct CursorStatusSnapshot: Sendable { used: resolvedOnDemandUsed, limit: resolvedOnDemandLimit ?? 0, currencyCode: "USD", - period: "monthly", + period: "Monthly", resetsAt: self.billingCycleEnd, updatedAt: Date()) } else { diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index b7ba51eae..e34277f9f 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -370,6 +370,7 @@ struct CursorStatusProbeTests { #expect(usageSnapshot.providerCost != nil) #expect(usageSnapshot.providerCost?.used == 0.0) #expect(usageSnapshot.providerCost?.limit == 75.0) + #expect(usageSnapshot.providerCost?.period == "Monthly") } @Test diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index e72581e06..df37232dc 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -20,4 +20,25 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.usedPercent == 92) } + + @Test + func `extra usage metric maps provider cost into a menu bar window`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 37.5, + limit: 150, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .extraUsage, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 25) + } } diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index ee7e4e229..33a67187f 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -62,6 +62,48 @@ struct ProvidersPaneCoverageTests { #expect(tertiaryOption?.title == "Tertiary (API)") } + @Test + func `cursor menu bar metric picker omits extra usage when on demand budget is missing`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(!ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + } + + @Test + func `cursor menu bar metric picker includes extra usage when on demand budget is available`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue } + #expect(option?.title == "Extra usage") + } + @Test func `zai menu bar metric picker omits tertiary lane when snapshot has no 5-hour metric`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-no-tertiary-picker") diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 7ff18809c..4948cb31b 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -36,6 +36,11 @@ struct SettingsStoreAdditionalTests { #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) #expect(settings.menuBarMetricSupportsTertiary(for: .cursor, snapshot: nil) == false) + settings.setMenuBarMetricPreference(.extraUsage, for: .cursor) + #expect(settings.menuBarMetricPreference(for: .cursor) == .extraUsage) + #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsExtraUsage(for: .cursor, snapshot: nil) == false) + settings.setMenuBarMetricPreference(.tertiary, for: .perplexity) #expect(settings.menuBarMetricPreference(for: .perplexity) == .tertiary) #expect(settings.menuBarMetricPreference(for: .perplexity, snapshot: nil) == .tertiary) @@ -60,6 +65,9 @@ struct SettingsStoreAdditionalTests { settings.setMenuBarMetricPreference(.tertiary, for: .openrouter) #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) + + settings.setMenuBarMetricPreference(.extraUsage, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) } @Test diff --git a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift new file mode 100644 index 000000000..34b9e3746 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift @@ -0,0 +1,83 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemExtraUsageMetricTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `menu bar extra usage preference uses cursor on demand budget`() { + let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-budget") + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 15) + } + + @Test + func `menu bar extra usage preference falls back to automatic when cursor on demand budget is missing`() { + let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-missing-budget") + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 72) + } + + private func makeCursorController(suiteName: String) -> (UsageStore, StatusItemController) { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .cursor + settings.setMenuBarMetricPreference(.extraUsage, for: .cursor) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + return (store, controller) + } +} From b75e1693e3bd1ac6f7552794793d5c8ee6266f68 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Fri, 24 Apr 2026 19:34:46 +0530 Subject: [PATCH 56/64] Update changelog to include Cursor extra usage menu bar metric (#789). --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b409ce036..5ce625cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! +- Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98! - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! ### Fixes From 47a4776e914d653f8240a5ebf52e1330d2b34db0 Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 24 Apr 2026 23:28:36 +0200 Subject: [PATCH 57/64] feat: add Mistral AI as a new provider (#607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Resolve merge conflicts: keep both Perplexity and Mistral providers Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use Gregorian calendar for billing queries and register Mistral in token-account catalog Address PR #607 review: use fixed Gregorian/UTC calendar so non-Gregorian system calendars don't produce wrong month/year for the Mistral billing API, and add the missing .mistral entry to TokenAccountSupportCatalog so token- account overrides and manual cookie mode work correctly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(mistral): surface monthly cost as detail text in card and CLI UsageFormatter.resetLine prefers resetsAt over resetDescription, so the "€X.XXXX this month" string on the primary window was never rendered. ProviderCostSnapshot with limit=0 was also dropped by providerCostSection. Wire Mistral through the existing Warp/Kilo/Alibaba pattern that routes primary.resetDescription into a detail line below the bar, in both the menu card and the CLI renderer. Drop the dead providerCost payload. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix Mistral Linux build --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Ratul Sarna --- Sources/CodexBar/MenuCardView.swift | 2 +- .../MistralProviderImplementation.swift | 106 +++++++++ .../Mistral/MistralSettingsStore.swift | 64 +++++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-mistral.svg | 1 + Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/CLIRenderer.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 13 +- .../Mistral/MistralCookieImporter.swift | 101 ++++++++ .../Providers/Mistral/MistralErrors.swift | 35 +++ .../Providers/Mistral/MistralModels.swift | 124 ++++++++++ .../Mistral/MistralProviderDescriptor.swift | 121 ++++++++++ .../Mistral/MistralUsageFetcher.swift | 197 ++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 27 ++- .../CodexBarCore/Providers/Providers.swift | 2 + .../TokenAccountSupportCatalog+Data.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 3 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + Tests/CodexBarTests/MenuCardModelTests.swift | 46 ++++ .../MistralUsageParserTests.swift | 222 ++++++++++++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 1 + docs/providers.md | 14 +- 24 files changed, 1085 insertions(+), 11 deletions(-) create mode 100644 Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-mistral.svg create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralModels.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift create mode 100644 Tests/CodexBarTests/MistralUsageParserTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 18b77df05..c97aed64b 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1069,7 +1069,7 @@ extension UsageMenuCardView.Model { { primaryDetailText = detail } - if input.provider == .alibaba, + if input.provider == .alibaba || input.provider == .mistral, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift new file mode 100644 index 000000000..1949d2ed0 --- /dev/null +++ b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift @@ -0,0 +1,106 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct MistralProviderImplementation: ProviderImplementation { + let id: UsageProvider = .mistral + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.mistralCookieSource + _ = settings.mistralCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .mistral(context.settings.mistralSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.mistralCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.mistralCookieSource != .manual { + settings.mistralCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.mistralCookieSource.rawValue }, + set: { raw in + context.settings.mistralCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.mistralCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies from admin.mistral.ai.", + manual: "Paste a Cookie header captured from the billing page.", + off: "Mistral cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "mistral-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies from admin.mistral.ai.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.load(provider: .mistral) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "mistral-cookie-header", + title: "Cookie header", + subtitle: "Paste the Cookie header from a request to admin.mistral.ai. " + + "Must contain an ory_session_* cookie.", + kind: .secure, + placeholder: "ory_session_…=…; csrftoken=…", + binding: context.stringBinding(\.mistralCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "mistral-open-console", + title: "Open Mistral Admin", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://admin.mistral.ai/organization/usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.mistralCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift new file mode 100644 index 000000000..e99485517 --- /dev/null +++ b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift @@ -0,0 +1,64 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var mistralCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .mistral)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .mistral) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .mistral, field: "cookieHeader", value: newValue) + } + } + + var mistralCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .mistral, fallback: .auto) } + set { + self.updateProviderConfig(provider: .mistral) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .mistral, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureMistralCookieLoaded() {} +} + +extension SettingsStore { + func mistralSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .MistralProviderSettings + { + ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: self.mistralSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.mistralSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func mistralSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.mistralCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .mistral), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .mistral, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func mistralSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.mistralCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .mistral), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .mistral).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 6dd8c45c4..1b4950ec3 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -39,6 +39,7 @@ enum ProviderImplementationRegistry { case .warp: WarpProviderImplementation() case .perplexity: PerplexityProviderImplementation() case .abacus: AbacusProviderImplementation() + case .mistral: MistralProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-mistral.svg b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg new file mode 100644 index 000000000..c946b5225 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg @@ -0,0 +1 @@ +Mistral diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 930778b90..3704feeda 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -882,7 +882,7 @@ extension UsageStore { let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains, .perplexity, .abacus: + .kimik2, .jetbrains, .perplexity, .abacus, .mistral: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 211cb9d52..e22260c8d 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -199,7 +199,7 @@ enum CLIRenderer { now: Date, lines: inout [String]) { - if provider == .warp || provider == .kilo { + if provider == .warp || provider == .kilo || provider == .mistral { if let reset = self.resetLineForDetailBackedWindow(window: window, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index e43073e82..5d4f9cba2 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -193,6 +193,13 @@ struct TokenAccountCLIContext { abacus: ProviderSettingsSnapshot.AbacusProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) + case .mistral: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) + return self.makeSnapshot( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: return nil } @@ -215,7 +222,8 @@ struct TokenAccountCLIContext { ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil, perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil, - abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot + abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil, + mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -234,7 +242,8 @@ struct TokenAccountCLIContext { ollama: ollama, jetbrains: jetbrains, perplexity: perplexity, - abacus: abacus) + abacus: abacus, + mistral: mistral) } private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) -> diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift new file mode 100644 index 000000000..0bd65cb44 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift @@ -0,0 +1,101 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +private let mistralCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.mistral]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum MistralCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["mistral.ai", "admin.mistral.ai", "auth.mistral.ai"] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + + /// Extracts the CSRF token from the `csrftoken` cookie for the `X-CSRFTOKEN` header. + public var csrfToken: String? { + self.cookies.first { $0.name == "csrftoken" }?.value + } + } + + /// Returns `true` if any cookie name starts with `ory_session_` (the Ory Kratos session cookie). + private static func hasSessionCookie(_ cookies: [HTTPCookie]) -> Bool { + cookies.contains { $0.name.hasPrefix("ory_session_") } + } + + public static func importSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[mistral-cookie] \(msg)") } + let installedBrowsers = preferredBrowsers.isEmpty + ? mistralCookieImportOrder.cookieImportCandidates(using: browserDetection) + : preferredBrowsers.cookieImportCandidates(using: browserDetection) + + for browserSource in installedBrowsers { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + if !httpCookies.isEmpty { + guard Self.hasSessionCookie(httpCookies) else { + log("Skipping \(source.label) cookies: missing ory_session_* cookie") + continue + } + log("Found \(httpCookies.count) Mistral cookies in \(source.label)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + } + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + throw MistralCookieImportError.noCookies + } + + public static func hasSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession( + browserDetection: browserDetection, + preferredBrowsers: preferredBrowsers, + logger: logger) + return true + } catch { + return false + } + } +} + +enum MistralCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No Mistral session cookies found in browsers." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift new file mode 100644 index 000000000..1c8ab4ae4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum MistralUsageError: LocalizedError, Sendable { + case missingCookie + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCookie: + "No Mistral session cookies found in browsers." + case .invalidCredentials: + "Mistral session expired or invalid (HTTP 401/403)." + case let .apiError(detail): + "Mistral API error: \(detail)" + case let .parseFailed(detail): + "Failed to parse Mistral billing response: \(detail)" + } + } +} + +enum MistralSettingsError: LocalizedError { + case missingCookie + case invalidCookie + + var errorDescription: String? { + switch self { + case .missingCookie: + "No Mistral session cookies found in browsers." + case .invalidCookie: + "Mistral cookie header is invalid or missing ory_session cookie." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift new file mode 100644 index 000000000..cdd81427e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -0,0 +1,124 @@ +import Foundation + +// MARK: - API Response Models + +/// Top-level response from `GET https://admin.mistral.ai/api/billing/v2/usage`. +struct MistralBillingResponse: Codable { + let completion: MistralModelUsageCategory? + let ocr: MistralModelUsageCategory? + let connectors: MistralModelUsageCategory? + let librariesApi: MistralLibrariesUsageCategory? + let fineTuning: MistralFineTuningCategory? + let audio: MistralModelUsageCategory? + let vibeUsage: Double? + let date: String? + let previousMonth: String? + let nextMonth: String? + let startDate: String? + let endDate: String? + let currency: String? + let currencySymbol: String? + let prices: [MistralPrice]? + + enum CodingKeys: String, CodingKey { + case completion, ocr, connectors, audio, date, currency, prices + case librariesApi = "libraries_api" + case fineTuning = "fine_tuning" + case vibeUsage = "vibe_usage" + case previousMonth = "previous_month" + case nextMonth = "next_month" + case startDate = "start_date" + case endDate = "end_date" + case currencySymbol = "currency_symbol" + } +} + +struct MistralModelUsageCategory: Codable { + let models: [String: MistralModelUsageData]? +} + +struct MistralLibrariesUsageCategory: Codable { + let pages: MistralModelUsageCategory? + let tokens: MistralModelUsageCategory? +} + +struct MistralFineTuningCategory: Codable { + let training: [String: MistralModelUsageData]? + let storage: [String: MistralModelUsageData]? +} + +struct MistralModelUsageData: Codable { + let input: [MistralUsageEntry]? + let output: [MistralUsageEntry]? + let cached: [MistralUsageEntry]? +} + +struct MistralUsageEntry: Codable { + let usageType: String? + let eventType: String? + let billingMetric: String? + let billingDisplayName: String? + let billingGroup: String? + let timestamp: String? + let value: Int? + let valuePaid: Int? + + enum CodingKeys: String, CodingKey { + case timestamp, value + case usageType = "usage_type" + case eventType = "event_type" + case billingMetric = "billing_metric" + case billingDisplayName = "billing_display_name" + case billingGroup = "billing_group" + case valuePaid = "value_paid" + } +} + +struct MistralPrice: Codable { + let eventType: String? + let billingMetric: String? + let billingGroup: String? + let price: String? + + enum CodingKeys: String, CodingKey { + case price + case eventType = "event_type" + case billingMetric = "billing_metric" + case billingGroup = "billing_group" + } +} + +// MARK: - Intermediate Snapshot + +public struct MistralUsageSnapshot: Sendable { + public let totalCost: Double + public let currency: String + public let currencySymbol: String + public let totalInputTokens: Int + public let totalOutputTokens: Int + public let totalCachedTokens: Int + public let modelCount: Int + public let startDate: Date? + public let endDate: Date? + public let updatedAt: Date + + public func toUsageSnapshot() -> UsageSnapshot { + let resetDate = self.endDate.map { Calendar.current.date(byAdding: .second, value: 1, to: $0) ?? $0 } + let costDescription = if self.totalCost > 0 { + "\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month" + } else { + "No usage this month" + } + let primary = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: costDescription) + return UsageSnapshot( + primary: primary, + secondary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: nil) + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift new file mode 100644 index 000000000..914a2c5e8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -0,0 +1,121 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum MistralProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .mistral, + metadata: ProviderMetadata( + id: .mistral, + displayName: "Mistral", + sessionLabel: "Monthly", + weeklyLabel: "", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Mistral usage", + cliName: "mistral", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://admin.mistral.ai/organization/usage", + statusPageURL: nil, + statusLinkURL: "https://status.mistral.ai"), + branding: ProviderBranding( + iconStyle: .mistral, + iconResourceName: "ProviderIcon-mistral", + color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Mistral cost summary is not yet supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MistralWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "mistral", + aliases: ["mistral-ai"], + versionDetector: nil)) + } +} + +struct MistralWebFetchStrategy: ProviderFetchStrategy { + let id: String = "mistral.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.mistral?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.mistral?.cookieSource ?? .auto + do { + let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: true) + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: context.webTimeout) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } catch MistralUsageError.invalidCredentials where cookieSource != .manual { + #if os(macOS) + CookieHeaderCache.clear(provider: .mistral) + let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: false) + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: context.webTimeout) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + #else + throw MistralUsageError.invalidCredentials + #endif + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveCookieHeader( + context: ProviderFetchContext, + allowCached: Bool) throws -> (cookieHeader: String, csrfToken: String?) + { + if let settings = context.settings?.mistral, settings.cookieSource == .manual { + if let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) { + let pairs = CookieHeaderNormalizer.pairs(from: header) + let hasSessionCookie = pairs.contains { $0.name.hasPrefix("ory_session_") } + if hasSessionCookie { + let csrfToken = pairs.first { $0.name == "csrftoken" }?.value + return (header, csrfToken) + } + } + throw MistralSettingsError.invalidCookie + } + + #if os(macOS) + if allowCached, + let cached = CookieHeaderCache.load(provider: .mistral), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + let pairs = CookieHeaderNormalizer.pairs(from: cached.cookieHeader) + let csrfToken = pairs.first { $0.name == "csrftoken" }?.value + return (cached.cookieHeader, csrfToken) + } + let session = try MistralCookieImporter.importSession(browserDetection: context.browserDetection) + CookieHeaderCache.store( + provider: .mistral, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return (session.cookieHeader, session.csrfToken) + #else + throw MistralSettingsError.missingCookie + #endif + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift new file mode 100644 index 000000000..1e4925444 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -0,0 +1,197 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum MistralUsageFetcher { + private static let baseURL = URL(string: "https://admin.mistral.ai")! + + public static func fetchUsage( + cookieHeader: String, + csrfToken: String?, + timeout: TimeInterval = 15) async throws -> MistralUsageSnapshot + { + let now = Date() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let month = calendar.component(.month, from: now) + let year = calendar.component(.year, from: now) + + let usagePath = self.baseURL.appendingPathComponent("/api/billing/v2/usage") + var components = URLComponents(url: usagePath, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "month", value: "\(month)"), + URLQueryItem(name: "year", value: "\(year)"), + ] + guard let url = components.url else { + throw MistralUsageError.apiError("Failed to construct URL") + } + + var request = URLRequest(url: url, timeoutInterval: timeout) + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("https://admin.mistral.ai/organization/usage", forHTTPHeaderField: "Referer") + request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin") + if let csrfToken { + request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw MistralUsageError.apiError("Invalid response type") + } + + switch httpResponse.statusCode { + case 200: + break + case 401, 403: + throw MistralUsageError.invalidCredentials + default: + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + throw MistralUsageError.apiError("HTTP \(httpResponse.statusCode): \(body)") + } + + return try Self.parseResponse(data: data, updatedAt: now) + } + + static func parseResponse(data: Data, updatedAt: Date) throws -> MistralUsageSnapshot { + let decoder = JSONDecoder() + let billing: MistralBillingResponse + do { + billing = try decoder.decode(MistralBillingResponse.self, from: data) + } catch { + throw MistralUsageError.parseFailed(error.localizedDescription) + } + + let prices = Self.buildPriceIndex(billing.prices ?? []) + var totalCost: Double = 0 + var totalInput = 0 + var totalOutput = 0 + var totalCached = 0 + var modelCount = 0 + + // Aggregate completion tokens + if let models = billing.completion?.models { + for (_, modelData) in models { + modelCount += 1 + let (input, output, cached, cost) = Self.aggregateModel(modelData, prices: prices) + totalInput += input + totalOutput += output + totalCached += cached + totalCost += cost + } + } + + // Aggregate OCR, connectors, audio if present + for category in [billing.ocr, billing.connectors, billing.audio] { + if let models = category?.models { + for (_, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + } + } + } + + // Aggregate libraries_api (pages + tokens) + for category in [billing.librariesApi?.pages, billing.librariesApi?.tokens] { + if let models = category?.models { + for (_, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + } + } + } + + // Aggregate fine_tuning (training + storage) + for models in [billing.fineTuning?.training, billing.fineTuning?.storage] { + if let models { + for (_, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + } + } + } + + let currency = billing.currency ?? "EUR" + let currencySymbol = billing.currencySymbol ?? "€" + + let startDate = billing.startDate.flatMap { Self.parseDate($0) } + let endDate = billing.endDate.flatMap { Self.parseDate($0) } + + return MistralUsageSnapshot( + totalCost: totalCost, + currency: currency, + currencySymbol: currencySymbol, + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalCachedTokens: totalCached, + modelCount: modelCount, + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt) + } + + // MARK: - Private Helpers + + private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] { + var index: [String: Double] = [:] + for price in prices { + guard let metric = price.billingMetric, + let group = price.billingGroup, + let priceStr = price.price, + let value = Double(priceStr) + else { continue } + let key = "\(metric)::\(group)" + index[key] = value + } + return index + } + + private static func aggregateModel( + _ data: MistralModelUsageData, + prices: [String: Double]) -> (input: Int, output: Int, cached: Int, cost: Double) + { + var totalInput = 0 + var totalOutput = 0 + var totalCached = 0 + var totalCost: Double = 0 + + for entry in data.input ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalInput += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + for entry in data.output ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalOutput += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + for entry in data.cached ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalCached += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + return (totalInput, totalOutput, totalCached, totalCost) + } + + private static func parseDate(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6fb994efc..d98204b8e 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -79,6 +79,7 @@ public enum ProviderDescriptorRegistry { .warp: WarpProviderDescriptor.descriptor, .perplexity: PerplexityProviderDescriptor.descriptor, .abacus: AbacusProviderDescriptor.descriptor, + .mistral: MistralProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 63aa6221c..2692c4920 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -21,7 +21,8 @@ public struct ProviderSettingsSnapshot: Sendable { ollama: OllamaProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil, - abacus: AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot + abacus: AbacusProviderSettings? = nil, + mistral: MistralProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -43,7 +44,8 @@ public struct ProviderSettingsSnapshot: Sendable { ollama: ollama, jetbrains: jetbrains, perplexity: perplexity, - abacus: abacus) + abacus: abacus, + mistral: mistral) } public struct CodexProviderSettings: Sendable { @@ -244,6 +246,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct MistralProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -264,6 +276,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let jetbrains: JetBrainsProviderSettings? public let perplexity: PerplexityProviderSettings? public let abacus: AbacusProviderSettings? + public let mistral: MistralProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -289,7 +302,8 @@ public struct ProviderSettingsSnapshot: Sendable { ollama: OllamaProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil, - abacus: AbacusProviderSettings? = nil) + abacus: AbacusProviderSettings? = nil, + mistral: MistralProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -311,6 +325,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.jetbrains = jetbrains self.perplexity = perplexity self.abacus = abacus + self.mistral = mistral } } @@ -333,6 +348,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) case abacus(ProviderSettingsSnapshot.AbacusProviderSettings) + case mistral(ProviderSettingsSnapshot.MistralProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -356,6 +372,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings? + public var mistral: ProviderSettingsSnapshot.MistralProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled @@ -382,6 +399,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .jetbrains(value): self.jetbrains = value case let .perplexity(value): self.perplexity = value case let .abacus(value): self.abacus = value + case let .mistral(value): self.mistral = value } } @@ -406,6 +424,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { ollama: self.ollama, jetbrains: self.jetbrains, perplexity: self.perplexity, - abacus: self.abacus) + abacus: self.abacus, + mistral: self.mistral) } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 83dade054..22493fa0f 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -29,6 +29,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case openrouter case perplexity case abacus + case mistral } // swiftformat:enable sortDeclarations @@ -60,6 +61,7 @@ public enum IconStyle: Sendable, CaseIterable { case openrouter case perplexity case abacus + case mistral case combined } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index a13d28a80..893be0d5a 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -72,5 +72,12 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .mistral: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Mistral Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 17ddf1dba..69cc02db6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -235,7 +235,8 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus, + .mistral: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 01075cabe..ce60108de 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -77,6 +77,7 @@ enum ProviderChoice: String, AppEnum { case .warp: return nil // Warp not yet supported in widgets case .perplexity: return nil // Perplexity not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets + case .mistral: return nil // Mistral not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 4e03801b3..00c791aa8 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -283,6 +283,7 @@ private struct ProviderSwitchChip: View { case .warp: "Warp" case .perplexity: "Pplx" case .abacus: "Abacus" + case .mistral: "Mistral" } } } @@ -644,6 +645,8 @@ enum WidgetColors { Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal case .abacus: Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) + case .mistral: + Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange } } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index ee8fce91a..a0ee41663 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -775,4 +775,50 @@ struct MenuCardModelTests { #expect(primary.resetText == nil) #expect(primary.detailText == "10/100 credits") } + + @Test + func `mistral model surfaces monthly cost as primary detail text`() throws { + let now = Date() + let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60) + let identity = ProviderIdentitySnapshot( + providerID: .mistral, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: "€1.2345 this month"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.detailText == "€1.2345 this month") + #expect(primary.resetText?.hasPrefix("Resets") == true) + } } diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift new file mode 100644 index 000000000..ec1d6dd84 --- /dev/null +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -0,0 +1,222 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MistralUsageParserTests { + // swiftlint:disable line_length + + private static let novemberResponseJSON = """ + {"completion":{"models":{"mistral-large-latest::mistral-large-2411":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"input","timestamp":"2025-11-14","value":11121,"value_paid":11121}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"output","timestamp":"2025-11-14","value":1115,"value_paid":1115}]},"mistral-small-latest::mistral-small-2506":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-14","value":20,"value_paid":20},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-24","value":100,"value_paid":100}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-14","value":500,"value_paid":500},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-24","value":2482,"value_paid":2482}]}}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2025-11-01T00:00:00Z","previous_month":"2025-10","next_month":"2025-12","start_date":"2025-11-01T00:00:00Z","end_date":"2025-11-30T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"input","price":"0.0000017000"},{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"output","price":"0.0000051000"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"input","price":"8.50E-8"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"output","price":"2.550E-7"}]} + """ + + private static let emptyResponseJSON = """ + {"completion":{"models":{}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2026-02-01T00:00:00Z","previous_month":"2026-01","next_month":"2026-03","start_date":"2026-02-01T00:00:00Z","end_date":"2026-02-28T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[]} + """ + + // swiftlint:enable line_length + + @Test + func `parses response with usage data and computes token totals`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + // mistral-large input: 11121, mistral-small input: 20+100=120 + #expect(snapshot.totalInputTokens == 11121 + 120) + // mistral-large output: 1115, mistral-small output: 500+2482=2982 + #expect(snapshot.totalOutputTokens == 1115 + 2982) + #expect(snapshot.totalCachedTokens == 0) + #expect(snapshot.modelCount == 2) + #expect(snapshot.currency == "EUR") + #expect(snapshot.currencySymbol == "€") + } + + @Test + func `computes cost from tokens and prices`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + // mistral-large-2411 input: 11121 * 0.0000017 = 0.0189057 + // mistral-large-2411 output: 1115 * 0.0000051 = 0.0056865 + // mistral-small-2506 input: 120 * 0.000000085 = 0.0000102 + // mistral-small-2506 output: 2982 * 0.000000255 = 0.00076041 + let expectedCost = 0.0189057 + 0.0056865 + 0.0000102 + 0.00076041 + #expect(abs(snapshot.totalCost - expectedCost) < 0.0001) + #expect(snapshot.totalCost > 0) + } + + @Test + func `parses empty response with no usage`() throws { + let data = try #require(Self.emptyResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + #expect(snapshot.totalInputTokens == 0) + #expect(snapshot.totalOutputTokens == 0) + #expect(snapshot.totalCost == 0) + #expect(snapshot.modelCount == 0) + #expect(snapshot.currency == "EUR") + } + + @Test + func `parses dates from response`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + #expect(snapshot.startDate != nil) + #expect(snapshot.endDate != nil) + + let calendar = Calendar.current + if let start = snapshot.startDate { + #expect(calendar.component(.month, from: start) == 11) + #expect(calendar.component(.year, from: start) == 2025) + } + } + + @Test + func `throws parseFailed for invalid JSON`() { + let data = Data("not json".utf8) + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + } + } +} + +struct MistralUsageSnapshotConversionTests { + @Test + func `converts cost into primary resetDescription so it surfaces as detail text`() { + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: Date(), + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary != nil) + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription?.contains("€1.2345") == true) + // providerCost is intentionally nil: the menu card's providerCostSection requires + // limit > 0 to render a bar, and Mistral is pay-as-you-go with no quota. The cost + // is surfaced via primary.resetDescription (rendered as detail text in the card). + #expect(usage.providerCost == nil) + } + + @Test + func `converts zero cost with no-usage description`() { + let snapshot = MistralUsageSnapshot( + totalCost: 0, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + startDate: nil, + endDate: nil, + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "No usage this month") + } +} + +struct MistralStrategyTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext( + sourceMode: ProviderSourceMode = .auto, + settings: ProviderSettingsSnapshot? = nil, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } + + @Test + func `strategy is unavailable when cookie source is off`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == false) + } + + @Test + func `strategy is available when cookie source is auto`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == true) + } + + @Test + func `strategy is available when cookie source is manual`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .manual, + manualCookieHeader: "ory_session_x=abc; csrftoken=xyz")) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == true) + } + + @Test + func `strategy never falls back (single strategy provider)`() { + let strategy = MistralWebFetchStrategy() + let context = self.makeContext() + let shouldFallback = strategy.shouldFallback( + on: MistralUsageError.invalidCredentials, + context: context) + #expect(shouldFallback == false) + } + + @Test + func `descriptor metadata is correct`() { + let descriptor = MistralProviderDescriptor.descriptor + #expect(descriptor.id == .mistral) + #expect(descriptor.metadata.displayName == "Mistral") + #expect(descriptor.metadata.cliName == "mistral") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(descriptor.cli.name == "mistral") + #expect(descriptor.fetchPlan.sourceModes == [.auto, .web]) + #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral") + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 6a85d0c70..9493cdb45 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -938,6 +938,7 @@ struct SettingsStoreTests { .openrouter, .perplexity, .abacus, + .mistral, ]) // Move one provider; ensure it's persisted across instances. diff --git a/docs/providers.md b/docs/providers.md index a82898e78..0fe26b2cf 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI, Mistral)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -40,6 +40,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Ollama | Web settings page via browser cookies (`web`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | | Abacus AI | Browser cookies → compute points + billing API (`web`). | +| Mistral | Console billing API via Ory Kratos session cookies (`web`). | ## Codex - Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -192,4 +193,15 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: none yet. - Details: `docs/abacus.md`. +## Mistral +- Session cookie (`ory_session_*`) from browser auto-import or manual `Cookie:` header. +- CSRF token (`csrftoken` cookie) sent as `X-CSRFTOKEN` header. +- Domain: `admin.mistral.ai`. +- Billing endpoint: `GET https://admin.mistral.ai/api/billing/v2/usage?month=&year=`. +- Returns monthly token usage per model (completion, OCR, audio, connectors, fine-tuning) with pricing. +- Cost computed client-side from token counts × per-model prices included in the response. +- Currency from response (typically EUR). +- Resets at end of calendar month. +- Status: `https://status.mistral.ai` (link only, no auto-polling). + See also: `docs/provider.md` for architecture notes. From a1dfe54d24d2a176c18f7fa965d81cae5faa5f6f Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 25 Apr 2026 03:17:49 +0530 Subject: [PATCH 58/64] Update CHANGELOG to include Mistral provider support with monthly spend tracking and cookie import features (#607). --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce625cb2..ecd57e5ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! - Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98! +- Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic! - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! ### Fixes From bb6fcab98cd2a93c4729c3fd34246bad8f0d7c8a Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 25 Apr 2026 12:38:44 +0530 Subject: [PATCH 59/64] Add GPT-5.5 pricing --- CHANGELOG.md | 1 + .../Vendored/CostUsage/CostUsagePricing.swift | 10 ++++++++ .../CodexBarTests/CostUsagePricingTests.swift | 24 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd57e5ad..aba5db514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes - Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy! +- Codex: add GPT-5.5 and GPT-5.5 Pro pricing so local cost scanning recognizes the new models. - Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98! - Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic! - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 1055e104a..3f7979604 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -112,6 +112,16 @@ enum CostUsagePricing { outputCostPerToken: 1.8e-4, cacheReadInputCostPerToken: nil, displayLabel: nil), + "gpt-5.5": CodexPricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 3e-5, + cacheReadInputCostPerToken: 5e-7, + displayLabel: nil), + "gpt-5.5-pro": CodexPricing( + inputCostPerToken: 3e-5, + outputCostPerToken: 1.8e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), ] private static let claude: [String: ClaudePricing] = [ diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 4917dc120..20af0b54b 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -10,6 +10,8 @@ struct CostUsagePricingTests { #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-pro-2026-03-05") == "gpt-5.4-pro") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-mini-2026-03-17") == "gpt-5.4-mini") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-nano-2026-03-17") == "gpt-5.4-nano") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-2026-04-23") == "gpt-5.5") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-pro-2026-04-23") == "gpt-5.5-pro") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-2026-03-05") == "gpt-5.3-codex") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") } @@ -51,6 +53,28 @@ struct CostUsagePricingTests { #expect(nano != nil) } + @Test + func `codex cost supports gpt55`() { + let cost = CostUsagePricing.codexCostUSD( + model: "openai/gpt-5.5-2026-04-23", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + + #expect(cost == 90 * 5e-6 + 10 * 5e-7 + 5 * 3e-5) + } + + @Test + func `codex cost supports gpt55 pro`() { + let cost = CostUsagePricing.codexCostUSD( + model: "openai/gpt-5.5-pro-2026-04-23", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + + #expect(cost == 100 * 3e-5 + 5 * 1.8e-4) + } + @Test func `codex cost returns zero for research preview model`() { let cost = CostUsagePricing.codexCostUSD( From 00184eac33b9b24c0fc1756e9fcde50a8469f1b4 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 25 Apr 2026 13:49:19 +0530 Subject: [PATCH 60/64] Fix duplicate utilization session tabs --- CHANGELOG.md | 1 + .../PlanUtilizationHistoryChartMenuView.swift | 1 + .../PlanUtilizationHistoryStore.swift | 17 ++- .../CodexBar/UsageStore+PlanUtilization.swift | 1 + .../UsageStorePlanUtilizationTests.swift | 101 ++++++++++++++++++ 5 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aba5db514..8f40e7b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats! ### Fixes +- Codex: ignore invalid zero-minute subscription history so the utilization submenu no longer shows duplicate Session tabs. - Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart. - Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman! - Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch! diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index c82b3740c..7112b50ce 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -185,6 +185,7 @@ struct PlanUtilizationHistoryChartMenuView: View { return histories .filter { history in guard !history.entries.isEmpty else { return false } + guard history.windowMinutes > 0 else { return false } guard let allowedNames else { return true } return allowedNames.contains(history.name) } diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 7fd3e5e89..9bb5f6bc7 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -129,7 +129,9 @@ struct PlanUtilizationHistoryStore { for provider in UsageProvider.allCases { let fileURL = self.providerFileURL(for: provider) let buckets = providers[provider] ?? PlanUtilizationHistoryBuckets() - guard !buckets.isEmpty else { + let unscoped = Self.sortedHistories(buckets.unscoped) + let accounts = Self.sortedAccounts(buckets.accounts) + guard !unscoped.isEmpty || !accounts.isEmpty else { try? FileManager.default.removeItem(at: fileURL) continue } @@ -137,8 +139,8 @@ struct PlanUtilizationHistoryStore { let payload = ProviderHistoryDocument( version: Self.providerSchemaVersion, preferredAccountKey: buckets.preferredAccountKey, - unscoped: Self.sortedHistories(buckets.unscoped), - accounts: Self.sortedAccounts(buckets.accounts)) + unscoped: unscoped, + accounts: accounts) let data = try encoder.encode(payload) try data.write(to: fileURL, options: Data.WritingOptions.atomic) } @@ -209,7 +211,7 @@ struct PlanUtilizationHistoryStore { } private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] { - histories.sorted { lhs, rhs in + self.sanitizedHistories(histories).sorted { lhs, rhs in if lhs.windowMinutes != rhs.windowMinutes { return lhs.windowMinutes < rhs.windowMinutes } @@ -217,6 +219,13 @@ struct PlanUtilizationHistoryStore { } } + private static func sanitizedHistories(_ histories: [PlanUtilizationSeriesHistory]) + -> [PlanUtilizationSeriesHistory] { + histories.filter { history in + history.windowMinutes > 0 && !history.entries.isEmpty + } + } + private static func defaultDirectoryURL() -> URL? { guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index de23b4782..10eca18b0 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -286,6 +286,7 @@ extension UsageStore { guard let name, let window, let windowMinutes = window.windowMinutes, + windowMinutes > 0, let usedPercent = Self.clampedPercent(window.usedPercent) else { return diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 89f2fd40a..539786eb7 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -123,6 +123,9 @@ struct UsageStorePlanUtilizationTests { @Test func `native chart shows visible series tabs only`() { let histories = [ + planSeries(name: .session, windowMinutes: 0, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 90), + ]), planSeries(name: .session, windowMinutes: 300, entries: [ planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), ]), @@ -588,6 +591,39 @@ struct UsageStorePlanUtilizationTests { #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.resetsAt == secondaryReset) } + @MainActor + @Test + func `record plan history skips invalid zero minute windows`() async { + let store = Self.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 0, + resetsAt: Date(timeIntervalSince1970: 1_710_000_000), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 44, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_710_086_400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "plus")) + store._setSnapshotForTesting(snapshot, provider: .codex) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let histories = store.planUtilizationHistory(for: .codex) + #expect(findSeries(histories, name: .session, windowMinutes: 0) == nil) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 44) + } + @MainActor @Test func `record plan history keeps semantic codex lanes when durations drift`() async { @@ -901,6 +937,64 @@ struct UsageStorePlanUtilizationTests { #expect(loaded.isEmpty) } + @Test + func `store drops invalid zero minute and empty histories when loading and saving`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let directoryURL = root + .appendingPathComponent("com.steipete.codexbar", isDirectory: true) + .appendingPathComponent("history", isDirectory: true) + let providerURL = directoryURL.appendingPathComponent("codex.json") + let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true) + + let validUnscoped = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 12), + ]) + let validAccount = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 64), + ]) + let document = PersistedFixtureDocument( + version: 1, + preferredAccountKey: "alice", + unscoped: [ + planSeries(name: .session, windowMinutes: 0, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 99), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: []), + validUnscoped, + ], + accounts: [ + "alice": [ + planSeries(name: .session, windowMinutes: 0, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 88), + ]), + validAccount, + ], + "empty": [ + planSeries(name: .weekly, windowMinutes: 10080, entries: []), + ], + ]) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(document).write(to: providerURL, options: Data.WritingOptions.atomic) + + let loaded = store.load() + let loadedBuckets = try #require(loaded[.codex]) + #expect(loadedBuckets.unscoped == [validUnscoped]) + #expect(loadedBuckets.accounts == ["alice": [validAccount]]) + + store.save(loaded) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let rewritten = try decoder.decode(PersistedFixtureDocument.self, from: Data(contentsOf: providerURL)) + #expect(rewritten.unscoped == [validUnscoped]) + #expect(rewritten.accounts == ["alice": [validAccount]]) + } + @Test func `store round trips account buckets with series entries`() { let root = FileManager.default.temporaryDirectory @@ -935,6 +1029,13 @@ struct UsageStorePlanUtilizationTests { } extension UsageStorePlanUtilizationTests { + private struct PersistedFixtureDocument: Codable { + let version: Int + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] + } + private struct FixtureDocument: Decodable { let preferredAccountKey: String? let unscoped: [PlanUtilizationSeriesHistory] From 3f5c1200964635b586a02278ed5af0302992d1b4 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sat, 25 Apr 2026 17:15:15 +0800 Subject: [PATCH 61/64] Fix menu height shrink after MiniMax section collapse Resolve the post-merge sizing regression by using instance-based remeasurement during menu refresh, so long MiniMax usage content can collapse correctly and the menu window shrinks as expected. Made-with: Cursor --- Sources/CodexBar/StatusItemController+Menu.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index a27e52b72..0ddf73fbc 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -989,7 +989,8 @@ extension StatusItemController { let isOverviewRow = represented?.hasPrefix(Self.overviewRowIdentifierPrefix) == true let isMenuCard = represented?.hasPrefix("menuCard") == true guard (isOverviewRow || isMenuCard), let view = item.view else { continue } - let height = self.menuCardHeight(for: view, width: width) + // Use instance-based remeasure here so dynamic collapse/expand state can shrink as well as grow. + let height = self.remeasuredMenuCardHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) } } From f4e08c63e64155b9b8135611297bf351df00d3d3 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sat, 25 Apr 2026 18:26:00 +0800 Subject: [PATCH 62/64] Handle unknown MiniMax usage percent and prevent overview tap bleed. Keep MiniMax progress unknown when interval usage counts are missing, and suppress overview row selection when users tap in-card controls like section collapse buttons. Made-with: Cursor --- Sources/CodexBar/MenuCardView.swift | 9 ++--- Sources/CodexBar/MiniMaxMenuCardViews.swift | 31 +++++++++++----- .../PreferencesProviderDetailView.swift | 36 ++++++++++++------- .../CodexBar/StatusItemController+Menu.swift | 2 +- ...tatusItemController+MenuPresentation.swift | 17 +++++++++ .../UsageMenuCardViewModel+MiniMax.swift | 8 ++--- docs/minimax.md | 2 ++ 7 files changed, 73 insertions(+), 32 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 612b531ff..c896a6c39 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -97,7 +97,7 @@ struct UsageMenuCardView: View { struct MiniMaxRow: Identifiable, Equatable { let id: String let title: String - let percent: Double + let percent: Double? let percentStyle: PercentStyle let resetText: String? let detailText: String? @@ -619,16 +619,13 @@ private struct CreditsBarContent: View { let hintCopyText: String? let progressColor: Color @Environment(\.menuItemHighlighted) private var isHighlighted - private var percentLeft: Double? { guard let creditsRemaining else { return nil } - let percent = (creditsRemaining / Self.fullScaleTokens) * 100 - return min(100, max(0, percent)) + return min(100, max(0, (creditsRemaining / Self.fullScaleTokens) * 100)) } private var scaleText: String { - let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) - return "\(scale) tokens" + "\(UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))) tokens" } var body: some View { diff --git a/Sources/CodexBar/MiniMaxMenuCardViews.swift b/Sources/CodexBar/MiniMaxMenuCardViews.swift index ec4327e7a..e9c3a9375 100644 --- a/Sources/CodexBar/MiniMaxMenuCardViews.swift +++ b/Sources/CodexBar/MiniMaxMenuCardViews.swift @@ -93,15 +93,28 @@ struct MiniMaxTokenPlanRowView: View { .font(.caption2) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } - UsageProgressBar( - percent: self.row.percent, - tint: self.progressColor, - accessibilityLabel: self.row.percentStyle.accessibilityLabel) - HStack(alignment: .firstTextBaseline) { - Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) - .font(.caption2) - Spacer() - if let reset = self.row.resetText { + if let percent = self.row.percent { + UsageProgressBar( + percent: percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.0f%% %@", percent, self.row.percentStyle.labelSuffix)) + .font(.caption2) + Spacer() + if let reset = self.row.resetText { + Text(reset) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(2) + } + } + } else if let reset = self.row.resetText { + HStack(alignment: .firstTextBaseline) { + Text("Usage unavailable") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + Spacer() Text(reset) .font(.caption2) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index d37be61a0..65610ccac 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -644,19 +644,31 @@ private struct ProviderMiniMaxRowInlineView: View { .font(.footnote) .foregroundStyle(.secondary) } - UsageProgressBar( - percent: self.row.percent, - tint: self.progressColor, - accessibilityLabel: self.row.percentStyle.accessibilityLabel) - .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) + if let percent = self.row.percent { + UsageProgressBar( + percent: percent, + tint: self.progressColor, + accessibilityLabel: self.row.percentStyle.accessibilityLabel) + .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(String(format: "%.0f%% %@", self.row.percent, self.row.percentStyle.labelSuffix)) - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() - Spacer(minLength: 8) - if let resetText = self.row.resetText, !resetText.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(String(format: "%.0f%% %@", percent, self.row.percentStyle.labelSuffix)) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + Spacer(minLength: 8) + if let resetText = self.row.resetText, !resetText.isEmpty { + Text(resetText) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } else if let resetText = self.row.resetText, !resetText.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Usage unavailable") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer(minLength: 8) Text(resetText) .font(.footnote) .foregroundStyle(.secondary) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 0ddf73fbc..9e72bbe5c 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -988,7 +988,7 @@ extension StatusItemController { let represented = item.representedObject as? String let isOverviewRow = represented?.hasPrefix(Self.overviewRowIdentifierPrefix) == true let isMenuCard = represented?.hasPrefix("menuCard") == true - guard (isOverviewRow || isMenuCard), let view = item.view else { continue } + guard isOverviewRow || isMenuCard, let view = item.view else { continue } // Use instance-based remeasure here so dynamic collapse/expand state can shrink as well as grow. let height = self.remeasuredMenuCardHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 418d4e9d2..83238535a 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -123,9 +123,26 @@ final class MenuCardItemHostingView: NSHostingView, Menu @objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) { guard recognizer.state == .ended else { return } + let location = recognizer.location(in: self) + if let hitView = self.hitTest(location), + self.shouldSuppressRowSelection(for: hitView) + { + return + } self.onClick?() } + private func shouldSuppressRowSelection(for hitView: NSView) -> Bool { + var current: NSView? = hitView + while let view = current, view !== self { + if view is NSButton || view is NSControl { + return true + } + current = view.superview + } + return false + } + func measuredHeight(width: CGFloat) -> CGFloat { let controller = NSHostingController(rootView: self.rootView) let measured = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) diff --git a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift index 0927e02f0..264953f9a 100644 --- a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift +++ b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift @@ -46,12 +46,12 @@ extension UsageMenuCardView.Model { static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow { let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left - let used = model.usedPercent ?? 0 - let barPercent = percentStyle == .used ? used : (100 - used) + let used = model.usedPercent + let barPercent = used.map { percentStyle == .used ? $0 : (100 - $0) } let resetText: String? = if let at = model.resetsAt { UsageFormatter.resetLine( for: RateWindow( - usedPercent: used, + usedPercent: used ?? 0, windowMinutes: model.windowMinutes, resetsAt: at, resetDescription: nil), @@ -65,7 +65,7 @@ extension UsageMenuCardView.Model { return MiniMaxRow( id: model.identifier, title: model.displayName, - percent: Self.clamped(barPercent), + percent: barPercent.map { Self.clamped($0) }, percentStyle: percentStyle, resetText: resetText, detailText: detailText, diff --git a/docs/minimax.md b/docs/minimax.md index 0ba441f5d..194eafa46 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -85,6 +85,7 @@ either a Bearer API token or a session cookie header. - The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. - Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). +- If interval counts are partially missing (for example, API omits `current_interval_usage_count`), the row keeps usage percent as **unknown** instead of coercing to `0% used` / `100% left`. Detail text can still show `—/total` when total is known. - Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. - When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.). - Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**. @@ -101,6 +102,7 @@ either a Bearer API token or a session cookie header. - Each grouped section (**5-hour window**, **Daily quota**, **Weekly quota**, **Other windows**) has a tappable header with a chevron. **Collapsed** headers show **`N items`** on the right. Default: **collapsed** when that section has **≥ 5** rows; **expanded** otherwise. The user’s toggle is stored in-process in `MiniMaxSectionCollapseStore` (keyed by section title); it resets on app quit. - Toggling a section invalidates and remeasures the hosting `NSMenuItem` view while the menu is open, so the MiniMax card **shrinks immediately when collapsing** and **grows immediately when expanding** instead of keeping the initial height. - This layout keeps the total `NSMenu` height bounded so app-level items below the card (e.g. Usage Dashboard, Refresh, Settings) remain reachable without relying on the menu’s own overflow chevrons. +- In merged **Overview** mode, MiniMax section header taps (collapse/expand) are handled as in-card interactions and must not trigger the row-level provider-selection action. ## Key files From b2ad13269afdfca44fdf6d9b1782d64ba3270c6f Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sat, 25 Apr 2026 19:10:50 +0800 Subject: [PATCH 63/64] Harden MiniMax row identity and menu screen height sizing. Make model-remains row IDs collision-safe for SwiftUI diffing and compute MiniMax menu scroll caps from the menu/status-item display screen instead of global NSScreen.main. Made-with: Cursor --- Sources/CodexBar/MenuCardView.swift | 41 +++++-------------- .../CodexBar/StatusItemController+Menu.swift | 26 +++++++++--- .../StatusItemController+MenuTypes.swift | 12 +++++- .../MiniMax/MiniMaxUsageFetcher.swift | 8 ++-- docs/minimax.md | 3 +- 5 files changed, 48 insertions(+), 42 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c896a6c39..81e24a181 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -127,23 +127,11 @@ struct UsageMenuCardView: View { let model: Model let width: CGFloat let onMiniMaxLayoutChange: (() -> Void)? + let miniMaxVisibleScreenHeight: CGFloat? @Environment(\.menuItemHighlighted) private var isHighlighted - init(model: Model, width: CGFloat, onMiniMaxLayoutChange: (() -> Void)? = nil) { - self.model = model - self.width = width - self.onMiniMaxLayoutChange = onMiniMaxLayoutChange - } - static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { - if provider == .openrouter, metric.id == "primary" { - return "API key limit" - } - return metric.title - } - - static func miniMaxUsageScrollMaxHeight() -> CGFloat { - MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: NSScreen.main?.visibleFrame.height) + provider == .openrouter && metric.id == "primary" ? "API key limit" : metric.title } var body: some View { @@ -173,7 +161,10 @@ struct UsageMenuCardView: View { if hasUsage { Group { if hasMiniMaxSections { - MiniMaxCappedScrollView(maxHeight: Self.miniMaxUsageScrollMaxHeight()) { + MiniMaxCappedScrollView( + maxHeight: MiniMaxUILayoutMetrics + .menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight)) + { VStack(alignment: .leading, spacing: 12) { ForEach(self.model.metrics, id: \.id) { metric in MetricRow( @@ -516,27 +507,17 @@ struct UsageMenuCardUsageSectionView: View { let bottomPadding: CGFloat let width: CGFloat let onMiniMaxLayoutChange: (() -> Void)? + let miniMaxVisibleScreenHeight: CGFloat? @Environment(\.menuItemHighlighted) private var isHighlighted - init( - model: UsageMenuCardView.Model, - showBottomDivider: Bool, - bottomPadding: CGFloat, - width: CGFloat, - onMiniMaxLayoutChange: (() -> Void)? = nil) - { - self.model = model - self.showBottomDivider = showBottomDivider - self.bottomPadding = bottomPadding - self.width = width - self.onMiniMaxLayoutChange = onMiniMaxLayoutChange - } - var body: some View { let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false VStack(alignment: .leading, spacing: 12) { if hasMiniMaxSections { - MiniMaxCappedScrollView(maxHeight: UsageMenuCardView.miniMaxUsageScrollMaxHeight()) { + MiniMaxCappedScrollView( + maxHeight: MiniMaxUILayoutMetrics + .menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight)) + { self.usageContent(hasMiniMaxSections: hasMiniMaxSections) } } else { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 9e72bbe5c..32f560f61 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -50,6 +50,16 @@ extension StatusItemController { return max(measuredWidth, Self.menuCardBaseWidth) } + private func menuVisibleScreenHeight(for menu: NSMenu) -> CGFloat? { + if let viewHeight = menu.items.lazy + .compactMap({ $0.view?.window?.screen?.visibleFrame.height }) + .first + { + return viewHeight + } + return self.statusItem.button?.window?.screen?.visibleFrame.height + } + func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) @@ -418,7 +428,8 @@ extension StatusItemController { OverviewMenuCardRowView( model: row.model, width: menuWidth, - onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: identifier, width: menuWidth, onClick: { [weak self, weak menu] in @@ -467,7 +478,8 @@ extension StatusItemController { UsageMenuCardView( model: model, width: context.menuWidth, - onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: "menuCard", width: context.menuWidth)) menu.addItem(.separator()) @@ -477,7 +489,8 @@ extension StatusItemController { UsageMenuCardView( model: model, width: context.menuWidth, - onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: "menuCard-\(index)", width: context.menuWidth)) if index < cards.count - 1 { @@ -511,7 +524,8 @@ extension StatusItemController { UsageMenuCardView( model: model, width: context.menuWidth, - onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu)), + onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu), + miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)), id: "menuCard", width: context.menuWidth)) if context.openAIContext.canShowBuyCredits { @@ -1086,7 +1100,9 @@ extension StatusItemController { model: model, showBottomDivider: false, bottomPadding: usageBottomPadding, - width: width) + width: width, + onMiniMaxLayoutChange: nil, + miniMaxVisibleScreenHeight: nil) let usageSubmenu = self.makeUsageSubmenu( provider: provider, snapshot: self.store.snapshot(for: provider), diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index e56199801..caf57de1a 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -17,11 +17,18 @@ struct OverviewMenuCardRowView: View { let model: UsageMenuCardView.Model let width: CGFloat let onMiniMaxLayoutChange: (() -> Void)? + let miniMaxVisibleScreenHeight: CGFloat? - init(model: UsageMenuCardView.Model, width: CGFloat, onMiniMaxLayoutChange: (() -> Void)? = nil) { + init( + model: UsageMenuCardView.Model, + width: CGFloat, + onMiniMaxLayoutChange: (() -> Void)? = nil, + miniMaxVisibleScreenHeight: CGFloat? = nil) + { self.model = model self.width = width self.onMiniMaxLayoutChange = onMiniMaxLayoutChange + self.miniMaxVisibleScreenHeight = miniMaxVisibleScreenHeight } var body: some View { @@ -36,7 +43,8 @@ struct OverviewMenuCardRowView: View { showBottomDivider: false, bottomPadding: 6, width: self.width, - onMiniMaxLayoutChange: self.onMiniMaxLayoutChange) + onMiniMaxLayoutChange: self.onMiniMaxLayoutChange, + miniMaxVisibleScreenHeight: self.miniMaxVisibleScreenHeight) } } .frame(width: self.width, alignment: .leading) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index b7fe788d8..eda24a2b1 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -647,16 +647,16 @@ enum MiniMaxUsageParser { } private static func modelIdentifier(row: MiniMaxModelRemains, index: Int) -> String { - let candidates = [ + let primary = [ + row.modelId, row.modelName, row.modelType, - row.modelId, row.modelTitle, row.displayName, ] - for candidate in candidates { + for candidate in primary { let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { return trimmed } + if !trimmed.isEmpty { return "\(trimmed)#\(index)" } } return "model-\(index + 1)" } diff --git a/docs/minimax.md b/docs/minimax.md index 194eafa46..a0126e43b 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -86,6 +86,7 @@ either a Bearer API token or a session cookie header. - The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. - Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). - If interval counts are partially missing (for example, API omits `current_interval_usage_count`), the row keeps usage percent as **unknown** instead of coercing to `0% used` / `100% left`. Detail text can still show `—/total` when total is known. +- `MiniMaxModelUsage.identifier` is collision-safe for UI diffing: rows with duplicated/missing `model_name`/`model_type` still get distinct stable IDs within a payload (used by SwiftUI `ForEach`). - Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. - When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.). - Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**. @@ -98,7 +99,7 @@ either a Bearer API token or a session cookie header. ### Menu-bar card layout (MiniMax-only) -- When `minimaxSections` is present, the card wraps **metrics + usage notes + multi-model sections** in an internal vertical `ScrollView`. The scroll region first **measures the rendered content height** and then applies an explicit frame height of `min(actualContentHeight, min(640, max(320, NSScreen.main.visibleFrame.height − 310)))`. This means the card **shrinks to fit** when collapsed/short and **scrolls only when content exceeds the cap**. The **header** (provider name / account / plan) stays **above** this scroll region so account context remains visible while scrolling. +- When `minimaxSections` is present, the card wraps **metrics + usage notes + multi-model sections** in an internal vertical `ScrollView`. The scroll region first **measures the rendered content height** and then applies an explicit frame height of `min(actualContentHeight, min(640, max(320, menuScreenVisibleHeight − 310)))`. `menuScreenVisibleHeight` is resolved from the status-item/menu display screen (with fallback only when unavailable), so multi-monitor setups use the correct cap. This means the card **shrinks to fit** when collapsed/short and **scrolls only when content exceeds the cap**. The **header** (provider name / account / plan) stays **above** this scroll region so account context remains visible while scrolling. - Each grouped section (**5-hour window**, **Daily quota**, **Weekly quota**, **Other windows**) has a tappable header with a chevron. **Collapsed** headers show **`N items`** on the right. Default: **collapsed** when that section has **≥ 5** rows; **expanded** otherwise. The user’s toggle is stored in-process in `MiniMaxSectionCollapseStore` (keyed by section title); it resets on app quit. - Toggling a section invalidates and remeasures the hosting `NSMenuItem` view while the menu is open, so the MiniMax card **shrinks immediately when collapsing** and **grows immediately when expanding** instead of keeping the initial height. - This layout keeps the total `NSMenu` height bounded so app-level items below the card (e.g. Usage Dashboard, Refresh, Settings) remain reachable without relying on the menu’s own overflow chevrons. From 4fa0330dbb2d768c83b18f42c374cc899e058004 Mon Sep 17 00:00:00 2001 From: Gu Cheng Date: Sat, 25 Apr 2026 19:28:12 +0800 Subject: [PATCH 64/64] Keep MiniMax identifiers stable and only suffix duplicates. Preserve unsuffixed model identifiers for lookup compatibility while appending #n only when duplicate IDs appear within the same payload. Made-with: Cursor --- .../MiniMax/MiniMaxUsageFetcher.swift | 26 +++++++++++++++---- docs/minimax.md | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index eda24a2b1..95662158b 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -567,8 +567,21 @@ enum MiniMaxUsageParser { nil } + let baseIdentifiers = rows.enumerated().map { index, row in + self.modelIdentifierBase(row: row, index: index) + } + var seenIdentifierCounts: [String: Int] = [:] + let identifiers = baseIdentifiers.map { baseIdentifier in + let seen = seenIdentifierCounts[baseIdentifier, default: 0] + seenIdentifierCounts[baseIdentifier] = seen + 1 + return seen == 0 ? baseIdentifier : "\(baseIdentifier)#\(seen)" + } + let models = rows.enumerated().map { index, row in - self.buildModelUsage(row: row, index: index, now: now) + self.buildModelUsage( + row: row, + identifier: identifiers[index], + now: now) } return MiniMaxUsageSnapshot( @@ -583,7 +596,11 @@ enum MiniMaxUsageParser { models: models) } - private static func buildModelUsage(row: MiniMaxModelRemains, index: Int, now: Date) -> MiniMaxModelUsage { + private static func buildModelUsage( + row: MiniMaxModelRemains, + identifier: String, + now: Date) -> MiniMaxModelUsage + { let total = row.currentIntervalTotalCount let remaining = row.currentIntervalUsageCount let usedPercent = self.usedPercent(total: total, remaining: remaining) @@ -620,7 +637,6 @@ enum MiniMaxUsageParser { self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now) } - let identifier = self.modelIdentifier(row: row, index: index) let displayName = self.modelDisplayName(row: row, identifier: identifier) let windowKind = self.classifyWindowKind( windowMinutes: windowMinutes, @@ -646,7 +662,7 @@ enum MiniMaxUsageParser { window: windowKind) } - private static func modelIdentifier(row: MiniMaxModelRemains, index: Int) -> String { + private static func modelIdentifierBase(row: MiniMaxModelRemains, index: Int) -> String { let primary = [ row.modelId, row.modelName, @@ -656,7 +672,7 @@ enum MiniMaxUsageParser { ] for candidate in primary { let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { return "\(trimmed)#\(index)" } + if !trimmed.isEmpty { return trimmed } } return "model-\(index + 1)" } diff --git a/docs/minimax.md b/docs/minimax.md index a0126e43b..2de11af52 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -86,7 +86,7 @@ either a Bearer API token or a session cookie header. - The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`. - Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before). - If interval counts are partially missing (for example, API omits `current_interval_usage_count`), the row keeps usage percent as **unknown** instead of coercing to `0% used` / `100% left`. Detail text can still show `—/total` when total is known. -- `MiniMaxModelUsage.identifier` is collision-safe for UI diffing: rows with duplicated/missing `model_name`/`model_type` still get distinct stable IDs within a payload (used by SwiftUI `ForEach`). +- `MiniMaxModelUsage.identifier` preserves unsuffixed model keys for normal rows (for stable lookups), and only appends `#n` when duplicate identifiers occur within the same payload so SwiftUI `ForEach` still has unique row IDs. - Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row. - When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.). - Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**.