diff --git a/README.md b/README.md index 19bb92a763..0fbcb41ed8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE) [![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app) -CodexBar — every AI coding limit in your menu bar. 63 providers. +CodexBar — every AI coding limit in your menu bar. 64 providers. Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons. @@ -94,6 +94,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking. - [MiniMax](docs/minimax.md) — API token, cookie header, or browser cookies for coding-plan usage. - [T3 Chat](docs/t3chat.md) — Browser cookies capture for Base and Overage usage buckets. +- [ZoomMate](docs/zoommate.md) — Chrome cookie auto-import or manual cURL capture for credits usage. - [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit. - [Kilo](docs/kilo.md) — API token with CLI-auth fallback for Kilo Pass usage. - [Kiro](docs/kiro.md) — CLI-based usage; monthly credits + bonus credits. diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index afbfe10041..bdc5c21cc4 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -255,6 +255,12 @@ extension UsageMenuCardView.Model { { return Self.poeInlineDashboard(usage, now: input.now) } + if input.provider == .zoommate, + let history = input.snapshot?.zoommateCreditsHistory, + !history.dailyBreakdown().isEmpty || history.pacingVerdict() != nil + { + return Self.zoommateInlineDashboard(history) + } if [.codex, .claude, .vertexai, .bedrock, .cursor, .opencodego].contains(input.provider), input.tokenCostInlineDashboardEnabled, let tokenSnapshot = input.tokenSnapshot, @@ -268,6 +274,57 @@ extension UsageMenuCardView.Model { return nil } + private static func zoommateInlineDashboard( + _ history: ZoomMateCreditsHistorySnapshot) + -> InlineUsageDashboardModel + { + let breakdown = history.dailyBreakdown() + let today = history.todayCreditsUsed() + let total = breakdown.reduce(0) { $0 + $1.totalCreditsUsed } + let points = breakdown.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.day, + label: Self.shortDayLabel($0.day), + value: $0.totalCreditsUsed, + accessibilityValue: "\($0.day): \(Self.creditsSummary($0.totalCreditsUsed))") + } + var details: [String] = [] + if let pace = history.pacingVerdict() { + details.append(Self.zoommatePaceLabel(for: pace)) + } + var model = InlineUsageDashboardModel( + accessibilityLabel: L("ZoomMate 30 day credits usage trend"), + valueStyle: .tokens, + kpis: [ + .init(title: L("Today"), value: Self.creditsSummary(today ?? 0), emphasis: true), + .init(title: L("30d credits"), value: Self.creditsSummary(total), emphasis: false), + ], + points: points, + detailLines: details) + model.barColor = Self.inlineDashboardBarColor(for: .zoommate) + return model + } + + private static func creditsSummary(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0...2))) + } + + private static func zoommatePaceLabel(for pace: UsagePace) -> String { + let deltaValue = Int(abs(pace.deltaPercent).rounded()) + switch pace.stage { + case .onTrack: + return L("Pace: on track") + case .slightlyAhead, .ahead, .farAhead: + return deltaValue == 0 + ? L("Pace: ahead of budget") + : L("Pace: %d%% ahead of budget", deltaValue) + case .slightlyBehind, .behind, .farBehind: + return deltaValue == 0 + ? L("Pace: behind budget") + : L("Pace: %d%% behind budget", deltaValue) + } + } + static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { provider == .openai || provider == .mistral || provider == .groq } @@ -777,7 +834,7 @@ extension UsageMenuCardView.Model { return .currency(symbol: symbol) } - private static func shortDayLabel(_ day: String) -> String { + static func shortDayLabel(_ day: String) -> String { let pieces = day.split(separator: "-") guard pieces.count == 3, let rawDay = Int(pieces[2]) else { return day } return "\(rawDay)" diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 638cb8b4a7..aa066d0769 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -122,6 +122,16 @@ extension UsageMenuCardView.Model { self.placeholder != nil } + var creditsOnlyInlineUsageDashboard: Bool { + self.creditsText != nil && + self.inlineUsageDashboard != nil && + self.metrics.isEmpty && + self.usageNotes.isEmpty && + self.openAIAPIUsage == nil && + self.codexResetCredits == nil && + self.placeholder == nil + } + var usesStackedDetailLayout: Bool { !self.metrics.isEmpty || self.creditsText != nil || diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index f0b01e1cef..2d06ca2f88 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -192,10 +192,10 @@ struct UsageMenuCardView: View { let hasCost = liveModel.tokenUsage != nil || hasProviderCost VStack(alignment: .leading, spacing: 12) { - if hasUsage { + if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard { UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: false) } - if hasUsage, hasCredits || hasCost { + if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard, hasCredits || hasCost { Divider() } if let credits = liveModel.creditsText { @@ -208,6 +208,9 @@ struct UsageMenuCardView: View { hintCopyText: liveModel.creditsHintCopyText, progressColor: liveModel.progressColor) } + if liveModel.creditsOnlyInlineUsageDashboard, let dashboard = liveModel.inlineUsageDashboard { + InlineUsageDashboardContent(model: dashboard) + } if hasCredits, hasCost { Divider() } diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index d21336adfc..d868a64485 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -316,6 +316,7 @@ struct DebugPane: View { Text("Augment").tag(UsageProvider.augment) Text("Amp").tag(UsageProvider.amp) Text("T3 Chat").tag(UsageProvider.t3chat) + Text("ZoomMate").tag(UsageProvider.zoommate) Text("Ollama").tag(UsageProvider.ollama) } .pickerStyle(.segmented) diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 0c13716e7f..6f23c7bac1 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -76,6 +76,7 @@ enum ProviderImplementationRegistry { case .wayfinder: WayfinderProviderImplementation() case .zenmux: ZenMuxProviderImplementation() case .aiand: AiAndProviderImplementation() + case .zoommate: ZoomMateProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift b/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift new file mode 100644 index 0000000000..d83e1fb96c --- /dev/null +++ b/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift @@ -0,0 +1,100 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct ZoomMateProviderImplementation: ProviderImplementation { + let id: UsageProvider = .zoommate + + /// ZoomMate is a web-cookie provider with no CLI/version detector, so the default detail line + /// ("zoommate not detected") would misleadingly read as "provider not found". Match the other + /// web-cookie providers (Cursor, Perplexity, Manus, …) and surface the source instead. + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.zoomMateCookieSource + _ = settings.zoomMateCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .zoommate(context.settings.zoomMateSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.zoomMateCookieSource.rawValue }, + set: { raw in + context.settings.zoomMateCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.zoomMateCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically signs in using your ZoomMate session cookies from Chrome.", + manual: "Paste a cURL capture from the ZoomMate AI credit usage page.", + off: "Paste a cURL capture from the ZoomMate AI credit usage page.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "zoommate-cookie-source", + title: "Cookie source", + subtitle: "Automatically signs in using your ZoomMate session cookies from Chrome.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + ProviderCookieRefreshAction.trailingText( + provider: .zoommate, + cookieSource: context.settings.zoomMateCookieSource, + context: context) + }, + trailingActions: [ + ProviderCookieRefreshAction.descriptor( + provider: .zoommate, + cookieSource: { context.settings.zoomMateCookieSource }, + context: context), + ]), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "zoommate-cookie", + title: "ZoomMate capture", + subtitle: "Paste a full cURL capture from the ZoomMate AI credit usage page. " + + "The token expires approximately hourly, so you may need to re-paste periodically.", + kind: .secure, + placeholder: "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: ...'", + binding: context.stringBinding(\.zoomMateCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "zoommate-open-app", + title: "Open ZoomMate", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://zoommate.zoom.us/#/?settings=credit-usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.zoomMateCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift b/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift new file mode 100644 index 0000000000..f44e01ad45 --- /dev/null +++ b/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var zoomMateCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .zoommate)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .zoommate) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .zoommate, field: "cookieHeader", value: newValue) + } + } + + var zoomMateCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .zoommate, fallback: .auto) } + set { + self.updateProviderConfig(provider: .zoommate) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .zoommate, field: "cookieSource", value: newValue.rawValue) + } + } +} + +extension SettingsStore { + func zoomMateSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ZoomMateProviderSettings + { + self.resolvedCookieSettings( + provider: .zoommate, + configuredSource: self.zoomMateCookieSource, + configuredHeader: self.zoomMateCookieHeader, + tokenOverride: tokenOverride) + } +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg b/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg new file mode 100644 index 0000000000..03b027dd42 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg @@ -0,0 +1 @@ + diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index c8e05dfd69..82da29fb86 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -80,6 +80,7 @@ extension SettingsStore { _ = self.augmentCookieSource _ = self.ampCookieSource _ = self.t3ChatCookieSource + _ = self.zoomMateCookieSource _ = self.ollamaCookieSource _ = self.mergeIcons _ = self.switcherShowsIcons @@ -101,6 +102,7 @@ extension SettingsStore { _ = self.augmentCookieHeader _ = self.ampCookieHeader _ = self.t3ChatCookieHeader + _ = self.zoomMateCookieHeader _ = self.ollamaCookieHeader _ = self.copilotAPIToken _ = self.warpAPIToken diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 3e4b13e6df..9923a05a8b 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -548,7 +548,7 @@ extension StatusItemController { // Before the first fetch lands the submenu still renders (just the website link below), so // every provider with a status feed gets the native submenu rather than a bare link; it // re-hydrates with the live component list once data arrives (see makeStatusComponentsSubmenu). - let components = self.store.statusComponents(for: provider) + let components = Self.filterStatusComponents(self.store.statusComponents(for: provider), for: provider) if !components.isEmpty { if self.menuCardRenderingEnabledForController { final class HostingRelay { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f5d0eaa43c..ec361edf8b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1604,7 +1604,19 @@ extension StatusItemController { /// Providers that surface the live component list as a native submenu. Every other provider /// keeps the plain "Status Page" link that opens the website. Kept deliberately small: these /// are the statuspage.io/incident.io feeds we actively curate and trust to render well. - static let statusComponentsSubmenuProviders: Set = [.claude, .codex, .augment] + static let statusComponentsSubmenuProviders: Set = [.claude, .codex, .augment, .zoommate] + + /// Filters `components` down to a provider's descriptor-owned named allowlist, if configured; + /// returns `components` unchanged when the provider has no allowlist. Matching is by exact + /// `name` equality at the top level only (groups and leaves alike). + static func filterStatusComponents( + _ components: [ProviderStatusComponent], + for provider: UsageProvider) -> [ProviderStatusComponent] + { + let metadata = ProviderDescriptorRegistry.descriptor(for: provider).metadata + guard let allowlist = metadata.statusComponentAllowlist else { return components } + return components.filter { allowlist.contains($0.name) } + } /// Builds the status submenu (component rows + a website link) for the curated providers in /// `statusComponentsSubmenuProviders`. Gated on the provider being in that allowlist (and diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 30be9d30d7..100c393149 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -989,6 +989,7 @@ extension UsageStore { .grok: "Grok debug log not yet implemented", .groq: "Groq debug log not yet implemented", .t3chat: "T3 Chat debug log not yet implemented", + .zoommate: "ZoomMate debug log not yet implemented", .llmproxy: "LLM Proxy debug log not yet implemented", .litellm: "LiteLLM debug log not yet implemented", .deepgram: "Deepgram debug log not yet implemented", @@ -1077,7 +1078,7 @@ extension UsageStore { .mimo, .doubao, .sakana, .abacus, .mistral, .deepinfra, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, .neuralwatt, .clawrouter, .longcat, .wayfinder, - .sub2api, .zenmux, .aiand: + .sub2api, .zenmux, .aiand, .zoommate: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 3708892916..b2ff97b2f5 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -151,6 +151,7 @@ struct TokenAccountCLIContext { } } + // swiftlint:disable:next cyclomatic_complexity private func makeCookieBackedSnapshot( provider: UsageProvider, account: ProviderTokenAccount?, @@ -215,6 +216,8 @@ struct TokenAccountCLIContext { return self.makeSnapshot(abacus: self.makeProviderCookieSettings(cookieSettings)) case .mistral: return self.makeSnapshot(mistral: self.makeProviderCookieSettings(cookieSettings)) + case .zoommate: + return self.makeSnapshot(zoommate: self.makeProviderCookieSettings(cookieSettings)) case .stepfun: let stepfunSettings = self.cookieSettings( provider: provider, @@ -258,7 +261,8 @@ struct TokenAccountCLIContext { abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil, mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil, qoder: ProviderSettingsSnapshot.QoderProviderSettings? = nil, - stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil, + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -278,6 +282,7 @@ struct TokenAccountCLIContext { augment: augment, moonshot: moonshot, amp: amp, + zoommate: zoommate, commandcode: commandcode, ollama: ollama, jetbrains: jetbrains, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d46bf4b5b2..65c53175f6 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "48ac20dad61e9a7f" + static let value = "21fd583e6522353d" } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index dd2b23f375..9d482635fb 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -96,4 +96,5 @@ public enum LogCategories { public static let zaiTokenStore = "zai-token-store" public static let zaiUsage = "zai-usage" public static let stepfunUsage = "stepfun-usage" + public static let zoommate = "zoommate" } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 8e5f757011..45a7f6c6f1 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -116,6 +116,7 @@ public enum ProviderDescriptorRegistry { .wayfinder: WayfinderProviderDescriptor.descriptor, .zenmux: ZenMuxProviderDescriptor.descriptor, .aiand: AiAndProviderDescriptor.descriptor, + .zoommate: ZoomMateProviderDescriptor.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 9c744f589e..768584d0a5 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -30,6 +30,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, t3chat: T3ChatProviderSettings? = nil, + zoommate: ZoomMateProviderSettings? = nil, devin: DevinProviderSettings? = nil, commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, @@ -64,6 +65,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: moonshot, amp: amp, t3chat: t3chat, + zoommate: zoommate, devin: devin, commandcode: commandcode, ollama: ollama, @@ -351,6 +353,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct ZoomMateProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct DevinProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualBearerToken: String? @@ -489,6 +501,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? public let t3chat: T3ChatProviderSettings? + public let zoommate: ZoomMateProviderSettings? public let devin: DevinProviderSettings? public let commandcode: CommandCodeProviderSettings? public let ollama: OllamaProviderSettings? @@ -527,6 +540,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, t3chat: T3ChatProviderSettings? = nil, + zoommate: ZoomMateProviderSettings? = nil, devin: DevinProviderSettings? = nil, commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings?, @@ -560,6 +574,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.moonshot = moonshot self.amp = amp self.t3chat = t3chat + self.zoommate = zoommate self.devin = devin self.commandcode = commandcode self.ollama = ollama @@ -594,6 +609,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) case t3chat(ProviderSettingsSnapshot.T3ChatProviderSettings) + case zoommate(ProviderSettingsSnapshot.ZoomMateProviderSettings) case devin(ProviderSettingsSnapshot.DevinProviderSettings) case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) @@ -629,6 +645,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings? + public var zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings? public var devin: ProviderSettingsSnapshot.DevinProviderSettings? public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? @@ -668,6 +685,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value case let .t3chat(value): self.t3chat = value + case let .zoommate(value): self.zoommate = value case let .devin(value): self.devin = value case let .commandcode(value): self.commandcode = value case let .ollama(value): self.ollama = value @@ -705,6 +723,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { moonshot: self.moonshot, amp: self.amp, t3chat: self.t3chat, + zoommate: self.zoommate, devin: self.devin, commandcode: self.commandcode, ollama: self.ollama, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 5f66dce6f9..0a5d30c09c 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -66,6 +66,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case wayfinder case zenmux case aiand + case zoommate } // swiftformat:enable sortDeclarations @@ -132,6 +133,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case wayfinder case zenmux case aiand + case zoommate case combined } @@ -160,6 +162,8 @@ public struct ProviderMetadata: Sendable { public let statusLinkURL: String? /// Google Workspace product ID for status polling (appsstatus dashboard). public let statusWorkspaceProductID: String? + /// Optional top-level component/group names to show from a provider status feed. + public let statusComponentAllowlist: Set? public init( id: UsageProvider, @@ -181,7 +185,8 @@ public struct ProviderMetadata: Sendable { changelogURL: String? = nil, statusPageURL: String?, statusLinkURL: String? = nil, - statusWorkspaceProductID: String? = nil) + statusWorkspaceProductID: String? = nil, + statusComponentAllowlist: Set? = nil) { self.id = id self.displayName = displayName @@ -203,6 +208,7 @@ public struct ProviderMetadata: Sendable { self.statusPageURL = statusPageURL self.statusLinkURL = statusLinkURL self.statusWorkspaceProductID = statusWorkspaceProductID + self.statusComponentAllowlist = statusComponentAllowlist } } @@ -213,6 +219,14 @@ public enum ProviderDefaults { } public enum ProviderBrowserCookieDefaults { + public static var chromeOnlyImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } + public static var defaultImportOrder: BrowserCookieImportOrder? { #if os(macOS) Browser.defaultImportOrder diff --git a/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift b/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift new file mode 100644 index 0000000000..1f09108884 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Shared parsing for "Copy as cURL" DevTools captures pasted by users into manual-auth provider +/// settings fields. Extracted from T3 Chat's original implementation (see #1830-era history) so +/// other web-cookie/bearer-token providers (e.g. ZoomMate) can reuse the exact same regex/shell +/// unescaping behavior instead of duplicating subtle parsing logic. +public enum CurlCaptureParser { + /// Extracts the request URL from the standard DevTools "Copy as cURL" shape, where the URL is + /// the first argument after `curl`. Returns `nil` for malformed captures or option-first forms. + public static func requestURL(from raw: String) -> URL? { + let pattern = + #"(?s)(?:^|\s)curl\s+"# + + #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|\"((?:\\.|[^\"])*)\"|([^\s\\]+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let range = NSRange(raw.startIndex.. [String] { + var fields: [String] = [] + let pattern = + #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# + + #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields } + let range = NSRange(raw.startIndex.. String? { + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. canonical HTTP header name). + public static func forwardedHeaders(from fields: [String], allowlist: [String: String]) -> [String: String] { + var headers: [String: String] = [:] + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. String? { + guard match.numberOfRanges > index, + let range = Range(match.range(at: index), in: raw) + else { + return nil + } + return String(raw[range]) + } + + private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String { + var output = "" + var index = raw.startIndex + while index < raw.endIndex { + guard raw[index] == "\\" else { + output.append(raw[index]) + index = raw.index(after: index) + continue + } + let next = raw.index(after: index) + guard next < raw.endIndex else { return output } + switch raw[next] { + case "n" where ansi: + output.append("\n") + case "r" where ansi: + output.append("\r") + case "t" where ansi: + output.append("\t") + case "\n": + break + default: + output.append(raw[next]) + } + index = raw.index(after: next) + } + return output + } +} diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift index bddc960e47..53478f6096 100644 --- a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift @@ -244,11 +244,11 @@ public struct T3ChatUsageFetcher: Sendable { static func requestContext(from raw: String?) -> RequestContext? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - let headerFields = Self.headerFields(from: raw) + let headerFields = CurlCaptureParser.headerFields(from: raw) guard let cookieHeader = Self.cookieHeader(from: headerFields) ?? CookieHeaderNormalizer.normalize(raw) else { return nil } - let headers = Self.forwardedHeaders(from: headerFields) + let headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) return RequestContext(cookieHeader: cookieHeader, headers: headers) } @@ -268,88 +268,9 @@ public struct T3ChatUsageFetcher: Sendable { request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") } - private static func forwardedHeaders(from fields: [String]) -> [String: String] { - var headers: [String: String] = [:] - for field in fields { - guard let colon = field.firstIndex(of: ":") else { continue } - let rawName = field[.. String? { - for field in fields { - guard let colon = field.firstIndex(of: ":") else { continue } - let rawName = field[.. [String] { - var fields: [String] = [] - let pattern = - #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# + - #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields } - let range = NSRange(raw.startIndex.. String? { - guard match.numberOfRanges > index, - let range = Range(match.range(at: index), in: raw) - else { - return nil - } - return String(raw[range]) - } - - private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String { - var output = "" - var index = raw.startIndex - while index < raw.endIndex { - guard raw[index] == "\\" else { - output.append(raw[index]) - index = raw.index(after: index) - continue - } - let next = raw.index(after: index) - guard next < raw.endIndex else { return output } - switch raw[next] { - case "n" where ansi: - output.append("\n") - case "r" where ansi: - output.append("\r") - case "t" where ansi: - output.append("\t") - case "\n": - break - default: - output.append(raw[next]) - } - index = raw.index(after: next) - } - return output + guard let raw = CurlCaptureParser.headerValue(named: "Cookie", in: fields) else { return nil } + return CookieHeaderNormalizer.normalize(raw) } private static func customerDataURL() throws -> URL { diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift new file mode 100644 index 0000000000..dd78ac06cb --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift @@ -0,0 +1,63 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +/// Process-lifetime, in-memory cache of freshly-minted ZoomMate bearer JWTs. +/// +/// The `.auto` cookie-mint path exchanges long-lived browser session cookies for a short-lived +/// (~hourly) bearer JWT on demand. Without a cache that mint happens on *every* refresh; this cache +/// lets a still-valid token be reused across refreshes instead. +/// +/// Safety properties (why reuse can't serve a bad token): +/// - Entries are keyed by a non-reversible SHA-256 of the originating cookie header, so distinct +/// browser sessions / accounts never collide and the raw cookies are never stored as a key. +/// - A token is cached *only* when its JWT carries a decodable `exp` claim, and is served only +/// while `now < exp - refreshSkew`. A token whose expiry cannot be determined is never cached +/// (the caller mints fresh), so the cache can never hand back a token past its own expiry. +/// - Nothing is persisted — the cache is empty on every launch. +/// +/// A revoked-before-expiry session is handled by the caller: a `401/403` from a downstream request +/// evicts the entry (see `ZoomMateWebFetchStrategy`) so the next refresh mints fresh. +actor ZoomMateBearerTokenCache { + static let shared = ZoomMateBearerTokenCache() + + /// Refresh this many seconds before the JWT's own `exp`, so an in-flight request never rides a + /// token that expires mid-flight. + static let refreshSkew: TimeInterval = 60 + + struct Entry: Sendable { + let token: String + let accountEmail: String? + let expiry: Date + } + + private var entries: [String: Entry] = [:] + + /// Non-reversible cache key for a cookie session. SHA-256 hex of the raw cookie header. + static func key(forCookieHeader cookieHeader: String) -> String { + let digest = SHA256.hash(data: Data(cookieHeader.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + /// Returns the cached entry for `key` when it is still comfortably in-date, evicting and + /// returning `nil` once it enters the `refreshSkew` window (or has passed `exp`). + func validEntry(forKey key: String, now: Date) -> Entry? { + guard let entry = self.entries[key] else { return nil } + guard entry.expiry.addingTimeInterval(-Self.refreshSkew) > now else { + self.entries[key] = nil + return nil + } + return entry + } + + func store(_ entry: Entry, forKey key: String) { + self.entries[key] = entry + } + + func invalidate(forKey key: String) { + self.entries[key] = nil + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift new file mode 100644 index 0000000000..96a4f9fe37 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift @@ -0,0 +1,92 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +private let zoomMateCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.zoommate]?.browserCookieOrder ?? Browser.defaultImportOrder + +/// Imports ZoomMate's browser session cookies (not the bearer JWT itself — see +/// `ZoomMateUsageFetcher.mintBearerToken`, which exchanges these cookies for a fresh JWT via +/// ZoomMate's own cookie-to-token bootstrap endpoint). Modeled on `T3ChatCookieImporter`. +public enum ZoomMateCookieImporter { + private static let cookieClient = BrowserCookieClient() + /// Includes the parent "zoom.us" domain — ZoomMate's SSO session cookies (`_zm_*`, + /// `cf_clearance`, etc.) are scoped to the shared parent domain, not the leaf subdomains, and + /// domain matching here is substring-based (`.contains`), so this one pattern also matches the + /// leaf domains below; both are kept for clarity. The over-broad `.contains("zoom.us")` read is + /// then narrowed at send time by `isSendable(toSessionHosts:)`. + private static let cookieDomains = ["zoommate.zoom.us", "ai.zoom.us", "zoom.us"] + + /// Hosts whose cookies the fetchers actually transmit (the login-bootstrap and credits calls + /// hit `ai.zoom.us`; the browser session lives on both). Used to drop cookies that a browser + /// would never attach to these requests — see `isSendable(cookieDomain:)`. + private static let sessionHosts = ["ai.zoom.us", "zoommate.zoom.us"] + + public struct SessionInfo: Sendable { + public let cookieHeader: String + public let sourceLabel: String + + public init(cookieHeader: String, sourceLabel: String) { + self.cookieHeader = cookieHeader + self.sourceLabel = sourceLabel + } + } + + public static func importSession( + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) throws -> SessionInfo + { + try self.importSessions(browserDetection: browserDetection, logger: logger)[0] + } + + public static func importSessions( + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) throws -> [SessionInfo] + { + let log: @Sendable (String) -> Void = { msg in logger?("[zoommate-cookie] \(msg)") } + let installed = zoomMateCookieImportOrder.cookieImportCandidates(using: browserDetection) + var sessions: [SessionInfo] = [] + + for browserSource in installed { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + .filter { Self.isSendable(cookieDomain: $0.domain) } + guard !cookies.isEmpty else { continue } + log("\(source.label): found \(cookies.count) matching cookies") + let header = cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + sessions.append(SessionInfo(cookieHeader: header, sourceLabel: source.label)) + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + guard !sessions.isEmpty else { throw ZoomMateUsageError.noSession } + return sessions + } + + /// Whether a browser would attach a cookie scoped to `cookieDomain` to a request to one of + /// `sessionHosts`, per RFC 6265 domain-matching: a host-only cookie matches its exact host; a + /// domain cookie (stored with a leading dot) matches that host and all of its subdomains. This + /// keeps the parent `.zoom.us` SSO cookies the endpoints need while dropping cookies host-scoped + /// to unrelated `*.zoom.us` siblings (marketing/support/web) swept in by the coarse `.contains` + /// domain read above — cookies those endpoints would never receive. + static func isSendable(cookieDomain: String) -> Bool { + let bare = cookieDomain.hasPrefix(".") ? String(cookieDomain.dropFirst()) : cookieDomain + let normalized = bare.lowercased() + guard !normalized.isEmpty else { return false } + return self.sessionHosts.contains { host in + host == normalized || host.hasSuffix("." + normalized) + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift new file mode 100644 index 0000000000..f1d8d6a944 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift @@ -0,0 +1,253 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// One raw ledger row from `GET .../credits/history` (design.md D3). `time` is ISO8601-shaped; +/// `cost` is the credits consumed by that session/task run. +public struct ZoomMateCreditHistoryRecord: Decodable, Sendable { + public let sessionID: String? + public let title: String? + public let cost: Double? + public let time: String? + public let isRunning: Bool? + public let isDeleted: Bool? + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case title + case cost + case time + case isRunning = "is_running" + case isDeleted = "is_deleted" + } + + public init( + sessionID: String?, + title: String?, + cost: Double?, + time: String?, + isRunning: Bool?, + isDeleted: Bool?) + { + self.sessionID = sessionID + self.title = title + self.cost = cost + self.time = time + self.isRunning = isRunning + self.isDeleted = isDeleted + } +} + +/// Aggregated result of fetching `credits/history` across as many pages as needed to cover the +/// requested window. Kept separate from the daily-bucketed breakdown so the same raw records can +/// be re-aggregated without refetching. +/// +/// `creditStatus` carries the `credits/status` snapshot the history fetch was paired with, so +/// the menu layer can compute the pacing verdict (`ZoomMateUsageSnapshot.pacingVerdict`) directly +/// from this one attached object instead of needing a second field on `UsageSnapshot` — deferring +/// pace computation to render time also means it always reflects "now," not the last fetch time. +public struct ZoomMateCreditsHistorySnapshot: Sendable { + public let records: [ZoomMateCreditHistoryRecord] + public let creditStatus: ZoomMateCreditStatus? + public let updatedAt: Date + + public init( + records: [ZoomMateCreditHistoryRecord], + creditStatus: ZoomMateCreditStatus? = nil, + updatedAt: Date) + { + self.records = records + self.creditStatus = creditStatus + self.updatedAt = updatedAt + } + + /// Pacing verdict computed from the paired `credits/status` snapshot, if one was attached at + /// fetch time. `nil` when no `creditStatus` is available (e.g. it wasn't passed to `fetch`) + /// or when the account is unlimited / missing cycle dates — see + /// `ZoomMateCreditStatus.pacingVerdict`. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + self.creditStatus?.pacingVerdict(now: now) + } +} + +/// Fetches and paginates `GET https://ai.zoom.us/ai-computer/api/v1/credits/history` (design.md +/// D3). Reuses the same minted-bearer `RequestContext` as `credits/status` — no separate auth +/// mechanism. `app_id` is confirmed not a scoping filter (D3/R2), so a fixed placeholder matching +/// ZoomMate's own web UI (`demo_app`) is sent on every request. +public struct ZoomMateCreditsHistoryFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.zoommate) + private static let historyPath = "/ai-computer/api/v1/credits/history" + private static let refererURL = URL(string: "https://zoommate.zoom.us")! + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + /// Confirmed cheap for real accounts (design.md D3/R3): 30 days of history is at most a + /// couple of pages at this size, well below any practical rate-limit concern. A larger + /// `limit` than the web UI's `10` reduces round-trips without meaningfully increasing + /// payload size (records are small). + public static let defaultPageLimit = 50 + /// Hard ceiling on pagination requests per fetch, independent of the account's actual + /// history size — guards against an unexpectedly large or misbehaving account/response + /// (e.g. a `total` that never gets satisfied) turning into an unbounded fetch loop. + public static let maxPages = 20 + + public init() {} + + /// Fetches every record whose `time` falls within `[startTime, endTime]`, paginating with + /// `limit`/`page` until the endpoint's flat `total` count is satisfied (no other pagination + /// metadata exists — design.md D3). + public static func fetch( + context: ZoomMateUsageFetcher.RequestContext, + startTime: Date, + endTime: Date, + creditStatus: ZoomMateCreditStatus? = nil, + limit: Int = ZoomMateCreditsHistoryFetcher.defaultPageLimit, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateCreditsHistorySnapshot + { + // The whole pagination loop fails over as a unit so all pages of one snapshot come from + // the same host. + try await ZoomMateUsageFetcher.withAPIHostFailover { host in + var allRecords: [ZoomMateCreditHistoryRecord] = [] + var page = 0 + var total = Int.max + + while page * limit < total, page < self.maxPages { + let request = PageRequest( + host: host, + context: context, + startTime: startTime, + endTime: endTime, + limit: limit, + page: page, + timeout: timeout, + transport: transport) + let envelope = try await self.fetchPage(request) + guard let data = envelope.data else { + throw ZoomMateUsageError.parseFailed("Missing data object in credits/history response.") + } + let pageRecords = data.records ?? [] + allRecords.append(contentsOf: pageRecords) + total = data.total ?? allRecords.count + if pageRecords.isEmpty { + // Defensive: stop if the server ever returns an empty page before `total` is + // reached, rather than looping until `maxPages`. + break + } + // Defensive date-boundary stop (design.md D2): `total` reflects the account's entire + // history, not just the requested window, so a server-side filtering quirk could + // otherwise cause extra pagination past what the window actually needs. If every + // record on this page is already older than the requested `startTime` (rows are + // sorted `time desc`, so an entirely-stale page means all subsequent pages are stale + // too), stop here rather than trusting `total`/`maxPages` to eventually end the loop. + let allOlderThanWindow = pageRecords.allSatisfy { record in + guard let time = record.time, let parsed = Self.parseRecordTime(time) else { return false } + return parsed < startTime + } + if allOlderThanWindow { + break + } + page += 1 + } + + return ZoomMateCreditsHistorySnapshot(records: allRecords, creditStatus: creditStatus, updatedAt: now) + } + } + + private static func fetchPage(_ pageRequest: PageRequest) async throws -> HistoryEnvelope { + var components = URLComponents(string: "https://\(pageRequest.host)\(self.historyPath)")! + components.queryItems = [ + URLQueryItem(name: "app_id", value: "demo_app"), + URLQueryItem(name: "limit", value: String(pageRequest.limit)), + URLQueryItem(name: "page", value: String(pageRequest.page)), + URLQueryItem(name: "sort_by", value: "time"), + URLQueryItem(name: "sort_order", value: "desc"), + URLQueryItem(name: "start_time", value: Self.iso8601String(pageRequest.startTime)), + URLQueryItem(name: "end_time", value: Self.iso8601String(pageRequest.endTime)), + ] + guard let url = components.url else { + throw ZoomMateUsageError.apiError("Failed to build credits/history URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = pageRequest.timeout + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-site", forHTTPHeaderField: "Sec-Fetch-Site") + for (name, value) in pageRequest.context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + request.setValue(pageRequest.context.authorization, forHTTPHeaderField: "Authorization") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + + let response = try await pageRequest.transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate credits/history returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + return try JSONDecoder().decode(HistoryEnvelope.self, from: data) + } catch { + Self.log.error("ZoomMate credits/history parse failed") + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + private static func iso8601String(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } + + /// Parses a record's `time` field for the pagination date-boundary check. Tries with and + /// without fractional seconds, matching the range of ISO8601 shapes the API may return. + private static func parseRecordTime(_ text: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: text) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: text) + } + + private struct PageRequest { + let host: String + let context: ZoomMateUsageFetcher.RequestContext + let startTime: Date + let endTime: Date + let limit: Int + let page: Int + let timeout: TimeInterval + let transport: any ProviderHTTPTransport + } + + private struct HistoryEnvelope: Decodable { + struct DataBox: Decodable { + let records: [ZoomMateCreditHistoryRecord]? + let total: Int? + } + + let data: DataBox? + let statusCode: Int? + let errorMessage: String? + + private enum CodingKeys: String, CodingKey { + case data + case statusCode = "status_code" + case errorMessage = "error_message" + } + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift new file mode 100644 index 0000000000..2df6f0ebb3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift @@ -0,0 +1,248 @@ +import Foundation + +private func zoomMateDate(fromMilliseconds raw: Int64?) -> Date? { + guard let raw, raw > 0 else { return nil } + return Date(timeIntervalSince1970: Double(raw) / 1000) +} + +public enum ZoomMateUsageError: LocalizedError, Sendable { + case noCapture + case noSession + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .noCapture: + "Paste a cURL capture of the HTTPS ZoomMate credits/status request " + + "(from ai.zoom.us or zoommate.zoom.us)." + case .noSession: + "No ZoomMate session is cached and no session cookies were imported from Chrome. " + + "Sign in to zoommate.zoom.us in Chrome and refresh from CodexBar, or paste a cURL capture." + case .invalidCredentials: + "ZoomMate rejected the current credentials. Sign in again in Chrome or paste a fresh cURL capture." + case let .apiError(message): + "ZoomMate API error: \(message)" + case let .parseFailed(message): + "Could not parse ZoomMate usage: \(message)" + } + } +} + +/// Decoded shape of `data.credit_status` from +/// `GET https://ai.zoom.us/ai-computer/api/v1/credits/status`. Dates are epoch milliseconds. +public struct ZoomMateCreditStatus: Decodable, Sendable { + public let budgetCap: Double? + public let usedCredit: Double? + public let remainingCredit: Double? + public let overageCredit: Double? + public let allowOverage: Bool? + public let cycleStartDate: Int64? + public let cycleEndDate: Int64? + public let isQuotaAvailable: Bool? + public let isUnlimited: Bool? + + private enum CodingKeys: String, CodingKey { + case budgetCap = "budget_cap" + case usedCredit = "used_credit" + case remainingCredit = "remaining_credit" + case overageCredit = "overage_credit" + case allowOverage = "allow_overage" + case cycleStartDate = "cycle_start_date" + case cycleEndDate = "cycle_end_date" + case isQuotaAvailable = "is_quota_available" + case isUnlimited = "is_unlimited" + } + + public init( + budgetCap: Double?, + usedCredit: Double?, + remainingCredit: Double?, + overageCredit: Double?, + allowOverage: Bool?, + cycleStartDate: Int64?, + cycleEndDate: Int64?, + isQuotaAvailable: Bool?, + isUnlimited: Bool?) + { + self.budgetCap = budgetCap + self.usedCredit = usedCredit + self.remainingCredit = remainingCredit + self.overageCredit = overageCredit + self.allowOverage = allowOverage + self.cycleStartDate = cycleStartDate + self.cycleEndDate = cycleEndDate + self.isQuotaAvailable = isQuotaAvailable + self.isUnlimited = isUnlimited + } +} + +public struct ZoomMateUsageSnapshot: Sendable { + public let creditStatus: ZoomMateCreditStatus + public let updatedAt: Date + + public init(creditStatus: ZoomMateCreditStatus, updatedAt: Date) { + self.creditStatus = creditStatus + self.updatedAt = updatedAt + } + + /// Implements design D5's credits mapping. `history` is optional and attached only when a + /// `credits/history` fetch succeeded (design.md D3) — its absence never blocks the primary + /// credits/status snapshot from being usable. + public func toUsageSnapshot( + history: ZoomMateCreditsHistorySnapshot? = nil, + accountEmail: String? = nil) -> UsageSnapshot + { + let budgetCap = self.creditStatus.budgetCap ?? 0 + let usedCredit = self.creditStatus.usedCredit ?? 0 + let isUnlimited = self.creditStatus.isUnlimited ?? false + + let usedPercent: Double = if isUnlimited || budgetCap <= 0 { + 0 + } else { + min(100, max(0, usedCredit / budgetCap * 100)) + } + + let resetsAt: Date? = (isUnlimited || budgetCap <= 0) + ? nil + : zoomMateDate(fromMilliseconds: self.creditStatus.cycleEndDate) + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: "Credits") + + let identity = ProviderIdentitySnapshot( + providerID: .zoommate, + accountEmail: accountEmail, + accountOrganization: nil, + loginMethod: accountEmail != nil ? "Cookie" : nil) + + return UsageSnapshot( + primary: primary, + secondary: nil, + zoommateCreditsHistory: history, + updatedAt: self.updatedAt, + identity: identity) + } + + /// Pacing verdict (design.md D3), delegated to `ZoomMateCreditStatus.pacingVerdict` so both + /// this snapshot and `ZoomMateCreditsHistorySnapshot` (which carries its own paired + /// `creditStatus`) can compute the identical verdict without duplicating the algorithm. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + self.creditStatus.pacingVerdict(now: now) + } +} + +extension ZoomMateCreditStatus { + /// Pacing verdict (design.md D3): reuses `UsagePace`'s generic stage thresholds rather than + /// reinventing them. ZoomMate's billing cycle has an arbitrary length (not a fixed weekly + /// cadence), so `windowMinutes` is set to the actual cycle duration in minutes — with + /// `workDays: nil`, `UsagePace.weekly()`'s workday-aware branch never engages and it reduces + /// to a plain linear elapsed-fraction-of-cycle comparison, which is exactly what's needed + /// here despite the "weekly" name. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + guard let budgetCap, budgetCap > 0, + self.isUnlimited != true, + let cycleStartMillis = self.cycleStartDate, + let cycleEndMillis = self.cycleEndDate + else { + return nil + } + guard let cycleStart = zoomMateDate(fromMilliseconds: cycleStartMillis), + let cycleEnd = zoomMateDate(fromMilliseconds: cycleEndMillis), + cycleEnd > cycleStart + else { + return nil + } + + let usedCredit = self.usedCredit ?? 0 + let usedPercent = min(100, max(0, usedCredit / budgetCap * 100)) + let cycleMinutes = Int(cycleEnd.timeIntervalSince(cycleStart) / 60) + guard cycleMinutes > 0 else { return nil } + + let window = RateWindow( + usedPercent: usedPercent, + windowMinutes: cycleMinutes, + resetsAt: cycleEnd, + resetDescription: "Credits") + return UsagePace.weekly(window: window, now: now, workDays: nil) + } +} + +/// One calendar day's total credit consumption, aggregated from raw `credits/history` ledger +/// records. Mirrors `OpenAIDashboardDailyBreakdown`'s shape (`day` as a local `yyyy-MM-dd` key) +/// so the same day-key parsing/formatting used by the Codex credits-history chart applies here +/// unchanged. +public struct ZoomMateCreditDailyBreakdown: Equatable, Sendable { + /// Day key in `yyyy-MM-dd` (local time). + public let day: String + public let totalCreditsUsed: Double + + public init(day: String, totalCreditsUsed: Double) { + self.day = day + self.totalCreditsUsed = totalCreditsUsed + } +} + +extension ZoomMateCreditsHistorySnapshot { + /// Aggregates raw `credits/history` records into a Today/N-day series, one entry per + /// calendar day (local time) that has at least one qualifying record. `is_deleted` records + /// are excluded per design.md D3 (they represent removed sessions, not real spend); running + /// sessions (`is_running == true`) are still counted since their `cost` reflects consumption + /// so far. Records with an unparseable `time` or a negative `cost` are skipped defensively + /// rather than corrupting the aggregate. + /// + /// Records older than a trailing 30-calendar-day window from `now` are excluded before + /// bucketing, mirroring `CostUsageFetcher`'s `since = now - (historyDays - 1)` boundary + /// (design.md D3). This makes the 30-day window an explicit, model-level guarantee rather + /// than an implicit assumption inherited from the fetcher's request parameters — the result + /// stays calendar-bounded even if the fetch window, caching, or pagination ever changes. + public func dailyBreakdown(calendar: Calendar = .current, now: Date = Date()) -> [ZoomMateCreditDailyBreakdown] { + var totalsByDay: [String: Double] = [:] + let dayKeyFormatter = DateFormatter() + dayKeyFormatter.calendar = calendar + dayKeyFormatter.timeZone = calendar.timeZone + dayKeyFormatter.dateFormat = "yyyy-MM-dd" + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoFormatterNoFraction = ISO8601DateFormatter() + isoFormatterNoFraction.formatOptions = [.withInternetDateTime] + + // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. + let since = calendar.date(byAdding: .day, value: -29, to: now) ?? now + + for record in self.records { + guard record.isDeleted != true else { continue } + guard let cost = record.cost, cost >= 0 else { continue } + guard let timeString = record.time else { continue } + guard let date = isoFormatter.date(from: timeString) ?? isoFormatterNoFraction.date(from: timeString) + else { + continue + } + guard date >= calendar.startOfDay(for: since) else { continue } + let dayKey = dayKeyFormatter.string(from: date) + totalsByDay[dayKey, default: 0] += cost + } + + return totalsByDay + .map { ZoomMateCreditDailyBreakdown(day: $0.key, totalCreditsUsed: $0.value) } + .sorted { $0.day < $1.day } + } + + /// Sum of `cost` for whichever calendar day (local time) is "today" relative to `now`, i.e. + /// the current-day bucket from `dailyBreakdown()` if one exists. Used by the inline Today/30d + /// KPI tiles (tasks.md 3.4 follow-up) so the UI layer doesn't need to re-derive day-key + /// formatting itself. + public func todayCreditsUsed(now: Date = Date(), calendar: Calendar = .current) -> Double? { + let dayKeyFormatter = DateFormatter() + dayKeyFormatter.calendar = calendar + dayKeyFormatter.timeZone = calendar.timeZone + dayKeyFormatter.dateFormat = "yyyy-MM-dd" + let todayKey = dayKeyFormatter.string(from: now) + return self.dailyBreakdown(calendar: calendar, now: now).first { $0.day == todayKey }?.totalCreditsUsed + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift new file mode 100644 index 0000000000..cb7418fa11 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift @@ -0,0 +1,171 @@ +import Foundation + +public enum ZoomMateProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .zoommate, + metadata: ProviderMetadata( + id: .zoommate, + displayName: "ZoomMate", + sessionLabel: "Credits", + weeklyLabel: "Credits", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Shows used/remaining credits against your ZoomMate budget cap.", + toggleTitle: "Show ZoomMate usage", + cliName: "zoommate", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.chromeOnlyImportOrder, + dashboardURL: "https://zoommate.zoom.us/#/?settings=credit-usage", + subscriptionDashboardURL: nil, + statusPageURL: "https://www.zoomstatus.com/", + statusComponentAllowlist: [ + "Zoom Meetings", + "ZoomMate", + "My Notes", + "Zoom Workflows", + "Zoom Developer Platform", + "Zoom Support", + "Zoom Website", + ]), + branding: ProviderBranding( + iconStyle: .zoommate, + iconResourceName: "ProviderIcon-zoommate", + // Zoom Brand Center "Visual identity > Color", retrieved 2026-07-18: + // https://brand.zoom.com/document/1#/visual-identity/color + // Bloom is primary; Dawn and Midnight are supporting core colors. + color: ProviderColor(red: 11 / 255, green: 92 / 255, blue: 255 / 255), + confettiPalette: [ + ProviderColor(hex: 0x0B5CFF), + ProviderColor(hex: 0xB4D0F8), + ProviderColor(hex: 0x00053D), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "ZoomMate cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZoomMateWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "zoommate", + aliases: [], + versionDetector: nil)) + } +} + +/// Single unified strategy (modeled on `T3ChatWebFetchStrategy`) branching internally on the +/// selected `cookieSource`: `.auto` resolves a cookie session — the `CookieHeaderCache`d header +/// first, else a fresh browser import whose validated header is persisted back through the cache — +/// and mints a bearer JWT via `ZoomMateUsageFetcher.mintBearerToken`, reusing a still-valid token +/// from `ZoomMateBearerTokenCache` across refreshes; `.manual` uses the pasted cURL capture. +/// Cookies outlive the ~hourly JWT by weeks, so minting from cookies (and caching the result until +/// it nears expiry) avoids the manual re-paste entirely as long as the underlying browser session +/// stays valid, and the persisted header lets background refreshes and the bundled CLI reuse that +/// session without rereading Chrome. A rejected session clears the cached header and retries once +/// with a fresh import (see `fetch`). +struct ZoomMateWebFetchStrategy: ProviderFetchStrategy { + let id: String = "zoommate.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let cookieSource = context.settings?.zoommate?.cookieSource ?? .auto + guard cookieSource != .off else { return false } + if cookieSource == .manual { + return true + } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.zoommate?.cookieSource ?? .auto + do { + return try await self.fetchOnce(context, allowCachedCookieHeader: true) + } catch ZoomMateUsageError.invalidCredentials where cookieSource == .auto { + // The persisted cookie session (or a bearer minted from it) was rejected. Drop the + // cached header and retry once against a fresh browser import, mirroring + // OpenCodeUsageFetchStrategy. Outside user-initiated contexts the import is + // gate-blocked, so the retry surfaces `noSession` instead of replaying a dead cookie. + CookieHeaderCache.clear(provider: .zoommate) + return try await self.fetchOnce(context, allowCachedCookieHeader: false) + } + } + + private func fetchOnce( + _ context: ProviderFetchContext, + allowCachedCookieHeader: Bool) async throws -> ProviderFetchResult + { + let fetcher = ZoomMateUsageFetcher(browserDetection: context.browserDetection) + let manual = Self.manualCookieHeader(from: context) + let logger: (@Sendable (String) -> Void)? = context.verbose + ? { @Sendable msg in CodexBarLog.logger(LogCategories.zoommate).verbose(msg) } + : nil + let requestContext = try await fetcher.resolveRequestContext( + manualCaptureOverride: manual, + allowCachedCookieHeader: allowCachedCookieHeader, + timeout: context.webTimeout, + logger: logger) + let snapshot: ZoomMateUsageSnapshot + do { + snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: requestContext, + timeout: context.webTimeout) + } catch ZoomMateUsageError.invalidCredentials { + // A reused cached bearer token was rejected (revoked session before its own expiry). + // Evict it so the next refresh mints fresh rather than replaying the dead token. + await Self.invalidateCachedBearerToken(for: requestContext) + throw ZoomMateUsageError.invalidCredentials + } + + // The Today/30-day history chart (design.md D3) is a non-fatal adjunct: a failure here + // (e.g. a transient credits/history error) must never block the primary credits/status + // snapshot from being usable, mirroring ZaiUsageStats.fetchUsageWithModelUsage's + // secondary-fetch pattern. + var history: ZoomMateCreditsHistorySnapshot? + do { + let now = Date() + let startTime = Calendar.current.date(byAdding: .day, value: -30, to: now) ?? now + history = try await ZoomMateCreditsHistoryFetcher.fetch( + context: requestContext, + startTime: startTime, + endTime: now, + creditStatus: snapshot.creditStatus, + timeout: context.webTimeout) + } catch ZoomMateUsageError.invalidCredentials { + await Self.invalidateCachedBearerToken(for: requestContext) + CodexBarLog.logger(LogCategories.zoommate) + .info("ZoomMate credits history fetch failed (non-fatal): invalid credentials") + history = nil + } catch { + CodexBarLog.logger(LogCategories.zoommate) + .info("ZoomMate credits history fetch failed (non-fatal): \(error.localizedDescription)") + history = nil + } + + return self.makeResult( + usage: snapshot.toUsageSnapshot(history: history, accountEmail: requestContext.accountEmail), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.zoommate?.cookieSource == .manual else { return nil } + return context.settings?.zoommate?.manualCookieHeader ?? "" + } + + private static func invalidateCachedBearerToken(for requestContext: ZoomMateUsageFetcher.RequestContext) async { + guard let cacheKey = requestContext.cacheKey else { return } + await ZoomMateBearerTokenCache.shared.invalidate(forKey: cacheKey) + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift new file mode 100644 index 0000000000..c3e4f39766 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift @@ -0,0 +1,528 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct ZoomMateUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.zoommate) + private static let refererURL = URL(string: "https://zoommate.zoom.us")! + /// First-party API hosts, tried in order. `ai.zoom.us` and `zoommate.zoom.us` currently serve + /// the same `/ai-computer/` API interchangeably and either may retire in the future, so every + /// API request falls over to the next host on non-auth failures via `withAPIHostFailover` + /// (precedent: `FactoryStatusProbe`'s base-URL candidates). + static let apiHosts = ["ai.zoom.us", "zoommate.zoom.us"] + static let creditsStatusPath = "/ai-computer/api/v1/credits/status" + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + /// Forwarded headers allowlist for the manual `.web` cURL capture. Unlike T3 Chat's, this + /// MUST include `authorization` (design D2) because ZoomMate's credential is a bearer token, + /// not a cookie. + private static let forwardedManualHeaders = [ + "authorization": "Authorization", + "cookie": "Cookie", + "user-agent": "User-Agent", + "accept": "Accept", + "accept-language": "Accept-Language", + "sec-fetch-dest": "Sec-Fetch-Dest", + "sec-fetch-mode": "Sec-Fetch-Mode", + "sec-fetch-site": "Sec-Fetch-Site", + ] + + public struct RequestContext: Sendable { + public let authorization: String + public let headers: [String: String] + /// Signed-in user's email, when known. Only populated by the `.auto` cookie-mint path + /// (sourced from the login bootstrap response's `data.user_profile.email`); the manual + /// `.web` cURL-capture path has no equivalent payload to read it from, so this stays `nil` + /// there. + public let accountEmail: String? + /// Bearer-token cache key for the originating cookie session (`.auto` path only). Lets a + /// caller evict the reused token from `ZoomMateBearerTokenCache` when a downstream request + /// rejects it (`401/403`). `nil` for the manual `.web` path, which carries its own bearer. + public let cacheKey: String? + + public init( + authorization: String, + headers: [String: String] = [:], + accountEmail: String? = nil, + cacheKey: String? = nil) + { + self.authorization = authorization + self.headers = headers + self.accountEmail = accountEmail + self.cacheKey = cacheKey + } + } + + /// Result of `mintBearerToken`: the freshly-minted bearer JWT plus whatever identity + /// enrichment (currently just `email`) the same login bootstrap response happened to include + /// in its `data.user_profile` object. Modeled on the small multi-field result structs other + /// providers return from a single fetch (e.g. `ZoomMateCookieImporter.SessionInfo`). + public struct MintedToken: Sendable { + public let bearerToken: String + public let accountEmail: String? + + public init(bearerToken: String, accountEmail: String?) { + self.bearerToken = bearerToken + self.accountEmail = accountEmail + } + } + + public let browserDetection: BrowserDetection + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + } + + public func fetch( + manualCaptureOverride: String? = nil, + timeout: TimeInterval = 15, + logger: (@Sendable (String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateUsageSnapshot + { + let log: @Sendable (String) -> Void = { msg in logger?("[zoommate] \(msg)") } + let context = try await self.resolveRequestContext( + manualCaptureOverride: manualCaptureOverride, + timeout: timeout, + logger: log, + transport: transport) + if !context.headers.isEmpty { + let headerNames = context.headers.keys.sorted().joined(separator: ", ") + log("Forwarding captured headers: \(headerNames)") + } + return try await Self.fetchCreditsStatus( + context: context, + timeout: timeout, + now: now, + transport: transport) + } + + public static func fetchCreditsStatus( + context: RequestContext, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateUsageSnapshot + { + try await self.withAPIHostFailover { host in + try await self.fetchCreditsStatus( + context: context, + host: host, + timeout: timeout, + now: now, + transport: transport) + } + } + + /// Runs one API request per host in `apiHosts` order, returning the first success. Auth + /// rejections and parse failures propagate immediately — the host answered, so retrying the + /// interchangeable alternate cannot help; anything else (unreachable host, non-auth HTTP + /// error) falls through to the next host so the provider keeps working if either host + /// retires. + static func withAPIHostFailover( + operation: (String) async throws -> T) async throws -> T + { + var lastError: Error? + for (index, host) in self.apiHosts.enumerated() { + try Task.checkCancellation() + do { + return try await operation(host) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch ZoomMateUsageError.invalidCredentials { + throw ZoomMateUsageError.invalidCredentials + } catch let ZoomMateUsageError.parseFailed(message) { + throw ZoomMateUsageError.parseFailed(message) + } catch { + if Task.isCancelled { + throw CancellationError() + } + lastError = error + if index < self.apiHosts.count - 1 { + Self.log.info("ZoomMate API host unavailable; retrying on the alternate host") + } + } + } + throw lastError ?? ZoomMateUsageError.apiError("No ZoomMate API host succeeded.") + } + + private static func fetchCreditsStatus( + context: RequestContext, + host: String, + timeout: TimeInterval, + now: Date, + transport: any ProviderHTTPTransport) async throws -> ZoomMateUsageSnapshot + { + var request = URLRequest(url: URL(string: "https://\(host)\(self.creditsStatusPath)")!) + request.httpMethod = "GET" + request.timeoutInterval = timeout + self.applyDefaultHeaders(to: &request) + for (name, value) in context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + // Authorization is always sent (the required credential per design D2). Origin and Referer + // are fixed here so captured values can never widen the first-party request boundary. + request.setValue(context.authorization, forHTTPHeaderField: "Authorization") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + + let response = try await transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate API returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + let envelope = try JSONDecoder().decode(CreditsStatusEnvelope.self, from: data) + guard let creditStatus = envelope.data?.creditStatus else { + throw ZoomMateUsageError.parseFailed("Missing credit_status object.") + } + return ZoomMateUsageSnapshot(creditStatus: creditStatus, updatedAt: now) + } catch let error as ZoomMateUsageError { + throw error + } catch { + Self.log.error("ZoomMate credits/status parse failed") + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + /// Exchanges a ZoomMate/Zoom session cookie header for a fresh bearer JWT via ZoomMate's own + /// cookie-to-token bootstrap endpoint — the same call its web frontend makes on every page + /// load. Cookies (session/SSO-backed) live far longer than the ~hourly JWT, so minting a fresh + /// token from cookies avoids the manual re-paste entirely as long as the underlying browser + /// session cookies remain valid. Callers should prefer `cachedOrMintedToken`, which reuses a + /// still-valid minted token from `ZoomMateBearerTokenCache` instead of re-minting every fetch. + public static func mintBearerToken( + cookieHeader: String, + timeout: TimeInterval = 15, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MintedToken + { + try await self.withAPIHostFailover { host in + try await self.mintBearerToken( + cookieHeader: cookieHeader, + host: host, + timeout: timeout, + transport: transport) + } + } + + private static func mintBearerToken( + cookieHeader: String, + host: String, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> MintedToken + { + var components = URLComponents(string: "https://\(host)/ai-computer/api/v1/login/")! + components.queryItems = [URLQueryItem(name: "continue", value: "https://zoommate.zoom.us/")] + guard let url = components.url else { + throw ZoomMateUsageError.apiError("Failed to build login bootstrap URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + self.applyDefaultHeaders(to: &request) + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let response = try await transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate login bootstrap returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + let envelope = try JSONDecoder().decode(LoginBootstrapEnvelope.self, from: data) + guard let nak = envelope.data?.nak, !nak.isEmpty else { + throw ZoomMateUsageError.parseFailed("Missing nak in login bootstrap response.") + } + let email = envelope.data?.userProfile?.email?.trimmingCharacters(in: .whitespacesAndNewlines) + return MintedToken(bearerToken: nak, accountEmail: (email?.isEmpty ?? true) ? nil : email) + } catch let error as ZoomMateUsageError { + throw error + } catch { + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + func resolveRequestContext( + manualCaptureOverride: String?, + allowCachedCookieHeader: Bool = true, + timeout: TimeInterval, + logger: (@Sendable (String) -> Void)?, + cache: ZoomMateBearerTokenCache = .shared, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> RequestContext + { + if let manualCaptureOverride { + guard let override = Self.requestContext(from: manualCaptureOverride) else { + throw ZoomMateUsageError.noCapture + } + logger?("[zoommate] Using manual cURL capture") + return override + } + + #if os(macOS) + // Cached cookie header first (Perplexity/OpenCode precedent): Chrome's cookie decryption + // is gated behind user-initiated contexts (`BrowserCookieAccessGate`) to avoid Keychain + // prompts, so background refreshes and the bundled CLI must be able to run entirely from + // the last validated session instead of rereading the browser. + if allowCachedCookieHeader, + let cached = CookieHeaderCache.load(provider: .zoommate), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + logger?("[zoommate] Using cached cookie header from \(cached.sourceLabel)") + return try await Self.requestContext( + forCookieHeader: cached.cookieHeader, + persistingValidatedHeaderAs: nil, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + } + + let sessions = try ZoomMateCookieImporter.importSessions( + browserDetection: self.browserDetection, + logger: logger) + return try await Self.requestContext( + forCookieSessions: sessions, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + #else + throw ZoomMateUsageError.noSession + #endif + } + + #if os(macOS) + /// Tries browser cookie profiles in import order, advancing only when the login bootstrap + /// explicitly rejects a candidate. Network and parse failures surface immediately rather than + /// being hidden by another profile. Only the first successfully minted session is persisted. + static func requestContext( + forCookieSessions sessions: [ZoomMateCookieImporter.SessionInfo], + cache: ZoomMateBearerTokenCache = .shared, + timeout: TimeInterval, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + logger: (@Sendable (String) -> Void)?) async throws -> RequestContext + { + guard !sessions.isEmpty else { throw ZoomMateUsageError.noSession } + + for session in sessions { + logger?("[zoommate] Trying cookies from \(session.sourceLabel)") + do { + return try await self.requestContext( + forCookieHeader: session.cookieHeader, + persistingValidatedHeaderAs: session.sourceLabel, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + } catch ZoomMateUsageError.invalidCredentials { + logger?("[zoommate] Cookie session from \(session.sourceLabel) was rejected") + } + } + + throw ZoomMateUsageError.invalidCredentials + } + + /// Builds the `.auto` request context for a cookie session: reuses or mints the bearer JWT + /// and, when `sourceLabel` is non-nil (a fresh browser import), persists the now-validated + /// cookie header through `CookieHeaderCache`. The successful mint is the validation — + /// ZoomMate's login bootstrap rejects a dead session with 401/403 before anything is stored. + /// Only the cookie header is persisted; the minted bearer stays in the in-memory + /// `ZoomMateBearerTokenCache`. + static func requestContext( + forCookieHeader cookieHeader: String, + persistingValidatedHeaderAs sourceLabel: String?, + cache: ZoomMateBearerTokenCache = .shared, + timeout: TimeInterval, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + logger: (@Sendable (String) -> Void)?) async throws -> RequestContext + { + let minted = try await Self.cachedOrMintedToken( + cookieHeader: cookieHeader, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + if let sourceLabel { + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: cookieHeader, + sourceLabel: sourceLabel) + } + return RequestContext( + authorization: Self.bearerHeaderValue(from: minted.bearerToken), + headers: ["Cookie": cookieHeader], + accountEmail: minted.accountEmail, + cacheKey: ZoomMateBearerTokenCache.key(forCookieHeader: cookieHeader)) + } + #endif + + /// Returns a still-valid cached bearer token for `cookieHeader`, or mints a fresh one and caches + /// it when the minted JWT exposes an `exp` claim. A token whose expiry can't be read is returned + /// but never cached, so `.auto` refreshes degrade to the mint-every-fetch behavior rather than + /// risk serving an undatable (possibly expired) token. + static func cachedOrMintedToken( + cookieHeader: String, + cache: ZoomMateBearerTokenCache, + timeout: TimeInterval, + transport: any ProviderHTTPTransport, + logger: (@Sendable (String) -> Void)?) async throws -> MintedToken + { + let cacheKey = ZoomMateBearerTokenCache.key(forCookieHeader: cookieHeader) + if let entry = await cache.validEntry(forKey: cacheKey, now: Date()) { + logger?("[zoommate] Reusing cached bearer token") + return MintedToken(bearerToken: entry.token, accountEmail: entry.accountEmail) + } + let minted = try await Self.mintBearerToken( + cookieHeader: cookieHeader, + timeout: timeout, + transport: transport) + if let expiry = Self.expiry(fromJWT: minted.bearerToken) { + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: minted.bearerToken, + accountEmail: minted.accountEmail, + expiry: expiry), + forKey: cacheKey) + logger?("[zoommate] Minted fresh bearer token via cookie session (cached until expiry)") + } else { + logger?("[zoommate] Minted fresh bearer token via cookie session (not cached: no expiry claim)") + } + return minted + } + + /// Reads the `exp` claim (seconds since epoch) from a bearer JWT, returning its expiry `Date`. + /// Returns `nil` for anything that isn't a decodable JWT with a numeric `exp` — the caller then + /// treats the token as non-cacheable. Mirrors the base64url/JSON payload decode used elsewhere + /// (e.g. `MiniMaxLocalStorageImporter`); no signature verification (we minted it ourselves). + static func expiry(fromJWT token: String) -> Date? { + let raw = Self.bearerHeaderValue(from: token).dropFirst("Bearer ".count) + let parts = raw.split(separator: ".") + guard parts.count >= 2, let data = Self.base64URLDecode(String(parts[1])) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = (object["exp"] as? NSNumber)?.doubleValue, exp > 0 + else { + return nil + } + return Date(timeIntervalSince1970: exp) + } + + private static func base64URLDecode(_ value: String) -> Data? { + var base64 = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - base64.count % 4) % 4 + if padding > 0 { + base64.append(String(repeating: "=", count: padding)) + } + return Data(base64Encoded: base64) + } + + static func bearerHeaderValue(from rawToken: String) -> String { + let trimmed = rawToken.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("bearer ") { + return trimmed + } + return "Bearer \(trimmed)" + } + + /// Parses a manual cURL capture into a `RequestContext`. Returns `nil` when no non-empty + /// `Authorization` header can be extracted — that's the required credential (design D2). + static func requestContext(from raw: String?) -> RequestContext? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + guard let captureURL = CurlCaptureParser.requestURL(from: raw), self.isAllowedCaptureURL(captureURL) else { + return nil + } + let headerFields = CurlCaptureParser.headerFields(from: raw) + guard let authorization = CurlCaptureParser.headerValue(named: "Authorization", in: headerFields), + !authorization.isEmpty + else { + return nil + } + var headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) + headers.removeValue(forKey: "Authorization") + return RequestContext(authorization: Self.bearerHeaderValue(from: authorization), headers: headers) + } + + /// Captures are accepted from any host in `apiHosts` (the interchangeable first-party API + /// hosts) — DevTools shows the credits/status request on whichever host the web client used — + /// but only for the exact HTTPS credits/status path with no port, userinfo, query, or fragment. + private static func isAllowedCaptureURL(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return url.scheme?.lowercased() == "https" && + self.apiHosts.contains(host) && + url.port == nil && + url.user == nil && + url.password == nil && + url.path == self.creditsStatusPath && + url.query == nil && + url.fragment == nil + } + + private static func applyDefaultHeaders(to request: inout URLRequest) { + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-site", forHTTPHeaderField: "Sec-Fetch-Site") + } + + private struct CreditsStatusEnvelope: Decodable { + struct DataBox: Decodable { + let creditStatus: ZoomMateCreditStatus? + + private enum CodingKeys: String, CodingKey { + case creditStatus = "credit_status" + } + } + + let data: DataBox? + let statusCode: Int? + let errorMessage: String? + + private enum CodingKeys: String, CodingKey { + case data + case statusCode = "status_code" + case errorMessage = "error_message" + } + } + + /// Shape of ZoomMate's cookie-to-token bootstrap response (`GET .../login/?continue=...`). + /// `data.nak` (the freshly-minted bearer JWT) is required; `data.user_profile.email` is + /// decoded as an optional identity-enrichment nice-to-have (never required — a missing/absent + /// `user_profile` or `email` must never fail the mint). The rest of the payload (permissions, + /// cluster config, etc.) is ignored. + private struct LoginBootstrapEnvelope: Decodable { + struct UserProfile: Decodable { + let email: String? + } + + struct DataBox: Decodable { + let nak: String? + let userProfile: UserProfile? + + private enum CodingKeys: String, CodingKey { + case nak + case userProfile = "user_profile" + } + } + + let success: Bool? + let data: DataBox? + } +} diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index c5850000ba..3295a2aa1d 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -1,5 +1,7 @@ import Foundation +// swiftlint:disable file_length + public struct RateWindow: Codable, Equatable, Sendable { public let usedPercent: Double public let windowMinutes: Int? @@ -188,6 +190,7 @@ public struct UsageSnapshot: Codable, Sendable { public let kiroUsage: KiroUsageDetails? public let ampUsage: AmpUsageDetails? public let zaiUsage: ZaiUsageSnapshot? + public let zoommateCreditsHistory: ZoomMateCreditsHistorySnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let deepseekUsage: DeepSeekUsageSummary? public let deepseekDetailedUsageState: DeepSeekDetailedUsageState @@ -259,6 +262,7 @@ public struct UsageSnapshot: Codable, Sendable { ampUsage: AmpUsageDetails? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, + zoommateCreditsHistory: ZoomMateCreditsHistorySnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, deepseekUsage: DeepSeekUsageSummary? = nil, deepseekDetailedUsageState: DeepSeekDetailedUsageState = .notRequested, @@ -295,6 +299,7 @@ public struct UsageSnapshot: Codable, Sendable { self.ampUsage = ampUsage self.providerCost = providerCost self.zaiUsage = zaiUsage + self.zoommateCreditsHistory = zoommateCreditsHistory self.minimaxUsage = minimaxUsage self.deepseekUsage = deepseekUsage self.deepseekDetailedUsageState = deepseekDetailedUsageState @@ -348,6 +353,7 @@ public struct UsageSnapshot: Codable, Sendable { self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) self.ampUsage = try container.decodeIfPresent(AmpUsageDetails.self, forKey: .ampUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time + self.zoommateCreditsHistory = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time self.deepseekUsage = nil // Not persisted, fetched fresh each time self.deepseekDetailedUsageState = .notRequested // Live-only fetch state @@ -633,6 +639,7 @@ public struct UsageSnapshot: Codable, Sendable { ampUsage: self.ampUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, + zoommateCreditsHistory: self.zoommateCreditsHistory, minimaxUsage: self.minimaxUsage, deepseekUsage: deepseekUsage.resolving(self.deepseekUsage), deepseekDetailedUsageState: deepseekDetailedUsageState.resolving(self.deepseekDetailedUsageState), diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 7ff583f33d..6014239c5f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -830,7 +830,7 @@ enum CostUsageScanner { .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepseek, .deepinfra, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .neuralwatt, - .clawrouter, .longcat, .sub2api, .wayfinder, .zenmux, .aiand: + .clawrouter, .longcat, .sub2api, .wayfinder, .zenmux, .aiand, .zoommate: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 6f25cd218d..42062417e6 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -93,6 +93,7 @@ enum ProviderChoice: String, AppEnum { case .moonshot: return nil // Moonshot not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets case .t3chat: return nil // T3 Chat not yet supported in widgets + case .zoommate: return nil // ZoomMate not yet supported in widgets case .ollama: return nil // Ollama not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 51e22c34d0..fdbfcac8c0 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -328,6 +328,7 @@ private struct ProviderSwitchChip: View { case .moonshot: "Moonshot" case .amp: "Amp" case .t3chat: "T3 Chat" + case .zoommate: "ZoomMate" case .ollama: "Ollama" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" @@ -1048,6 +1049,8 @@ enum WidgetColors { Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red case .t3chat: Color(red: 245 / 255, green: 102 / 255, blue: 71 / 255) + case .zoommate: + Color(red: 11 / 255, green: 92 / 255, blue: 255 / 255) // Zoom blue case .ollama: Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal case .synthetic: diff --git a/Tests/CodexBarTests/CurlCaptureParserTests.swift b/Tests/CodexBarTests/CurlCaptureParserTests.swift new file mode 100644 index 0000000000..b77cbe1821 --- /dev/null +++ b/Tests/CodexBarTests/CurlCaptureParserTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CurlCaptureParserTests { + @Test + func `extracts request URL from standard DevTools capture`() { + let curl = "curl 'https://example.com/api/usage' -H 'Authorization: Bearer fake-token'" + #expect(CurlCaptureParser.requestURL(from: curl)?.absoluteString == "https://example.com/api/usage") + } + + @Test + func `extracts request URL from double quoted and bare captures`() { + #expect(CurlCaptureParser.requestURL(from: "curl \"https://example.com/double\"")?.path == "/double") + #expect(CurlCaptureParser.requestURL(from: "curl https://example.com/bare")?.path == "/bare") + } + + @Test + func `request URL returns nil for option first or malformed captures`() { + #expect(CurlCaptureParser.requestURL(from: "curl --location 'https://example.com'") == nil) + #expect(CurlCaptureParser.requestURL(from: "not-curl 'https://example.com'") == nil) + } + + @Test + func `extracts header fields from double quoted headers`() { + let curl = """ + curl 'https://example.com' --header "Authorization: Bearer fake-token" --header "Cookie: session=abc" + """ + let fields = CurlCaptureParser.headerFields(from: curl) + + #expect(fields.contains("Authorization: Bearer fake-token")) + #expect(fields.contains("Cookie: session=abc")) + } + + @Test + func `extracts header fields from single quoted -H flags`() { + let curl = "curl 'https://example.com' -H 'Authorization: Bearer fake-token'" + let fields = CurlCaptureParser.headerFields(from: curl) + + #expect(fields == ["Authorization: Bearer fake-token"]) + } + + @Test + func `header value lookup is case insensitive`() { + let fields = ["AUTHORIZATION: Bearer fake-token"] + #expect(CurlCaptureParser.headerValue(named: "authorization", in: fields) == "Bearer fake-token") + } + + @Test + func `header value lookup returns nil for missing header`() { + let fields = ["Cookie: session=abc"] + #expect(CurlCaptureParser.headerValue(named: "Authorization", in: fields) == nil) + } + + @Test + func `forwarded headers respects allowlist and drops unlisted headers`() { + let fields = [ + "Authorization: Bearer fake-token", + "Cookie: session=abc", + "X-Not-Allowed: nope", + ] + let allowlist = ["authorization": "Authorization", "cookie": "Cookie"] + let headers = CurlCaptureParser.forwardedHeaders(from: fields, allowlist: allowlist) + + #expect(headers["Authorization"] == "Bearer fake-token") + #expect(headers["Cookie"] == "session=abc") + #expect(headers["X-Not-Allowed"] == nil) + } + + @Test + func `forwarded headers can include authorization when allowlisted`() { + // ZoomMate's allowlist differs from T3 Chat's by deliberately including authorization (design D2). + let fields = ["authorization: Bearer fake-token"] + let allowlist = ["authorization": "Authorization"] + let headers = CurlCaptureParser.forwardedHeaders(from: fields, allowlist: allowlist) + + #expect(headers["Authorization"] == "Bearer fake-token") + } +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index e25548ebbd..c93c67caba 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -43,6 +43,7 @@ struct ProviderIconResourcesTests { "wayfinder", "zenmux", "aiand", + "zoommate", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index e1cfb228ef..b3ef9c15fb 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -486,6 +486,20 @@ struct ProviderSettingsDescriptorTests { } extension ProviderSettingsDescriptorTests { + @Test + func `zoommate presentation surfaces web rather than an undetected version`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-zoommate-presentation") + let metadata = try #require(ProviderDescriptorRegistry.metadata[.zoommate]) + let context = fixture.presentationContext(provider: .zoommate, metadata: metadata) + + let detailLine = ZoomMateProviderImplementation() + .presentation(context: context) + .detailLine(context) + + // Web-cookie provider with versionDetector: nil — must not fall back to "zoommate not detected". + #expect(detailLine == "web") + } + @Test func `devin presentation follows store source label`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-devin-presentation") diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 9e90f2b0c6..d5e167a85e 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -251,6 +251,102 @@ struct StatusItemControllerMenuTests { #expect(ceil(submenuMenu.size.width) < 310) } + // MARK: - Status component allowlist + + @Test + @MainActor + func `status component allowlist passes through unfiltered for providers without one`() { + let components = [ + ProviderStatusComponent(id: "1", name: "API", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "2", name: "Web App", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .claude) + + #expect(filtered.map(\.name) == ["API", "Web App"]) + } + + @Test + @MainActor + func `status component allowlist keeps only named components and groups for zoommate`() { + let components = [ + ProviderStatusComponent( + id: "g1", + name: "Zoom Meetings", + indicator: .none, + status: "operational", + children: [ + ProviderStatusComponent( + id: "c1", + name: "Zoom Whiteboard", + indicator: .none, + status: "operational"), + ]), + ProviderStatusComponent(id: "2", name: "ZoomMate", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "3", name: "My Notes", indicator: .none, status: "operational"), + ProviderStatusComponent( + id: "g2", + name: "Zoom Workflows", + indicator: .none, + status: "operational", + children: [ + ProviderStatusComponent( + id: "c2", + name: "Zoom AIC", + indicator: .none, + status: "operational"), + ]), + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "5", name: "Zoom Rooms", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.map(\.name) == ["Zoom Meetings", "ZoomMate", "My Notes", "Zoom Workflows"]) + // Allowlisted groups keep their existing full child list; the allowlist filters at the + // top level only, it does not additionally prune group children. + #expect(filtered.first { $0.name == "Zoom Meetings" }?.children.map(\.name) == ["Zoom Whiteboard"]) + } + + @Test + @MainActor + func `status component allowlist tolerates any subset of named components being absent`() { + // Only two of the four allowlisted names are present; the other two are silently omitted + // without error, and unrelated components remain excluded as usual. + let components = [ + ProviderStatusComponent(id: "2", name: "ZoomMate", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "3", name: "My Notes", indicator: .minor, status: "degraded_performance"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.map(\.name) == ["ZoomMate", "My Notes"]) + } + + @Test + @MainActor + func `status component allowlist returns empty list when all named components are absent`() { + // Zoom's status page has been fully restructured and none of the four allowlisted names + // remain; the filter must degrade to an empty list rather than crash, so the existing + // "no components" gate (which shows only the website link) takes over. + let components = [ + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "5", name: "Zoom Rooms", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.isEmpty) + } + + @Test + @MainActor + func `status component allowlist on an empty component list stays empty`() { + let filtered = StatusItemController.filterStatusComponents([], for: .zoommate) + #expect(filtered.isEmpty) + } + @Test @MainActor func `update menu action installs prepared update instead of checking again`() throws { diff --git a/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift b/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift new file mode 100644 index 0000000000..4da01f42dd --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift @@ -0,0 +1,347 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Covers the `.auto` cookie-cache handoff: a validated browser session is persisted through +/// `CookieHeaderCache`, and later resolutions (background refreshes, the bundled CLI) run from the +/// cached header without rereading the browser. Modeled on `PerplexityCookieCacheTests`. +@Suite(.serialized) +struct ZoomMateCookieCacheTests { + private static let cachedHeader = "_zm_ssid=fake-session-value; cf_clearance=fake-clearance-value" + + /// Minimal unsigned JWT carrying only a far-future `exp` claim, so minted tokens are cacheable. + private static func makeJWT(exp: Int = 9_999_999_999) -> String { + func b64url(_ text: String) -> String { + Data(text.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(b64url("{\"alg\":\"none\"}")).\(b64url("{\"exp\":\(exp)}")).sig" + } + + private static func mintResponseStub( + nak: String, + email: String? = nil, + expectedCookieHeader: String? = nil) -> ProviderHTTPTransportStub + { + ProviderHTTPTransportStub { request in + if let expectedCookieHeader { + #expect(request.value(forHTTPHeaderField: "Cookie") == expectedCookieHeader) + } + let profile = email.map { ", \"user_profile\": {\"email\": \"\($0)\"}" } ?? "" + let body = "{\"success\": true, \"data\": {\"nak\": \"\(nak)\"\(profile)}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + } + + #if os(macOS) + @Test + func `auto mode reuses the cached cookie header without a browser read`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedHeader, + sourceLabel: "Chrome (Test)") + + let jwt = Self.makeJWT() + let stub = Self.mintResponseStub( + nak: jwt, + email: "fake.user@example.com", + expectedCookieHeader: Self.cachedHeader) + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + let context = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + + #expect(context.authorization == "Bearer \(jwt)") + #expect(context.headers["Cookie"] == Self.cachedHeader) + #expect(context.accountEmail == "fake.user@example.com") + #expect(context.cacheKey == ZoomMateBearerTokenCache.key(forCookieHeader: Self.cachedHeader)) + #expect(await stub.requests().count == 1) // the mint only — no browser import happened + } + + @Test + func `resolution without cache falls back to the browser import path`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedHeader, + sourceLabel: "Chrome (Test)") + + let stub = ProviderHTTPTransportStub { request in + Issue.record("Unexpected network request: \(request.url?.absoluteString ?? "nil")") + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + // The dead-session retry disallows the cache; under the test runner the browser cookie + // store is suppressed, so the fallback surfaces `noSession` without any network traffic. + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + allowCachedCookieHeader: false, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.noSession = error else { return false } + return true + } + // Skipping the cache must not mutate it; clearing is the strategy's explicit decision. + #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader == Self.cachedHeader) + } + + @Test + func `rejected cached session surfaces invalidCredentials and leaves the entry intact`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedHeader, + sourceLabel: "Chrome (Test)") + + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + // The fetcher never clears the cache itself — the strategy clears and retries once with a + // fresh import, so a transient mis-clear can't wipe a concurrently refreshed entry. + #expect(CookieHeaderCache.load(provider: .zoommate) != nil) + } + + @Test + func `validated browser session is persisted through the cookie cache`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let nak = Self.makeJWT() + let stub = Self.mintResponseStub(nak: nak, expectedCookieHeader: Self.cachedHeader) + + let context = try await ZoomMateUsageFetcher.requestContext( + forCookieHeader: Self.cachedHeader, + persistingValidatedHeaderAs: "Chrome (Test)", + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + let cached = try #require(CookieHeaderCache.load(provider: .zoommate)) + #expect(cached.cookieHeader == Self.cachedHeader) + #expect(cached.sourceLabel == "Chrome (Test)") + // Only the cookie header is persisted — the minted bearer stays in memory. + #expect(!cached.cookieHeader.contains(nak)) + #expect(context.authorization == "Bearer \(nak)") + } + + @Test + func `auto mode continues past a rejected Chrome profile`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let rejectedHeader = "_zm_ssid=fake-rejected-session" + let validHeader = "_zm_ssid=fake-valid-session" + let jwt = Self.makeJWT() + let sessions = [ + ZoomMateCookieImporter.SessionInfo( + cookieHeader: rejectedHeader, + sourceLabel: "Chrome Profile 1"), + ZoomMateCookieImporter.SessionInfo( + cookieHeader: validHeader, + sourceLabel: "Chrome Profile 2"), + ] + let stub = ProviderHTTPTransportStub { request in + let cookieHeader = request.value(forHTTPHeaderField: "Cookie") + if cookieHeader == rejectedHeader { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)! + return (Data("{}".utf8), response) + } + + #expect(cookieHeader == validHeader) + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = try await ZoomMateUsageFetcher.requestContext( + forCookieSessions: sessions, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + #expect(context.authorization == "Bearer \(jwt)") + #expect(context.headers["Cookie"] == validHeader) + #expect(await stub.requests().count == 2) + let cached = try #require(CookieHeaderCache.load(provider: .zoommate)) + #expect(cached.cookieHeader == validHeader) + #expect(cached.sourceLabel == "Chrome Profile 2") + } + + @Test + func `auto mode does not hide a parse failure behind another Chrome profile`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let sessions = [ + ZoomMateCookieImporter.SessionInfo( + cookieHeader: "_zm_ssid=fake-malformed-response-session", + sourceLabel: "Chrome Profile 1"), + ZoomMateCookieImporter.SessionInfo( + cookieHeader: "_zm_ssid=fake-unused-session", + sourceLabel: "Chrome Profile 2"), + ] + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data("{\"success\": true, \"data\": {}}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieSessions: sessions, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `failed mint persists nothing`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieHeader: Self.cachedHeader, + persistingValidatedHeaderAs: "Chrome (Test)", + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `already cached header is not re-persisted`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let stub = Self.mintResponseStub(nak: Self.makeJWT()) + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieHeader: Self.cachedHeader, + persistingValidatedHeaderAs: nil, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `manual capture mode neither reads nor writes the cookie cache`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedHeader, + sourceLabel: "Chrome (Test)") + + let curl = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: session=fake-manual-cookie'" + let stub = ProviderHTTPTransportStub { request in + Issue.record("Unexpected network request: \(request.url?.absoluteString ?? "nil")") + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + let context = try await fetcher.resolveRequestContext( + manualCaptureOverride: curl, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + + #expect(context.authorization == "Bearer fake-manual-token") + #expect(context.headers["Cookie"] == "session=fake-manual-cookie") + #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader == Self.cachedHeader) + } + #endif +} diff --git a/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift b/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift new file mode 100644 index 0000000000..de3a85a291 --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift @@ -0,0 +1,514 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ZoomMateCreditsHistoryFetcherTests { + // Every payload below is generated from synthetic IDs, titles, costs, and timestamps. + private static let now = Date(timeIntervalSince1970: 1_782_800_000) + private static let startTime = Self.now.addingTimeInterval(-30 * 24 * 3600) + + private static func page(records: String, total: Int) -> String { + """ + { "data": { "records": [\(records)], "total": \(total) }, "status_code": 200, "error_message": null } + """ + } + + private static func record( + id: String, + title: String, + cost: Double, + time: String, + isRunning: Bool = false, + isDeleted: Bool = false) -> String + { + """ + {"session_id": "\(id)", "title": "\(title)", "cost": \(cost), "time": "\(time)", + "is_running": \(isRunning), "is_deleted": \(isDeleted)} + """ + } + + @Test + func `decodes a single page fully within the limit`() async throws { + let body = Self.page( + records: [ + Self.record(id: "s1", title: "Task A", cost: 5, time: "2026-06-30T10:00:00Z"), + Self.record(id: "s2", title: "Task B", cost: 3, time: "2026-06-29T10:00:00Z"), + ].joined(separator: ","), + total: 2) + + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.scheme == "https") + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/history") + #expect(request.url?.query?.contains("app_id=demo_app") == true) + #expect(request.url?.query?.contains("page=0") == true) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fake-token") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://zoommate.zoom.us") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + headers: ["Origin": "https://attacker.example", "Referer": "https://attacker.example/path"]) + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 2) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `paginates across multiple pages until total is satisfied`() async throws { + let stub = ProviderHTTPTransportStub { request in + let query = request.url?.query ?? "" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + if query.contains("page=0") { + let body = Self.page( + records: (0..<50).map { + Self.record(id: "s\($0)", title: "Task \($0)", cost: 1, time: "2026-06-30T10:00:00Z") + }.joined(separator: ","), + total: 55) + return (Data(body.utf8), response) + } + #expect(query.contains("page=1")) + let body = Self.page( + records: (50..<55).map { + Self.record(id: "s\($0)", title: "Task \($0)", cost: 1, time: "2026-06-29T10:00:00Z") + }.joined(separator: ","), + total: 55) + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 55) + let requestCount = await stub.requests().count + #expect(requestCount == 2) + } + + @Test + func `stops pagination early when a page returns no records`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = Self.page(records: "", total: 1000) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.isEmpty) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `stops pagination early when a page is entirely older than startTime`() async throws { + // `total: 1000` implies many more pages exist, but every record on page 0 is already + // older than `startTime` — the defensive date-boundary stop (design.md D2) should break + // before requesting page 1, regardless of what `total`/`maxPages` would otherwise allow. + let staleTime = Self.startTime.addingTimeInterval(-24 * 3600) // 1 day before the window. + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.query?.contains("page=0") == true) + let body = Self.page( + records: (0..<50).map { + Self.record( + id: "s\($0)", + title: "Stale \($0)", + cost: 1, + time: ISO8601DateFormatter().string(from: staleTime)) + }.joined(separator: ","), + total: 1000) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 50) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `unauthorized response maps to invalidCredentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{\"detail\": \"unauthorized\"}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `other server error maps to apiError`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data("boom".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.apiError = error else { return false } + return true + } + } + + @Test + func `malformed body surfaces parseFailed`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Daily aggregation + + @Test + func `daily breakdown sums cost per calendar day and sorts ascending`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "A", + cost: 5, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "B", + cost: 3, + time: "2026-06-30T20:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s3", + title: "C", + cost: 2, + time: "2026-06-29T10:00:00Z", + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar) + + #expect(breakdown.count == 2) + #expect(breakdown[0].day == "2026-06-29") + #expect(breakdown[0].totalCreditsUsed == 2) + #expect(breakdown[1].day == "2026-06-30") + #expect(breakdown[1].totalCreditsUsed == 8) + } + + @Test + func `daily breakdown excludes deleted records`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "A", + cost: 5, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "B (deleted)", + cost: 100, + time: "2026-06-30T11:00:00Z", + isRunning: false, + isDeleted: true), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar) + + #expect(breakdown.count == 1) + #expect(breakdown[0].totalCreditsUsed == 5) + } + + @Test + func `daily breakdown includes running sessions`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Still running", + cost: 1.5, + time: "2026-06-30T10:00:00Z", + isRunning: true, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar) + + #expect(breakdown.count == 1) + #expect(breakdown[0].totalCreditsUsed == 1.5) + } + + @Test + func `daily breakdown skips records with unparseable time or negative cost`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Bad time", + cost: 5, + time: "not-a-date", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "Negative cost", + cost: -1, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s3", + title: "Missing time", + cost: 2, + time: nil, + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s4", + title: "Missing cost", + cost: nil, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar) + + #expect(breakdown.isEmpty) + } + + @Test + func `daily breakdown returns empty for no records`() { + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], updatedAt: Self.now) + #expect(snapshot.dailyBreakdown(calendar: Self.utcCalendar).isEmpty) + } + + @Test + func `daily breakdown excludes records older than the trailing 30-day window`() throws { + // Fixed `now`; one record just inside the 30-day window, one just outside it. + let fixedNow = try #require(Self.utcCalendar.date(from: DateComponents(year: 2026, month: 7, day: 4, hour: 12))) + let withinWindow = "2026-06-05T10:00:00Z" // 29 days before `now` -> included. + let outsideWindow = "2026-06-03T10:00:00Z" // 31 days before `now` -> excluded. + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Recent", + cost: 5, + time: withinWindow, + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "Stale", + cost: 100, + time: outsideWindow, + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: fixedNow) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: fixedNow) + + #expect(breakdown.count == 1) + #expect(breakdown[0].day == "2026-06-05") + #expect(breakdown[0].totalCreditsUsed == 5) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + // MARK: - Pacing verdict + + @Test + func `pacing verdict reports onTrack when usage matches elapsed cycle fraction`() throws { + // Cycle: 100,000s long; now is 50,000s in (50% elapsed); used = 50% of budget. + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .onTrack) + } + + @Test + func `pacing verdict reports behind when usage is well below elapsed cycle fraction`() throws { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 100, + remainingCredit: 900, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .behind || pace.stage == .farBehind || pace.stage == .slightlyBehind) + #expect(pace.deltaPercent < 0) + } + + @Test + func `pacing verdict reports ahead when usage is well above elapsed cycle fraction`() throws { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 900, + remainingCredit: 100, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .ahead || pace.stage == .farAhead || pace.stage == .slightlyAhead) + #expect(pace.deltaPercent > 0) + } + + @Test + func `pacing verdict is nil for unlimited plans`() { + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(Self.now.addingTimeInterval(-50000).timeIntervalSince1970 * 1000), + cycleEndDate: Int64(Self.now.addingTimeInterval(50000).timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: true) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `pacing verdict is nil when cycle dates are missing`() { + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: nil, + cycleEndDate: nil, + isQuotaAvailable: true, + isUnlimited: false) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `pacing verdict is nil when budget cap is zero`() { + let status = ZoomMateCreditStatus( + budgetCap: 0, + usedCredit: 0, + remainingCredit: 0, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(Self.now.addingTimeInterval(-50000).timeIntervalSince1970 * 1000), + cycleEndDate: Int64(Self.now.addingTimeInterval(50000).timeIntervalSince1970 * 1000), + isQuotaAvailable: false, + isUnlimited: false) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `ZoomMateCreditsHistorySnapshot pacingVerdict delegates to its attached creditStatus`() { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], creditStatus: status, updatedAt: Self.now) + + #expect(snapshot.pacingVerdict(now: Self.now)?.stage == .onTrack) + } + + @Test + func `ZoomMateCreditsHistorySnapshot pacingVerdict is nil without an attached creditStatus`() { + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], updatedAt: Self.now) + #expect(snapshot.pacingVerdict(now: Self.now) == nil) + } +} diff --git a/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift new file mode 100644 index 0000000000..07e4eedd25 --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift @@ -0,0 +1,773 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ZoomMateUsageFetcherTests { + private final class MessageRecorder: @unchecked Sendable { + private var messages: [String] = [] + private let lock = NSLock() + + func append(_ message: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.messages.append(message) + } + + func output() -> String { + self.lock.lock() + defer { self.lock.unlock() } + return self.messages.joined(separator: "\n") + } + } + + 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 static let now = Date(timeIntervalSince1970: 1_782_800_000) + + /// Fully synthetic payload matching the first-party web client's decoded response shape. + private static let sampleResponse = """ + { "data": { "credit_status": { + "budget_cap": 12345.0, "used_credit": 678.0, "remaining_credit": 11667.0, + "overage_credit": 0.0, "allow_overage": false, + "cycle_start_date": 1893456000000, "cycle_end_date": 1896134399000, + "is_quota_available": true, "is_unlimited": false } }, + "status_code": 200, "error_message": null } + """ + + @Test + func `decodes credit status from sample JSON`() throws { + let data = Data(Self.sampleResponse.utf8) + struct Envelope: Decodable { + struct DataBox: Decodable { + let creditStatus: ZoomMateCreditStatus + private enum CodingKeys: String, CodingKey { case creditStatus = "credit_status" } + } + + let data: DataBox + } + let envelope = try JSONDecoder().decode(Envelope.self, from: data) + let status = envelope.data.creditStatus + + #expect(status.budgetCap == 12345) + #expect(status.usedCredit == 678) + #expect(status.remainingCredit == 11667) + #expect(status.isUnlimited == false) + #expect(status.cycleEndDate == 1_896_134_399_000) + } + + @Test + func `maps normal credit usage to primary window`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary != nil) + #expect(abs((snapshot.primary?.usedPercent ?? 0) - 2.691_428_57) < 0.001) + #expect(snapshot.primary?.resetsAt?.timeIntervalSince1970 == Double(1_785_455_999_000) / 1000) + #expect(snapshot.primary?.resetDescription == "Credits") + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.providerID == .zoommate) + #expect(snapshot.identity?.accountEmail == nil) + } + + @Test + func `unlimited plan reports zero percent and no reset`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: true) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.primary?.resetsAt == nil) + } + + @Test + func `zero budget cap avoids divide by zero`() { + let status = ZoomMateCreditStatus( + budgetCap: 0, + usedCredit: 0, + remainingCredit: 0, + overageCredit: 0, + allowOverage: false, + cycleStartDate: nil, + cycleEndDate: nil, + isQuotaAvailable: false, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.primary?.resetsAt == nil) + } + + @Test + func `fetch sends authorization and decodes credit status`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.scheme == "https") + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/status") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fake-token") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://zoommate.zoom.us") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + headers: ["Origin": "https://attacker.example", "Referer": "https://attacker.example/path"]) + let snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: context, + now: Self.now, + transport: stub) + + #expect(snapshot.creditStatus.usedCredit == 678) + } + + @Test + func `unauthorized response is invalid credentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{\"detail\": \"Missing Authorization header\"}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `other server error is apiError`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data("boom".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.apiError = error else { return false } + return true + } + } + + @Test + func `malformed 200 body surfaces parseFailed`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `manual curl capture extracts authorization and cookie`() throws { + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \\ + -H 'authorization: Bearer fake-manual-token' \\ + -H 'cookie: session=fake-cookie-value' \\ + -H 'origin: https://zoommate.zoom.us' \\ + -H 'referer: https://zoommate.zoom.us/' + """ + + let context = try #require(ZoomMateUsageFetcher.requestContext(from: curl)) + #expect(context.authorization == "Bearer fake-manual-token") + #expect(context.headers["Cookie"] == "session=fake-cookie-value") + #expect(context.headers["Origin"] == nil) + #expect(context.headers["Referer"] == nil) + } + + @Test + func `manual curl capture rejects nonofficial and malformed targets`() { + let captures = [ + "curl 'http://ai.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://marketing.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://zoom.us.attacker.com/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://example.com/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/history' -H 'authorization: Bearer fake'", + "curl 'https://ai.zoom.us:444/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl --location 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake'", + ] + + for capture in captures { + #expect(ZoomMateUsageFetcher.requestContext(from: capture) == nil) + } + } + + @Test + func `manual curl capture accepts either interchangeable first-party host`() throws { + let capture = "curl 'https://zoommate.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token'" + + let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) + #expect(context.authorization == "Bearer fake-manual-token") + } + + @Test + func `credits status fails over to the alternate host on a non-auth failure`() async throws { + let stub = ProviderHTTPTransportStub { request in + if request.url?.host == "ai.zoom.us" { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 503, + httpVersion: nil, + headerFields: nil)! + return (Data(), response) + } + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/status") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: context, + now: Self.now, + transport: stub) + + #expect(snapshot.creditStatus.usedCredit == 678) + #expect(await stub.requests().count == 2) + } + + @Test + func `auth rejection does not fail over to the alternate host`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + } + + @Test + func `parse failure does not fail over to the alternate host`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + } + + @Test + func `mint fails over to the alternate host on a non-auth failure`() async throws { + let stub = ProviderHTTPTransportStub { request in + if request.url?.host == "ai.zoom.us" { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 500, + httpVersion: nil, + headerFields: nil)! + return (Data(), response) + } + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/login") + let body = "{\"success\": true, \"data\": {\"nak\": \"fake-minted-jwt\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeader: "session=fake-cookie-value", + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(await stub.requests().count == 2) + } + + @Test + func `host failover preserves cancellation without trying the alternate host`() async { + var attemptedHosts: [String] = [] + + do { + let _: String = try await ZoomMateUsageFetcher.withAPIHostFailover { host in + attemptedHosts.append(host) + throw CancellationError() + } + Issue.record("Expected cancellation") + } catch { + #expect(error is CancellationError) + } + + #expect(attemptedHosts == ["ai.zoom.us"]) + } + + @Test + func `curl capture without authorization header yields nil context`() { + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \\ + -H 'cookie: session=fake-cookie-value' + """ + + #expect(ZoomMateUsageFetcher.requestContext(from: curl) == nil) + } + + @Test + func `manual strategy remains available so malformed captures surface an honest error`() async { + let curl = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token'" + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .manual, + manualCookieHeader: curl)) + + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) + + let emptySettings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .manual, + manualCookieHeader: nil)) + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: emptySettings))) + } + + @Test + func `manual mode with an empty or malformed capture returns noCapture`() async { + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + for capture in ["", "curl 'https://example.com' -H 'authorization: Bearer fake'"] { + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: capture, + timeout: 1, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.noCapture = error else { return false } + return true + } + } + } + + @Test + func `auto strategy is available on macOS regardless of a stored manual capture`() async { + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + + #if os(macOS) + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) + #else + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)) == false) + #endif + } + + @Test + func `strategy is unavailable when cookie source is off`() async { + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)) == false) + } + + @Test + func `mintBearerToken sends cookie and decodes nak from login bootstrap response`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/login") + #expect(request.url?.query?.contains("continue=") == true) + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=fake-cookie-value") + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt"}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeader: "session=fake-cookie-value", + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `mintBearerToken extracts email from user_profile when present`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt", "user_profile": { + "user_id": "fake-user-id", "email": "fake.user@example.com", "display_name": "Fake User" + }}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeader: "session=fake-cookie-value", + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == "fake.user@example.com") + } + + @Test + func `mintBearerToken tolerates missing user_profile without throwing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt"}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeader: "session=fake-cookie-value", + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `mintBearerToken tolerates user_profile with missing email without throwing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt", "user_profile": { + "user_id": "fake-user-id", "display_name": "Fake User" + }}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeader: "session=fake-cookie-value", + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `toUsageSnapshot populates identity accountEmail and loginMethod when email is known`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now) + .toUsageSnapshot(accountEmail: "fake.user@example.com") + + #expect(snapshot.identity?.accountEmail == "fake.user@example.com") + #expect(snapshot.identity?.loginMethod == "Cookie") + } + + @Test + func `mintBearerToken maps unauthorized to invalidCredentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.mintBearerToken(cookieHeader: "session=expired", transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `mintBearerToken surfaces parseFailed when nak is missing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"success\": true, \"data\": {}}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.mintBearerToken(cookieHeader: "session=fake", transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `descriptor dashboard URL points to the credit usage pane`() { + #expect( + ZoomMateProviderDescriptor.descriptor.metadata.dashboardURL == + "https://zoommate.zoom.us/#/?settings=credit-usage") + } + + #if os(macOS) + @Test + func `descriptor limits automatic cookie import to Chrome`() throws { + let order = try #require(ZoomMateProviderDescriptor.descriptor.metadata.browserCookieOrder) + #expect(order == [.chrome]) + } + #endif + + @Test + func `credential errors describe distinct recovery actions`() { + #expect(ZoomMateUsageError.noCapture.localizedDescription.contains("ai.zoom.us")) + #expect(ZoomMateUsageError.noSession.localizedDescription.contains("Chrome")) + #expect(ZoomMateUsageError.invalidCredentials.localizedDescription.contains("rejected")) + } + + @Test + func `verbose logs omit captured cookies and bearer tokens`() async throws { + let cookieMarker = "COOKIE_SECRET_MARKER" + let tokenMarker = "TOKEN_SECRET_MARKER" + let nakMarker = "NAK_SECRET_MARKER" + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \ + -H 'authorization: Bearer \(tokenMarker)' \ + -H 'cookie: session=\(cookieMarker)' + """ + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + let messages = MessageRecorder() + + _ = try await fetcher.fetch( + manualCaptureOverride: curl, + logger: { messages.append($0) }, + transport: stub) + + let output = messages.output() + #expect(!output.contains(cookieMarker)) + #expect(!output.contains(tokenMarker)) + #expect(output.contains("Forwarding captured headers: Cookie")) + + let mintStub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(nakMarker)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeader: "session=\(cookieMarker)", + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: mintStub, + logger: { messages.append($0) }) + + let mintOutput = messages.output() + #expect(!mintOutput.contains(cookieMarker)) + #expect(!mintOutput.contains(nakMarker)) + } + + // MARK: - Bearer token expiry + in-memory cache + + /// Minimal unsigned JWT carrying only an `exp` claim, for cache-expiry tests. + private static func makeJWT(exp: Int) -> String { + func b64url(_ text: String) -> String { + Data(text.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(b64url("{\"alg\":\"none\"}")).\(b64url("{\"exp\":\(exp)}")).sig" + } + + @Test + func `expiry decodes exp claim from a bearer JWT and ignores non-JWT tokens`() { + let jwt = Self.makeJWT(exp: 1_782_800_000) + #expect(ZoomMateUsageFetcher.expiry(fromJWT: jwt) == Date(timeIntervalSince1970: 1_782_800_000)) + // Tolerates an already-prefixed "Bearer " value. + #expect(ZoomMateUsageFetcher.expiry(fromJWT: "Bearer \(jwt)") == Date(timeIntervalSince1970: 1_782_800_000)) + // Opaque (non-JWT) tokens are undatable → nil (caller must not cache them). + #expect(ZoomMateUsageFetcher.expiry(fromJWT: "opaque-token") == nil) + } + + @Test + func `cachedOrMintedToken reuses an in-date token instead of re-minting`() async throws { + let jwt = Self.makeJWT(exp: 9_999_999_999) + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + + let first = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeader: "session=abc", + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + let second = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeader: "session=abc", + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(first.bearerToken == jwt) + #expect(second.bearerToken == jwt) + #expect(await stub.requests().count == 1) // minted once, reused once + } + + @Test + func `cachedOrMintedToken re-mints a token without a decodable expiry`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"opaque-not-a-jwt\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeader: "session=abc", + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeader: "session=abc", + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(await stub.requests().count == 2) // undatable token is never cached + } + + @Test + func `cache serves an in-date entry but withholds one inside the refresh-skew window`() async { + let cache = ZoomMateBearerTokenCache() + let key = ZoomMateBearerTokenCache.key(forCookieHeader: "session=abc") + let now = Date(timeIntervalSince1970: 1_000_000_000) + // Expiry comfortably beyond the 60s skew → served. + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: "t", + accountEmail: nil, + expiry: now.addingTimeInterval(600)), + forKey: key) + #expect(await cache.validEntry(forKey: key, now: now) != nil) + + // Re-store with an expiry only 30s out (inside the 60s skew) → withheld and evicted. + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: "t", + accountEmail: nil, + expiry: now.addingTimeInterval(30)), + forKey: key) + #expect(await cache.validEntry(forKey: key, now: now) == nil) + // Eviction is durable: a later lookup still misses. + #expect(await cache.validEntry(forKey: key, now: now) == nil) + } + + @Test + func `invalidate evicts a cached token so the next call re-mints`() async throws { + let jwt = Self.makeJWT(exp: 9_999_999_999) + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + let key = ZoomMateBearerTokenCache.key(forCookieHeader: "session=abc") + + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeader: "session=abc", + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + await cache.invalidate(forKey: key) + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeader: "session=abc", + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(await stub.requests().count == 2) + } + + #if os(macOS) + @Test + func `cookie scope filter keeps session hosts and parent domain but drops unrelated subdomains`() { + // Kept: exact session hosts + parent-scoped SSO cookies a browser sends to ai.zoom.us. + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: "ai.zoom.us")) + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: "zoommate.zoom.us")) + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: ".zoom.us")) + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: "zoom.us")) + // Dropped: cookies host-scoped to unrelated *.zoom.us siblings, and non-Zoom lookalikes. + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "marketing.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "us05web.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "zoom.us.attacker.com")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "")) + } + #endif + + private static func makeContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index e743f65daa..17eea611a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -269,7 +269,7 @@ z.ai team accounts also use `usageScope`, `organizationId`, and `workspaceID`; s ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/index.html b/docs/index.html index 99b0ab5bcd..429c914c71 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ CodexBar — every AI coding limit, in your menu bar @@ -37,7 +37,7 @@ @@ -293,7 +293,7 @@

- 63 providers,{mobileBreak}one menu bar + 64 providers,{mobileBreak}one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus. diff --git a/docs/llms.txt b/docs/llms.txt index bc9837997a..0a6467a4ff 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,9 +1,9 @@ # CodexBar -A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 63 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Canonical documentation: -- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 63 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/steipete/CodexBar diff --git a/docs/providers.md b/docs/providers.md index 2be0fdeb74..a83f84eba3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 63 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 64 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -59,6 +59,7 @@ scan fails, while provider/account configuration changes replace obsolete result | JetBrains AI | Local XML quota file (`local`). | | Amp | Local `amp usage` CLI, access-token API, then browser-cookie legacy fallback (`cli`, `api`, `web`). | | T3 Chat | Web tRPC customer-data endpoint via browser cookies (`web`). | +| ZoomMate | Chrome cookie auto-import + cookie-to-token minting, or manual cURL capture, for the credits/status API (`web`). | | Warp | API token (config/env) → GraphQL request limits (`api`). | | ElevenLabs | API key from config/env → subscription usage API (`api`). | | Windsurf | Web session bundle from browser localStorage (`web`) → local SQLite cache (`local`). | @@ -307,6 +308,22 @@ scan fails, while provider/account configuration changes replace obsolete result - Status: none yet. - Details: `docs/t3chat.md`. +## ZoomMate +- Credits API endpoint (`https://ai.zoom.us/ai-computer/api/v1/credits/status`) authenticated via a + bearer token. +- Auto-imports ZoomMate/Zoom session cookies from Chrome, validates and stores the narrowed cookie + header in CodexBar's Keychain cache, and exchanges it for a short-lived bearer token through + ZoomMate's own cookie-to-token bootstrap endpoint. The bearer remains in memory only and is reused + until it nears expiry. Manual cURL capture is available as an explicit alternative. +- Shows a single "Credits" window: used/remaining credits against a budget cap, resetting at the + billing cycle end, plus an inline Today/30-day credits history chart and pacing verdict (on + track/behind/ahead of budget). +- Status: `https://www.zoomstatus.com/` (Statuspage.io); the component drill-down is filtered to a + named allowlist ("Zoom Meetings", "ZoomMate", "My Notes", "Zoom Workflows", "Zoom Developer + Platform", "Zoom Support", "Zoom Website") rather than showing Zoom's full ~300-component status + page. +- Details: `docs/zoommate.md`. + ## Ollama - Web settings page (`https://ollama.com/settings`) via browser cookies. - Parses Cloud Usage plan badge, session/weekly usage, and reset timestamps. diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index 41733b78ef..b43f18c91e 100644 --- a/docs/site-locales.mjs +++ b/docs/site-locales.mjs @@ -98,8 +98,8 @@ export const localeCatalog = [ export const localeMessages = { "en": { "meta.title": "CodexBar — every AI coding limit in your menu bar", - "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 63 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", - "meta.ogDescription": "Track usage windows, credits, and resets across 63 AI coding providers from your macOS menu bar.", + "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", + "meta.ogDescription": "Track usage windows, credits, and resets across 64 AI coding providers from your macOS menu bar.", "nav.primary": "Primary", "nav.language": "Language", "nav.docs": "docs", @@ -112,7 +112,7 @@ export const localeMessages = { "hero.description": "CodexBar tracks usage windows, credit balances, and reset countdowns across the providers you actually pay for — one status item each, or merge them into one.", "hero.download": "Download for macOS", "hero.fineprint": "Free & open source · macOS 14+ · Universal via GitHub Releases and Homebrew", - "providers.title": "63 providers,{mobileBreak}one menu bar", + "providers.title": "64 providers,{mobileBreak}one menu bar", "providers.description": "Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.", "providers.yourProvider": "Your provider", "providers.authoringGuide": "Authoring guide", @@ -238,8 +238,8 @@ export const localeMessages = { }, "zh-CN": { "meta.title": "CodexBar — 菜单栏中的每个 AI 编码限制", - "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 63 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", - "meta.ogDescription": "从您的 macOS 菜单栏跟踪 63 个 AI 编码提供商的使用窗口、积分和重置。", + "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 64 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", + "meta.ogDescription": "从您的 macOS 菜单栏跟踪 64 个 AI 编码提供商的使用窗口、积分和重置。", "nav.primary": "基本的", "nav.language": "语言", "nav.docs": "文档", @@ -252,7 +252,7 @@ export const localeMessages = { "hero.description": "CodexBar 跟踪您实际付费的提供商的使用窗口、信用余额和重置倒计时 - 每个状态项一项,或将它们合并为一项。", "hero.download": "下载macOS", "hero.fineprint": "免费开源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "63 个提供商,{mobileBreak}一个菜单栏", + "providers.title": "64 个提供商,{mobileBreak}一个菜单栏", "providers.description": "受欢迎的提供商成为状态项目,具有自己的使用窗口、重置倒计时、图表和提供商菜单。", "providers.yourProvider": "您的提供商", "providers.authoringGuide": "创作指南", @@ -378,8 +378,8 @@ export const localeMessages = { }, "zh-TW": { "meta.title": "CodexBar — 功能表列中的每個 AI 編碼限制", - "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 63 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", - "meta.ogDescription": "從您的 macOS 功能表列追蹤 63 個 AI 編碼提供者的使用視窗、積分和重設。", + "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 64 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", + "meta.ogDescription": "從您的 macOS 功能表列追蹤 64 個 AI 編碼提供者的使用視窗、積分和重設。", "nav.primary": "基本的", "nav.language": "語言", "nav.docs": "文件", @@ -392,7 +392,7 @@ export const localeMessages = { "hero.description": "CodexBar 追蹤您實際付費的提供者的使用視窗、信用餘額和重設倒數計時 - 每個狀態項一項,或將它們合併為一項。", "hero.download": "下載macOS", "hero.fineprint": "免費開源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "63 個提供者,{mobileBreak}一個選單列", + "providers.title": "64 個提供者,{mobileBreak}一個選單列", "providers.description": "受歡迎的提供者成為狀態項目,具有自己的使用視窗、重置倒數計時、圖表和提供者選單。", "providers.yourProvider": "您的提供者", "providers.authoringGuide": "創作指南", @@ -518,8 +518,8 @@ export const localeMessages = { }, "ja-JP": { "meta.title": "CodexBar — メニュー バーのすべての AI コーディング制限", - "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、63 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", - "meta.ogDescription": "macOS メニュー バーから、63 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", + "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、64 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", + "meta.ogDescription": "macOS メニュー バーから、64 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", "nav.primary": "主要な", "nav.language": "言語", "nav.docs": "ドキュメント", @@ -532,7 +532,7 @@ export const localeMessages = { "hero.description": "CodexBar は、実際に料金を支払っているプロバイダー全体の使用期間、クレジット残高、リセット カウントダウンを追跡します。ステータス項目を 1 つずつ、または 1 つに統合します。", "hero.download": "macOS のダウンロード", "hero.fineprint": "無料・オープンソース · macOS 14+ · GitHub Releases と Homebrew でユニバーサル版を提供", - "providers.title": "63 プロバイダー、{mobileBreak}1つのメニューバー", + "providers.title": "64 プロバイダー、{mobileBreak}1つのメニューバー", "providers.description": "人気のあるプロバイダーは、独自の使用期間、リセット カウントダウン、グラフ、プロバイダー メニューを備えたステータス アイテムになります。", "providers.yourProvider": "あなたのプロバイダー", "providers.authoringGuide": "オーサリングガイド", @@ -658,8 +658,8 @@ export const localeMessages = { }, "es": { "meta.title": "CodexBar: cada límite de codificación de IA en tu barra de menú", - "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 63 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", - "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 63 proveedores de codificación de IA desde su barra de menú macOS.", + "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 64 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", + "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 64 proveedores de codificación de IA desde su barra de menú macOS.", "nav.primary": "Primario", "nav.language": "Idioma", "nav.docs": "Documentos", @@ -672,7 +672,7 @@ export const localeMessages = { "hero.description": "CodexBar realiza un seguimiento de los períodos de uso, los saldos de crédito y restablece las cuentas regresivas de los proveedores por los que realmente paga: un elemento de estado para cada uno o los fusiona en uno solo.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratis y de código abierto · macOS 14+ · Universal mediante GitHub Releases y Homebrew", - "providers.title": "63 proveedores,{mobileBreak}una barra de menús", + "providers.title": "64 proveedores,{mobileBreak}una barra de menús", "providers.description": "Los proveedores populares se convierten en elementos de estado con sus propias ventanas de uso, restablecen cuentas regresivas, gráficos y menús de proveedores.", "providers.yourProvider": "Tu proveedor", "providers.authoringGuide": "guía de autoría", @@ -798,8 +798,8 @@ export const localeMessages = { }, "pt-BR": { "meta.title": "CodexBar — todos os limites de codificação de IA na sua barra de menu", - "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 63 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", - "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 63 provedores de codificação de IA na barra de menu macOS.", + "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 64 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", + "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 64 provedores de codificação de IA na barra de menu macOS.", "nav.primary": "Primário", "nav.language": "Linguagem", "nav.docs": "Documentos", @@ -812,7 +812,7 @@ export const localeMessages = { "hero.description": "CodexBar rastreia janelas de uso, saldos de crédito e reinicia contagens regressivas nos provedores pelos quais você realmente paga - um item de status cada, ou mescla-os em um.", "hero.download": "Baixar para macOS", "hero.fineprint": "Gratuito e de código aberto · macOS 14+ · Universal via GitHub Releases e Homebrew", - "providers.title": "63 provedores,{mobileBreak}uma barra de menu", + "providers.title": "64 provedores,{mobileBreak}uma barra de menu", "providers.description": "Provedores populares tornam-se itens de status com suas próprias janelas de uso, reiniciam contagens regressivas, gráficos e menus de provedores.", "providers.yourProvider": "Seu provedor", "providers.authoringGuide": "Guia de autoria", @@ -938,8 +938,8 @@ export const localeMessages = { }, "ko": { "meta.title": "CodexBar — 메뉴 표시줄의 모든 AI 코딩 제한", - "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 63개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", - "meta.ogDescription": "macOS 메뉴 표시줄에서 63개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", + "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 64개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", + "meta.ogDescription": "macOS 메뉴 표시줄에서 64개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", "nav.primary": "주요한", "nav.language": "언어", "nav.docs": "문서", @@ -952,7 +952,7 @@ export const localeMessages = { "hero.description": "CodexBar는 귀하가 실제로 비용을 지불한 제공업체 전반에 걸쳐 사용 기간, 크레딧 잔액 및 재설정 카운트다운을 추적합니다. 즉, 각각 하나의 상태 항목 또는 하나로 병합됩니다.", "hero.download": "macOS 동안 다운로드", "hero.fineprint": "무료 오픈 소스 · macOS 14+ · GitHub Releases와 Homebrew에서 유니버설 제공", - "providers.title": "63개 제공자,{mobileBreak}하나의 메뉴 막대", + "providers.title": "64개 제공자,{mobileBreak}하나의 메뉴 막대", "providers.description": "인기 있는 제공업체는 자체 사용 창, 재설정 카운트다운, 차트 및 제공업체 메뉴를 갖춘 상태 항목이 됩니다.", "providers.yourProvider": "귀하의 제공자", "providers.authoringGuide": "저작 가이드", @@ -1078,8 +1078,8 @@ export const localeMessages = { }, "de": { "meta.title": "CodexBar – alle Limits Ihrer KI-Coding-Tools in der Menüleiste", - "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 63 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", - "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 63 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", + "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 64 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", + "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 64 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", "nav.primary": "Primär", "nav.language": "Sprache", "nav.docs": "Dokumente", @@ -1092,7 +1092,7 @@ export const localeMessages = { "hero.description": "CodexBar verfolgt Nutzungsfenster, Guthaben und Reset-Countdowns bei den Anbietern, für die Sie tatsächlich bezahlen – jeweils ein Statuselement oder sie werden zu einem zusammengeführt.", "hero.download": "Herunterladen für macOS", "hero.fineprint": "Kostenlos und Open Source · macOS 14+ · Universal über GitHub Releases und Homebrew", - "providers.title": "63 Provider,{mobileBreak}eine Menüleiste", + "providers.title": "64 Provider,{mobileBreak}eine Menüleiste", "providers.description": "Beliebte Anbieter werden zu Statuselementen mit eigenen Nutzungsfenstern, Reset-Countdowns, Diagrammen und Anbietermenüs.", "providers.yourProvider": "Ihr Anbieter", "providers.authoringGuide": "Autorenleitfaden", @@ -1218,8 +1218,8 @@ export const localeMessages = { }, "fr": { "meta.title": "CodexBar — chaque limite de codage IA dans votre barre de menus", - "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 63 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", - "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 63 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", + "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 64 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", + "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 64 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", "nav.primary": "Primaire", "nav.language": "Langue", "nav.docs": "Documents", @@ -1232,7 +1232,7 @@ export const localeMessages = { "hero.description": "CodexBar suit les fenêtres d'utilisation, les soldes créditeurs et réinitialise les comptes à rebours pour les fournisseurs pour lesquels vous payez réellement - un élément de statut chacun, ou les fusionne en un seul.", "hero.download": "Télécharger pour macOS", "hero.fineprint": "Gratuit et open source · macOS 14+ · Universel via GitHub Releases et Homebrew", - "providers.title": "63 fournisseurs,{mobileBreak}une barre des menus", + "providers.title": "64 fournisseurs,{mobileBreak}une barre des menus", "providers.description": "Les fournisseurs populaires deviennent des éléments de statut avec leurs propres fenêtres d'utilisation, réinitialisent les comptes à rebours, les graphiques et les menus des fournisseurs.", "providers.yourProvider": "Votre fournisseur", "providers.authoringGuide": "Guide de création", @@ -1358,8 +1358,8 @@ export const localeMessages = { }, "ar": { "meta.title": "CodexBar — كل حد لترميز الذكاء الاصطناعي في شريط القائمة", - "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 63 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", - "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 63 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", + "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 64 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", + "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 64 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", "nav.primary": "أساسي", "nav.language": "لغة", "nav.docs": "المستندات", @@ -1372,7 +1372,7 @@ export const localeMessages = { "hero.description": "يتتبع CodexBar فترات الاستخدام والأرصدة الائتمانية وإعادة تعيين العد التنازلي عبر مقدمي الخدمة الذين تدفع مقابلهم فعليًا — عنصر حالة واحد لكل منهم، أو دمجهم في عنصر واحد.", "hero.download": "التنزيل لمدة macOS", "hero.fineprint": "مجاني ومفتوح المصدر · macOS 14+ · إصدار شامل عبر GitHub Releases وHomebrew", - "providers.title": "63 مزودًا،{mobileBreak}شريط قوائم واحد", + "providers.title": "64 مزودًا،{mobileBreak}شريط قوائم واحد", "providers.description": "يصبح الموفرون المشهورون عناصر حالة مع نوافذ الاستخدام الخاصة بهم، وعمليات إعادة تعيين العد التنازلي، والمخططات، وقوائم الموفر.", "providers.yourProvider": "المزود الخاص بك", "providers.authoringGuide": "دليل التأليف", @@ -1498,8 +1498,8 @@ export const localeMessages = { }, "it": { "meta.title": "CodexBar: ogni limite di codifica AI nella barra dei menu", - "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 63 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", - "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 63 fornitori di codifica AI dalla barra dei menu macOS.", + "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 64 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", + "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 64 fornitori di codifica AI dalla barra dei menu macOS.", "nav.primary": "Primario", "nav.language": "Lingua", "nav.docs": "Documenti", @@ -1512,7 +1512,7 @@ export const localeMessages = { "hero.description": "CodexBar tiene traccia delle finestre di utilizzo, dei saldi del credito e reimposta i conti alla rovescia tra i fornitori per cui paghi effettivamente: un elemento di stato ciascuno o uniscili in uno solo.", "hero.download": "Scarica per macOS", "hero.fineprint": "Gratuito e open source · macOS 14+ · Universale tramite GitHub Releases e Homebrew", - "providers.title": "63 provider,{mobileBreak}una barra dei menu", + "providers.title": "64 provider,{mobileBreak}una barra dei menu", "providers.description": "I fornitori più popolari diventano elementi di stato con le proprie finestre di utilizzo, reimpostano i conti alla rovescia, i grafici e i menu dei fornitori.", "providers.yourProvider": "Il tuo fornitore", "providers.authoringGuide": "Guida all'autore", @@ -1638,8 +1638,8 @@ export const localeMessages = { }, "vi": { "meta.title": "CodexBar — mọi giới hạn mã hóa AI trong thanh menu của bạn", - "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 63 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", - "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 63 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", + "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 64 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", + "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 64 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", "nav.primary": "Sơ đẳng", "nav.language": "Ngôn ngữ", "nav.docs": "Tài liệu", @@ -1652,7 +1652,7 @@ export const localeMessages = { "hero.description": "CodexBar theo dõi khoảng thời gian sử dụng, số dư tín dụng và đếm ngược đặt lại trên các nhà cung cấp mà bạn thực sự thanh toán — mỗi mục một trạng thái hoặc hợp nhất chúng thành một.", "hero.download": "Tải xuống cho macOS", "hero.fineprint": "Miễn phí và nguồn mở · macOS 14+ · Bản universal qua GitHub Releases và Homebrew", - "providers.title": "63 nhà cung cấp,{mobileBreak}một thanh menu", + "providers.title": "64 nhà cung cấp,{mobileBreak}một thanh menu", "providers.description": "Các nhà cung cấp phổ biến trở thành các mục trạng thái với cửa sổ sử dụng của riêng họ, đặt lại bộ đếm ngược, biểu đồ và menu nhà cung cấp.", "providers.yourProvider": "Nhà cung cấp của bạn", "providers.authoringGuide": "Hướng dẫn soạn thảo", @@ -1778,8 +1778,8 @@ export const localeMessages = { }, "nl": { "meta.title": "CodexBar — elke AI-coderingslimiet in uw menubalk", - "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 63 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", - "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 63 leveranciers van AI-codering via uw macOS-menubalk.", + "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 64 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", + "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 64 leveranciers van AI-codering via uw macOS-menubalk.", "nav.primary": "Primair", "nav.language": "Taal", "nav.docs": "Documenten", @@ -1792,7 +1792,7 @@ export const localeMessages = { "hero.description": "CodexBar houdt gebruiksperioden, tegoeden en reset-countdowns bij voor de providers waarvoor u daadwerkelijk betaalt: elk één statusitem, of u kunt ze samenvoegen tot één statusitem.", "hero.download": "Downloaden voor macOS", "hero.fineprint": "Gratis en open source · macOS 14+ · Universeel via GitHub Releases en Homebrew", - "providers.title": "63 providers,{mobileBreak}één menubalk", + "providers.title": "64 providers,{mobileBreak}één menubalk", "providers.description": "Populaire providers worden statusitems met hun eigen gebruiksvensters, resetcountdowns, grafieken en providermenu's.", "providers.yourProvider": "Uw aanbieder", "providers.authoringGuide": "Handleiding voor het schrijven", @@ -1918,8 +1918,8 @@ export const localeMessages = { }, "tr": { "meta.title": "CodexBar — menü çubuğunuzdaki tüm AI kodlama limitleri", - "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 63 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", - "meta.ogDescription": "macOS menü çubuğunu kullanarak 63 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", + "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 64 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", + "meta.ogDescription": "macOS menü çubuğunu kullanarak 64 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", "nav.primary": "Öncelik", "nav.language": "Dil", "nav.docs": "Dokümanlar", @@ -1932,7 +1932,7 @@ export const localeMessages = { "hero.description": "CodexBar, gerçekte ödeme yaptığınız sağlayıcılar genelinde kullanım pencerelerini, kredi bakiyelerini ve geri sayımları sıfırlar (her biri bir durum öğesi olacak şekilde) izler veya bunları tek bir öğede birleştirir.", "hero.download": "macOS için indirin", "hero.fineprint": "Ücretsiz ve açık kaynak · macOS 14+ · GitHub Releases ve Homebrew ile evrensel sürüm", - "providers.title": "63 sağlayıcı,{mobileBreak}bir menü çubuğu", + "providers.title": "64 sağlayıcı,{mobileBreak}bir menü çubuğu", "providers.description": "Popüler sağlayıcılar, kendi kullanım pencereleri, sıfırlama geri sayımları, çizelgeleri ve sağlayıcı menüleriyle durum öğeleri haline gelir.", "providers.yourProvider": "Sağlayıcınız", "providers.authoringGuide": "Yazma kılavuzu", @@ -2058,8 +2058,8 @@ export const localeMessages = { }, "uk": { "meta.title": "CodexBar — кожне обмеження кодування AI у вашій панелі меню", - "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 63 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", - "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 63 постачальників кодування ШІ за допомогою панелі меню macOS.", + "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 64 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", + "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 64 постачальників кодування ШІ за допомогою панелі меню macOS.", "nav.primary": "Первинний", "nav.language": "Мова", "nav.docs": "документи", @@ -2072,7 +2072,7 @@ export const localeMessages = { "hero.description": "CodexBar відстежує вікна використання, кредитні баланси та скидає зворотний відлік для постачальників, за яких ви фактично платите, — по одному статусу для кожного або об’єднує їх в один.", "hero.download": "Завантажити для macOS", "hero.fineprint": "Безкоштовний і відкритий код · macOS 14+ · Універсальна версія через GitHub Releases і Homebrew", - "providers.title": "63 провайдерів,{mobileBreak}одна панель меню", + "providers.title": "64 провайдерів,{mobileBreak}одна панель меню", "providers.description": "Популярні постачальники стають елементами статусу з власними вікнами використання, скиданням зворотного відліку, діаграмами та меню постачальників.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Авторський посібник", @@ -2198,8 +2198,8 @@ export const localeMessages = { }, "ru": { "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню", - "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 63 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", - "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 63 AI-провайдеров для кодинга прямо из строки меню macOS.", + "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 64 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", + "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 64 AI-провайдеров для кодинга прямо из строки меню macOS.", "nav.primary": "Основная навигация", "nav.language": "Язык", "nav.docs": "Документация", @@ -2212,7 +2212,7 @@ export const localeMessages = { "hero.description": "CodexBar отслеживает окна использования, балансы кредитов и обратный отсчет до сброса у провайдеров, за которых вы действительно платите, — по одному элементу статуса на каждого или все вместе в одном.", "hero.download": "Скачать для macOS", "hero.fineprint": "Бесплатно и с открытым исходным кодом · macOS 14+ · универсальная сборка через GitHub Releases и Homebrew", - "providers.title": "63 провайдеров,{mobileBreak}одна строка меню", + "providers.title": "64 провайдеров,{mobileBreak}одна строка меню", "providers.description": "Популярные провайдеры становятся элементами статуса со своими окнами использования, обратным отсчетом до сброса, графиками и меню провайдера.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Руководство по добавлению", @@ -2338,8 +2338,8 @@ export const localeMessages = { }, "id": { "meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda", - "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 63 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", - "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 63 penyedia pengkodean AI dari bilah menu macOS Anda.", + "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 64 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", + "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 64 penyedia pengkodean AI dari bilah menu macOS Anda.", "nav.primary": "Utama", "nav.language": "Bahasa", "nav.docs": "dokumen", @@ -2352,7 +2352,7 @@ export const localeMessages = { "hero.description": "CodexBar melacak jangka waktu penggunaan, saldo kredit, dan hitungan mundur penyetelan ulang di seluruh penyedia yang sebenarnya Anda bayar — masing-masing satu item status, atau gabungkan menjadi satu.", "hero.download": "Unduh untuk macOS", "hero.fineprint": "Gratis dan sumber terbuka · macOS 14+ · Universal melalui GitHub Releases dan Homebrew", - "providers.title": "63 penyedia,{mobileBreak}satu bilah menu", + "providers.title": "64 penyedia,{mobileBreak}satu bilah menu", "providers.description": "Penyedia populer menjadi item status dengan jendela penggunaannya sendiri, hitung mundur pengaturan ulang, bagan, dan menu penyedia.", "providers.yourProvider": "Penyedia Anda", "providers.authoringGuide": "Panduan penulisan", @@ -2478,8 +2478,8 @@ export const localeMessages = { }, "pl": { "meta.title": "CodexBar — każdy limit kodowania AI na pasku menu", - "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 63 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", - "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 63 dostawców kodowania AI za pomocą paska menu macOS.", + "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 64 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", + "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 64 dostawców kodowania AI za pomocą paska menu macOS.", "nav.primary": "Podstawowy", "nav.language": "Język", "nav.docs": "Dokumenty", @@ -2492,7 +2492,7 @@ export const localeMessages = { "hero.description": "CodexBar śledzi okna użytkowania, salda kredytów i resetuje odliczanie u dostawców, za których faktycznie płacisz — po jednym statusie dla każdego lub połącz je w jeden.", "hero.download": "Pobierz dla macOS", "hero.fineprint": "Darmowe i otwarte oprogramowanie · macOS 14+ · Wersja uniwersalna przez GitHub Releases i Homebrew", - "providers.title": "63 dostawców,{mobileBreak}jeden pasek menu", + "providers.title": "64 dostawców,{mobileBreak}jeden pasek menu", "providers.description": "Popularni dostawcy stają się elementami statusu z własnymi oknami użytkowania, resetowaniem odliczania, wykresami i menu dostawców.", "providers.yourProvider": "Twój dostawca", "providers.authoringGuide": "Przewodnik autorski", @@ -2618,8 +2618,8 @@ export const localeMessages = { }, "fa": { "meta.title": "CodexBar - هر محدودیت کدنویسی هوش مصنوعی در نوار منو شما", - "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 63 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", - "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 63 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", + "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 64 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", + "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 64 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", "nav.primary": "اولیه", "nav.language": "زبان", "nav.docs": "اسناد", @@ -2632,7 +2632,7 @@ export const localeMessages = { "hero.description": "CodexBar پنجره‌های استفاده، مانده اعتبار و بازنشانی شمارش معکوس را در سراسر ارائه‌دهندگانی که واقعاً برایشان پول پرداخت می‌کنید ردیابی می‌کند - هر کدام یک مورد وضعیت، یا آنها را در یکی ادغام کنید.", "hero.download": "دانلود برای macOS", "hero.fineprint": "رایگان و متن‌باز · macOS 14+ · نسخهٔ یونیورسال از GitHub Releases و Homebrew", - "providers.title": "63 ارائه دهنده،{mobileBreak}یک نوار منو", + "providers.title": "64 ارائه دهنده،{mobileBreak}یک نوار منو", "providers.description": "ارائه‌دهندگان محبوب با پنجره‌های استفاده خاص خود، شمارش معکوس، نمودارها و منوهای ارائه‌دهنده را بازنشانی می‌کنند.", "providers.yourProvider": "ارائه دهنده شما", "providers.authoringGuide": "راهنمای نگارش", @@ -2758,8 +2758,8 @@ export const localeMessages = { }, "th": { "meta.title": "CodexBar — ทุกขีดจำกัดการเข้ารหัส AI ในแถบเมนูของคุณ", - "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 63 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", - "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 63 รายจากแถบเมนู macOS", + "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 64 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", + "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 64 รายจากแถบเมนู macOS", "nav.primary": "หลัก", "nav.language": "ภาษา", "nav.docs": "เอกสาร", @@ -2772,7 +2772,7 @@ export const localeMessages = { "hero.description": "CodexBar ติดตามกรอบเวลาการใช้งาน ยอดเครดิต และรีเซ็ตการนับถอยหลังของผู้ให้บริการที่คุณชำระเงินจริง — รายการสถานะแต่ละรายการ หรือรวมรายการเหล่านั้นเป็นรายการเดียว", "hero.download": "ดาวน์โหลดสำหรับ macOS", "hero.fineprint": "ฟรีและโอเพ่นซอร์ส · macOS 14+ · รุ่น Universal ผ่าน GitHub Releases และ Homebrew", - "providers.title": "ผู้ให้บริการ 63 ราย{mobileBreak}หนึ่งแถบเมนู", + "providers.title": "ผู้ให้บริการ 64 ราย{mobileBreak}หนึ่งแถบเมนู", "providers.description": "ผู้ให้บริการยอดนิยมจะกลายเป็นรายการสถานะที่มีหน้าต่างการใช้งานของตนเอง รีเซ็ตการนับถอยหลัง แผนภูมิ และเมนูของผู้ให้บริการ", "providers.yourProvider": "ผู้ให้บริการของคุณ", "providers.authoringGuide": "คู่มือการเขียน", @@ -2898,8 +2898,8 @@ export const localeMessages = { }, "gl": { "meta.title": "CodexBar — todos os límites de programación con IA na túa barra de menús", - "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 63 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", - "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 63 provedores de programación con IA desde a barra de menús de macOS.", + "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 64 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", + "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 64 provedores de programación con IA desde a barra de menús de macOS.", "nav.primary": "Principal", "nav.language": "Idioma", "nav.docs": "Documentación", @@ -2912,7 +2912,7 @@ export const localeMessages = { "hero.description": "CodexBar controla as xanelas de uso, os saldos de crédito e as contas atrás ata o restablecemento dos provedores polos que realmente pagas — un elemento de estado para cada un ou todos combinados nun só.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratuíto e de código aberto · macOS 14+ · Universal mediante GitHub Releases e Homebrew", - "providers.title": "63 provedores,{mobileBreak}unha barra de menús", + "providers.title": "64 provedores,{mobileBreak}unha barra de menús", "providers.description": "Os provedores populares convértense en elementos de estado coas súas propias xanelas de uso, contas atrás de restablecemento, gráficas e menús.", "providers.yourProvider": "O teu provedor", "providers.authoringGuide": "Guía de creación", @@ -3038,8 +3038,8 @@ export const localeMessages = { }, "ca": { "meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús", - "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 63 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", - "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 63 proveïdors de programació amb IA des de la barra de menús del macOS.", + "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 64 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", + "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 64 proveïdors de programació amb IA des de la barra de menús del macOS.", "nav.primary": "Navegació principal", "nav.language": "Llengua", "nav.docs": "Documentació", @@ -3052,7 +3052,7 @@ export const localeMessages = { "hero.description": "El CodexBar fa el seguiment de les finestres d'ús, els saldos de crèdit i els comptes enrere fins al restabliment dels proveïdors pels quals realment pagueu: un element d'estat per a cadascun, o combineu-los tots en un de sol.", "hero.download": "Baixeu per al macOS", "hero.fineprint": "Gratuït i de codi obert · macOS 14+ · Universal mitjançant GitHub Releases i Homebrew", - "providers.title": "63 proveïdors,{mobileBreak}una barra de menús", + "providers.title": "64 proveïdors,{mobileBreak}una barra de menús", "providers.description": "Els proveïdors populars es converteixen en elements d'estat amb les seves pròpies finestres d'ús, comptes enrere de restabliment, gràfics i menús de proveïdor.", "providers.yourProvider": "El vostre proveïdor", "providers.authoringGuide": "Guia de creació", @@ -3178,8 +3178,8 @@ export const localeMessages = { }, "sv": { "meta.title": "CodexBar — varje AI-kodningsgräns i din menyrad", - "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 63 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", - "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 63 AI-kodningsleverantörer från din macOS-menyrad.", + "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 64 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", + "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 64 AI-kodningsleverantörer från din macOS-menyrad.", "nav.primary": "Primär", "nav.language": "Språk", "nav.docs": "Dokument", @@ -3192,7 +3192,7 @@ export const localeMessages = { "hero.description": "CodexBar spårar användningsfönster, kreditsaldon och återställningsnedräkningar för de leverantörer du faktiskt betalar för – en statuspost var, eller slå samman dem till en.", "hero.download": "Ladda ner för macOS", "hero.fineprint": "Gratis och öppen källkod · macOS 14+ · Universal via GitHub Releases och Homebrew", - "providers.title": "63 leverantörer,{mobileBreak}en menyrad", + "providers.title": "64 leverantörer,{mobileBreak}en menyrad", "providers.description": "Populära leverantörer blir statusobjekt med sina egna användningsfönster, återställer nedräkningar, diagram och leverantörsmenyer.", "providers.yourProvider": "Din leverantör", "providers.authoringGuide": "Författarguide", diff --git a/docs/social.html b/docs/social.html index acbb6291c0..a62990619e 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

Every AI coding limit, in your menu bar.

-

63 providers·usage windows, credits, resets·one status item each, or merged.

+

64 providers·usage windows, credits, resets·one status item each, or merged.

    diff --git a/docs/zoommate.md b/docs/zoommate.md new file mode 100644 index 0000000000..9df1a420e4 --- /dev/null +++ b/docs/zoommate.md @@ -0,0 +1,261 @@ +--- +summary: "ZoomMate provider auth, credits endpoint, history/pacing, and status page." +read_when: + - Adding or modifying the ZoomMate provider + - Debugging ZoomMate token import or usage parsing + - Explaining ZoomMate setup +--- + +# ZoomMate Provider + +The ZoomMate provider tracks credit usage against a budget cap from +[zoommate.zoom.us](https://zoommate.zoom.us). ZoomMate is a credits-based quota — there is no +session/weekly rate window, just a single primary "Credits" window. + +CodexBar's ZoomMate branding uses Zoom's official core palette: Bloom (`#0B5CFF`) as the primary +color, with Dawn (`#B4D0F8`) and Midnight (`#00053D`) as supporting colors. Provenance: +[Zoom Brand Center, Visual identity — Color](https://brand.zoom.com/document/1#/visual-identity/color), +retrieved 2026-07-18 (page last modified 2025-08-28). + +## Setup + +### Automatic (recommended, Chrome only) + +1. Sign in to ZoomMate in Chrome. +2. Enable **ZoomMate** in **Settings → Providers**. **Cookie source** defaults to **Auto**. + +CodexBar imports your ZoomMate/Zoom session cookies from Chrome's cookie jar (covering +`zoommate.zoom.us`, `ai.zoom.us`, and the parent `zoom.us` domain, where Zoom's SSO session cookies +actually live) and exchanges them for a short-lived bearer token via the same +cookie-to-token bootstrap endpoint ZoomMate's own web app uses internally +(`GET https://ai.zoom.us/ai-computer/api/v1/login/?continue=...`) — no manual paste required. This +automatic path intentionally tries Chrome only to avoid surprise prompts from other browser stores; +other browsers must use manual capture. + +Because the bearer token is minted from your (much longer-lived) session cookies, this keeps working +as long as you stay signed in to ZoomMate in Chrome. A minted token is reused from an in-memory cache +across refreshes until it nears expiry, then re-minted from the same cookies — no re-paste required. +If the Chrome session cookie is missing, CodexBar reports `noSession`; if Zoom rejects an existing +session, CodexBar reports `invalidCredentials`. + +Chrome's cookie decryption key lives in the macOS Keychain, so CodexBar only reads Chrome's cookie +store during user-initiated refreshes (a menu or Settings refresh, or `codexbar cookie`). Once a +fresh import validates — the cookie-to-token mint succeeds — the validated cookie header is saved to +CodexBar's shared Keychain cookie cache (`com.steipete.codexbar.cache`, account `cookie.zoommate`, +same as other cookie providers) and reused before re-importing from Chrome. Background refreshes and +the bundled `codexbar` CLI run entirely from that cached header, so they never touch Chrome's cookie +store or trigger a Keychain prompt. If Zoom rejects the cached session, CodexBar drops it and retries +one fresh Chrome import (user-initiated contexts only; background refreshes report `noSession` until +the next user refresh). + +### Manual + +Set **Cookie source** to **Manual** in the ZoomMate provider settings, then paste a full `curl` +command captured from DevTools: + +1. Open [zoommate.zoom.us](https://zoommate.zoom.us) and sign in. +2. Navigate to the AI credit usage page (Settings → AI credit usage, or equivalent). +3. Open Developer Tools → Network tab. +4. Reload the page and find the `credits/status` request + (`GET https://ai.zoom.us/ai-computer/api/v1/credits/status`). +5. Right-click → Copy → Copy as cURL. +6. Paste the full `curl` command into the **ZoomMate capture** field in CodexBar settings. + +Unlike some other manual-paste providers, the forwarded header allowlist for ZoomMate **includes +`Authorization`** — that's the required credential (see "Common errors" below). `Cookie` and +selected browser headers may also be forwarded, but `Origin` and `Referer` are always replaced with +the fixed `https://zoommate.zoom.us` value. Captures are accepted only for the HTTPS +`/ai-computer/api/v1/credits/status` endpoint on `ai.zoom.us` or `zoommate.zoom.us` — DevTools may +show the request on either host, since both currently serve the same first-party API. + +## Token expiry (~hourly) + +The ZoomMate bearer token itself is short-lived — typically about an hour. Session cookies live far +longer, so: + +- **Automatic mode**: no action needed as long as you stay signed in to ZoomMate in Chrome; + CodexBar mints a bearer token from your session cookies and reuses it (in memory) until it nears + expiry, then re-mints automatically. +- **Manual mode**: re-capture and re-paste a fresh `curl` command when the pasted token expires + (surfaced as `invalidCredentials` — see below). This is expected behavior, not a bug — switching + to Automatic mode avoids it entirely. + +## Auth & privacy + +Automatic mode uses the same first-party ZoomMate web-client flow a signed-in browser uses: +CodexBar imports Zoom session cookies from the local Chrome cookie jar, sends those cookies only +to Zoom's `ai.zoom.us` login bootstrap endpoint, receives a short-lived bearer token, and then uses +that bearer token for the `credits/status` and `credits/history` requests. + +The minted bearer token is never persisted — it is held only in a process-lifetime in-memory cache. +The validated cookie header (and only the header — never the bearer) is persisted to CodexBar's +existing shared Keychain cookie cache after a successful mint, the same cache and lifecycle other +cookie providers (Claude web, Perplexity, OpenCode, …) already use, so background refreshes and the +bundled CLI can reuse the session without rereading Chrome. ZoomMate logs intentionally omit +cookies, bearer tokens, `nak` values, and raw response bodies. The +cookie read spans the parent `zoom.us` domain because Zoom's SSO session cookies are parent-scoped, +but the imported set is then narrowed to only cookies a browser would actually attach to +`ai.zoom.us` / `zoommate.zoom.us` (RFC 6265 domain-matching), dropping cookies host-scoped to +unrelated `*.zoom.us` siblings that these endpoints never receive. + +All authentication and credit requests use fixed HTTPS URLs on the two first-party API hosts: +`ai.zoom.us` is tried first, falling back to `zoommate.zoom.us` on non-auth failures. The hosts +currently serve the same `/ai-computer/` API interchangeably and either may retire in the future, so +the provider works with both; auth rejections (401/403) never trigger the fallback since the host +answered and the session is the problem. `zoommate.zoom.us` additionally provides the product UI and +the fixed web-client `continue`, `Origin`, and `Referer` values. The parent `zoom.us` scope is used +only to discover parent-scoped SSO cookies; it does not authorize requests to arbitrary Zoom +subdomains. ZoomMate does not currently expose a +documented public API, so this provider depends on the product's first-party web-client endpoints. + +This cookie-to-bearer shape follows an existing CodexBar precedent in the Factory provider's WorkOS +cookie exchange. The minted bearer is cached in memory (keyed by a SHA-256 of the cookie session) +and reused until it nears its JWT `exp`; a token whose expiry can't be read is never cached, and a +`401/403` from a downstream request evicts the cached token so the next refresh mints fresh. + +## Data Source + +CodexBar sends up to a few GET requests per refresh: + +```text +GET https://ai.zoom.us/ai-computer/api/v1/credits/status +GET https://ai.zoom.us/ai-computer/api/v1/credits/history?app_id=demo_app&limit=50&page=&sort_by=time&sort_order=desc&start_time=&end_time= +``` + +(Each request retries once on `zoommate.zoom.us` with the same path if `ai.zoom.us` fails with a +non-auth error — see "Auth & privacy" above.) + +The `credits/status` response's `data.credit_status` object is decoded into a +`ZoomMateCreditStatus` struct. The `credits/history` request is paginated (looping on `page` until +`page * limit + records.length` reaches the response's flat `data.total`, or a page's records are +entirely older than the requested `start_time`) to cover the last 30 days; real accounts have +modest history (tens of records total), so this is normally 1–2 requests. `app_id` is sent as a +fixed placeholder matching ZoomMate's own web UI — it does not scope the result set to a particular +integration. A failed `credits/history` fetch is non-fatal: it never blocks the primary +`credits/status` snapshot, it just means the history dashboard (below) is omitted for that refresh. + +### Fields mapped to the Credits window + +| Source field | CodexBar mapping | +|---|---| +| `used_credit` / `budget_cap` | Primary window `usedPercent` (clamped 0–100) | +| `cycle_end_date` | Primary window reset time (epoch ms) | +| `is_unlimited` / `budget_cap <= 0` | `usedPercent` forced to 0, reset countdown omitted | + +There is no secondary window; `resetDescription` is always "Credits". + +### Account identity (Automatic mode only) + +The same login bootstrap response used to mint the bearer token (`GET +.../login/?continue=...`) also carries a `data.user_profile` object with the signed-in +user's account details. CodexBar reads `user_profile.email` from it and surfaces it as the +provider's `accountEmail` — the same identity field Codex/Claude populate — so the menu card's +account row shows who is signed in, matching those providers' presentation. `loginMethod` is +set to `"Cookie"` whenever an email was resolved this way. This is purely additive enrichment: +a missing/absent `user_profile` or `email` never fails the token mint, it just leaves identity +unset. Manual (`.web` cURL-capture) mode has no equivalent bootstrap call, so `accountEmail` +stays `nil` there. + +## Credits history and pacing + +When ZoomMate is enabled and history data is available, the menu's Credits card shows a Today/30d +credits dashboard and pacing verdict **inline**, directly under the credits progress bar — on both +ZoomMate's own tab and the Overview aggregate view, matching Claude's/Codex's inline dashboards. It +includes: + +- Two KPI tiles, reusing the same `InlineUsageDashboardContent` component Claude/Codex render + their inline dashboards with: **Today** (emphasized — the current calendar day's summed `cost` + from `credits/history`, or 0 if nothing posted yet today) and **30d credits** (the sum across the + full 30-day window). This mirrors Codex's "Today" / "30d cost" tile pair and Claude's "Today" / + "30d spend" tile, swapping the "$"/cost unit for "credits" since ZoomMate has no dollar-cost + concept. +- A row of mini usage bars below the tiles, one per calendar day that has ZoomMate usage + (`credits/history`'s per-event ledger summed by day), capped at the most recent 30 days, rendered + in ZoomMate's brand color (`#0B5CFF`) — matching Codex/Claude's own bar density and per-provider + coloring exactly. +- A pacing line ("Pace: on track" / "Pace: N% ahead of budget" / "Pace: N% behind budget") rendered + as a plain inline-dashboard detail line below the mini-bars. It's computed from `credits/status`'s + `budget_cap`, cumulative `used_credit`, and the current billing cycle's + `cycle_start_date`/`cycle_end_date` — comparing actual usage against the expected + linear-elapsed-fraction of the cycle. This reuses `UsagePace`'s existing stage thresholds (on + track / slightly ahead / ahead / far ahead / slightly behind / behind / far behind) rather than + a ZoomMate-specific scale, so the wording matches other CodexBar pacing indicators. + +`is_deleted` history records are excluded from both the KPI tiles and the mini-bars; still-running +sessions (`is_running: true`) are included since their `cost` reflects consumption so far. The +30-day window is enforced independently at both fetch time (the request's `start_time`) and +display time (`dailyBreakdown()` filters to the trailing 30 calendar days regardless of what the +fetch returned), so the chart's calendar span is guaranteed either way. The pacing line only needs +the always-fetched `credits/status` snapshot, so it can appear even in refreshes where +`credits/history` fails or returns nothing. The inline section is gated on having either a +non-empty daily breakdown or a computable pacing verdict — an empty/failed history fetch silently +omits the section instead of showing an empty dashboard. + +## Status page + +ZoomMate's descriptor points at Zoom's public status page, +[zoomstatus.com](https://www.zoomstatus.com/), which is an Atlassian Statuspage.io site — the same +platform Claude's status page already uses. This means the overall-status row and its "Updated …" +subtitle work through CodexBar's existing shared Statuspage.io fetch/parse path with no +ZoomMate-specific fetcher code. + +Zoom's status page lists ~300+ components, dominated by heavy per-region duplication for services +unrelated to ZoomMate (Zoom Phone, Contact Center, and CX each repeated across many regions). +Showing all of them in ZoomMate's status drill-down would be noise, so ZoomMate's component submenu +is filtered to a named allowlist: + +- Zoom Meetings +- ZoomMate +- My Notes +- Zoom Workflows +- Zoom Developer Platform +- Zoom Support +- Zoom Website + +The allowlist matches component/group names exactly (case-sensitive) against whatever the live API +returns, and is tolerant of any subset being renamed or removed on Zoom's side: a missing name is +silently omitted, never an error, and if none of the allowlisted names are present the submenu +falls back to the same "components not loaded" empty state every other provider already has (no +ZoomMate-specific empty-state UI). "Zoom Meetings" and "Zoom Workflows" are themselves groups in +Zoom's data (each with their own child components); an allowlisted group is shown with its full +existing child list when expanded, same as it would be for any other provider. + +This allowlist lives in ZoomMate's provider descriptor metadata. Every other provider has no +descriptor allowlist and keeps showing every component their feed returns, unchanged. + +## CLI + +```bash +codexbar usage --provider zoommate +``` + +The CLI reuses the cookie header cached by a previous validated refresh; it does not read Chrome's +cookie store itself. If no cached session exists yet (`noSession`), refresh once from the app or +seed the cache from the terminal with `codexbar cookie --provider zoommate` (add +`--allow-keychain-prompt` to acknowledge that Chrome cookie decryption may prompt). + +ZoomMate provides no token-cost data and is not yet supported in the CodexBar widget (this +includes the credits history dashboard and status page above — both are app-menu-only). + +## Common errors + +| Error | Cause | Fix | +|---|---|---| +| `noCapture` | Manual mode is selected but the capture is empty, off-domain, or lacks a parseable `Authorization` header | Paste a fresh cURL capture of the HTTPS `credits/status` request from `ai.zoom.us` or `zoommate.zoom.us` | +| `noSession` | Automatic mode found no cached session and no ZoomMate/Zoom session cookies it may read (background refreshes and the CLI never read Chrome directly) | Sign in to ZoomMate in Chrome and refresh once from the app (or `codexbar cookie --provider zoommate`), or switch to Manual and paste a capture | +| `invalidCredentials` | HTTP 401/403 — the token expired (~hourly) or was revoked | Re-sign-in (auto) or re-paste a fresh capture (manual) | +| `apiError` | Any other non-200 HTTP status | Check ZoomMate's status; retry later | +| `parseFailed` | HTTP 200 body did not contain the expected `credit_status` shape | Open a CodexBar issue with a redacted response sample | + +## Key files + +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift` — provider metadata (including `statusPageURL` and the status-component allowlist) and the unified fetch strategy (calls both `credits/status` and `credits/history`) +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift` — credits/status request, cURL parsing, and cookie-to-token minting +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift` — credits/history request, paginated with a date-boundary stop, and the `ZoomMateCreditsHistorySnapshot` model +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift` — response decoding, error taxonomy, window mapping, daily-bucket aggregation (`dailyBreakdown()`), today's-total lookup (`todayCreditsUsed(now:calendar:)`), and pacing verdict computation +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift` — Chrome cookie-jar import (macOS only) +- `Sources/CodexBar/InlineUsageDashboardContent.swift` — shared Today/30d KPI-tile + mini-bar view also used by Claude/Codex/OpenRouter/etc.; ZoomMate renders through this same component +- `Sources/CodexBar/MenuCardView.swift` — renders the generic inline-dashboard slot for credits-only stacked cards +- `Sources/CodexBar/StatusItemController+Menu.swift` — `statusComponentsSubmenuProviders` and descriptor-backed `filterStatusComponents` +- `Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift` — settings pickers and bindings +- `Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift` — cookie source and capture persistence