diff --git a/CHANGELOG.md b/CHANGELOG.md index 259f2f20d9..357eea2c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## 0.46.1 — Unreleased ### Added +- Notion AI: add Business and Enterprise workspace allowance tracking for rolling and billing-period windows (#2552). Thanks @n0ah37! +- Notion AI: pace estimates on both the rolling and billing-period bars, scored against the real calendar month ending at the reset rather than a flat 30 days (#2552). Thanks @n0ah37! - Sync: opt-in iCloud sync (Settings → iCloud Sync, default off) syncs provider configuration, a curated preferences subset, and per-device usage snapshots across Macs via CloudKit; API keys/cookies/tokens ride end-to-end-encrypted fields with their own opt-out, hooks and machine-local paths never sync, and menus can show accounts from other Macs with last-known usage ("via · 1h ago") when the local fetch is unavailable. The app now also watches `config.json`, so external CLI edits apply live. - z.ai: add 7-day and 30-day model-usage chart ranges with dataset-consistent legends, colors, and daily tooltips (#2524). Thanks @LeoLin990405! - Refresh: add a default-off global Low Power Mode that limits automatic provider, local usage, and storage work to once every 30 minutes while keeping manual refresh immediate (#2518). Thanks @Carl723000! @@ -12,6 +14,8 @@ - Menu bar: Session/Weekly/Auto pace layout tokens that render the signed pace delta (`+11%`, `-8%`, `0%`), restoring the pre-0.45 "Both" display in the layout editor (#2540, fixes #2534). Thanks @kratocz! ### Fixed +- Notion AI: `codexbar` now honors the provider's Workspace ID, manual cookie header, and `off` source instead of always auto-selecting a workspace (#2552). Thanks @n0ah37! +- Providers with monthly billing windows (Notion AI, Amp, MiMo, StepFun, Doubao, Alibaba, OpenCode Go): the menu bar's pace token, the "runs out" estimate, and predictive pace warnings now measure the real calendar cycle, matching the card and the CLI instead of scoring every period as a flat 30 days (#2552). Thanks @n0ah37! - Cursor: make on-demand extra usage follow the shared optional-usage setting and remove the unsupported credits placeholder (#2338). Thanks @Zihao-Qi! - Antigravity/Sessions: inspect processes in-process via libproc instead of spawning full-system ps/lsof, eliminating repeated macOS 26 “access data from other apps” prompts (#2267 hardening). - Doubao: show Agent Plan windows alongside Coding Plan usage for Volcengine AK/SK accounts that subscribe to both products (#2517). Thanks @Astro-Han! diff --git a/README.md b/README.md index 3ef5206fd9..941126574a 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. 66 providers. +CodexBar — every AI coding limit in your menu bar. 67 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. diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 4aa8dd3f4a..4528a4c9bd 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -720,7 +720,11 @@ extension UsageMenuCardView.Model { window.remainingPercent > 0 else { return nil } let paceWindow = Self.resetWindowForPace(provider: input.provider, window: window) - let resolved = pace ?? UsagePace.weekly( + // A caller-supplied pace was measured against the raw window, so reuse it only when resolution + // left the duration alone. Trusting it for a monthly sentinel would score the billing period as + // a flat 30 days and silently undo the calendar-cycle resolution one line above. + let reusablePace = paceWindow.windowMinutes == window.windowMinutes ? pace : nil + let resolved = reusablePace ?? UsagePace.weekly( window: paceWindow, now: input.now, defaultWindowMinutes: 10080, diff --git a/Sources/CodexBar/Providers/Notion/NotionProviderImplementation.swift b/Sources/CodexBar/Providers/Notion/NotionProviderImplementation.swift new file mode 100644 index 0000000000..6bee054532 --- /dev/null +++ b/Sources/CodexBar/Providers/Notion/NotionProviderImplementation.swift @@ -0,0 +1,104 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct NotionProviderImplementation: ProviderImplementation { + let id: UsageProvider = .notion + let supportsLoginFlow: Bool = true + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func runLoginFlow(context _: ProviderLoginContext) async -> Bool { + if let url = URL(string: "https://app.notion.com/") { + NSWorkspace.shared.open(url) + } + return false + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.notionCookieSource + _ = settings.notionCookieHeader + _ = settings.notionWorkspaceID + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .notion(context.settings.notionSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.notionCookieSource.rawValue }, + set: { raw in + context.settings.notionCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let options = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.notionCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports the browser session cookie.", + manual: "Paste a full cookie header or the token_v2 value.", + off: "Notion cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "notion-cookie-source", + title: "Cookie source", + subtitle: "Automatically imports the browser session cookie.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "notion-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: \u{2026}\n\nor paste the token_v2 value", + binding: context.stringBinding(\.notionCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "notion-open-usage", + title: "Open Usage Page", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://app.notion.com/") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.notionCookieSource == .manual }, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "notion-workspace-id", + title: "Workspace ID", + subtitle: "Optional. Defaults to the first Business or Enterprise workspace on the account.", + kind: .plain, + placeholder: "00000000-0000-0000-0000-000000000000", + binding: context.stringBinding(\.notionWorkspaceID), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Notion/NotionSettingsStore.swift b/Sources/CodexBar/Providers/Notion/NotionSettingsStore.swift new file mode 100644 index 0000000000..52a313e6c5 --- /dev/null +++ b/Sources/CodexBar/Providers/Notion/NotionSettingsStore.swift @@ -0,0 +1,55 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var notionCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .notion)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .notion) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .notion, field: "cookieHeader", value: newValue) + } + } + + var notionCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .notion, fallback: .auto) } + set { + self.updateProviderConfig(provider: .notion) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .notion, field: "cookieSource", value: newValue.rawValue) + } + } + + var notionWorkspaceID: String { + get { self.configSnapshot.providerConfig(for: .notion)?.sanitizedWorkspaceID ?? "" } + set { + self.updateProviderConfig(provider: .notion) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + } + } +} + +extension SettingsStore { + func notionSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.NotionProviderSettings + { + // Resolved directly rather than through `resolvedCookieSettings`, whose generic construction + // can only carry the two cookie fields and would drop the workspace override. + let resolved = ProviderCookieSettingsResolver.resolve( + provider: .notion, + configuredSource: self.notionCookieSource, + configuredHeader: self.notionCookieHeader, + selectedAccount: ProviderTokenAccountSelection.selectedAccount( + provider: .notion, + settings: self, + override: tokenOverride)) + let workspaceID = self.notionWorkspaceID + return ProviderSettingsSnapshot.NotionProviderSettings( + cookieSource: resolved.cookieSource, + manualCookieHeader: resolved.manualCookieHeader, + workspaceID: workspaceID.isEmpty ? nil : workspaceID) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift index f0f858b721..7a888a5679 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift @@ -71,5 +71,6 @@ enum ProviderImplementationManifest { { AiAndProviderImplementation() }, { ZoomMateProviderImplementation() }, { XAIProviderImplementation() }, + { NotionProviderImplementation() }, ] } diff --git a/Sources/CodexBar/Resources/ProviderIcon-notion.svg b/Sources/CodexBar/Resources/ProviderIcon-notion.svg new file mode 100644 index 0000000000..671be89960 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-notion.svg @@ -0,0 +1 @@ +Notion \ No newline at end of file diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 744f41ee17..6130b4130e 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -202,6 +202,9 @@ struct ShareStatsSubscriptionName: Sendable, Equatable { "starter": "Starter", "pro": "Pro", "team": "Team", "enterprise": "Enterprise", ], UsageProvider.t3chat.rawValue: ["free": "Free", "pro": "Pro", "team": "Team"], + UsageProvider.notion.rawValue: [ + "free": "Free", "plus": "Plus", "business": "Business", "enterprise": "Enterprise", + ], UsageProvider.sub2api.rawValue: [ "free": "Free", "pro": "Pro", "team": "Team", "claude team": "Team", "enterprise": "Enterprise", "wallet plan": "Wallet", diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 76cd6e833a..f864f549de 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -156,7 +156,7 @@ enum UsagePaceText { static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { guard provider == .codex || provider == .claude || provider == .ollama || provider == .antigravity || - provider == .kimi + provider == .kimi || provider == .notion else { return nil } if provider == .ollama, window.windowMinutes == nil { return nil @@ -167,6 +167,14 @@ enum UsagePaceText { if provider == .kimi, window.windowMinutes != KimiProviderDescriptor.sessionWindowMinutes { return nil } + if provider == .notion { + // Notion parses its rolling length from an API token (`6h`), so the shape is not guaranteed. + // Only a real rolling allowance may be paced here; anything longer is a billing period and + // belongs on the descriptor's reset-window pace instead. + guard let minutes = window.windowMinutes, + minutes <= NotionProviderDescriptor.rollingWindowMaxMinutes + else { return nil } + } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) else { return nil } guard pace.expectedUsedPercent >= 3 else { return nil } diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index 435b292aa5..360f544be0 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -34,7 +34,14 @@ extension UsageStore { // windows without windowMinutes would fabricate a weekly pace for non-weekly windows // (e.g. Factory monthly with only resetsAt). guard window.windowMinutes != nil else { return nil } - resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays) + // Expand a monthly sentinel to the real calendar cycle before scoring. The menu card and the + // CLI both resolve first, so skipping it here would score a billing period as a flat 30 days + // and disagree with them — and a 31-day cycle would exceed the sentinel outright, dropping the + // pace for the first day of every long month. + let paceWindow = ProviderDescriptorRegistry.descriptor(for: provider) + .pace + .resolvedResetWindowForPace(window) + resolved = UsagePace.weekly(window: paceWindow, now: now, defaultWindowMinutes: 10080, workDays: workDays) } guard let resolved else { return nil } diff --git a/Sources/CodexBar/UsageStore+NotionDebug.swift b/Sources/CodexBar/UsageStore+NotionDebug.swift new file mode 100644 index 0000000000..b6fcf6b5ef --- /dev/null +++ b/Sources/CodexBar/UsageStore+NotionDebug.swift @@ -0,0 +1,21 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + static func debugNotionLog( + browserDetection: BrowserDetection, + notionCookieSource: ProviderCookieSource, + notionCookieHeader: String, + notionWorkspaceID: String) async -> String + { + await runWithTimeout(seconds: 15) { + let fetcher = NotionUsageFetcher(browserDetection: browserDetection) + let manualHeader = notionCookieSource == .manual + ? CookieHeaderNormalizer.normalize(notionCookieHeader) + : nil + return await fetcher.debugRawProbe( + cookieHeaderOverride: manualHeader, + preferredSpaceID: notionWorkspaceID.isEmpty ? nil : notionWorkspaceID) + } + } +} diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 41fe3ed9a3..53f625df30 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -6,6 +6,11 @@ import WidgetKit extension UsageStore { func persistWidgetSnapshot(reason: String) { + #if DEBUG + // Unsigned test processes must not cross into the real app-group container. Snapshot tests + // opt in with an in-memory override, which also keeps their assertions deterministic. + guard !SettingsStore.isRunningTests || self._test_widgetSnapshotSaveOverride != nil else { return } + #endif // A fresh process has token-cost data before a user-authorized Claude OAuth refresh can run. // Keep the last queued snapshot in memory so back-to-back writes cannot race the on-disk cache. let previousSnapshot = self.lastQueuedWidgetSnapshot ?? { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d471a8606f..e67609b8e5 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1010,6 +1010,9 @@ extension UsageStore { let ampCookieHeader = self.settings.ampCookieHeader let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader + let notionCookieSource = self.settings.notionCookieSource + let notionCookieHeader = self.settings.notionCookieHeader + let notionWorkspaceID = self.settings.notionWorkspaceID let processEnvironment = self.environmentBase let openAIDebugContext = self.openAIAPIKeyDebugContext(processEnvironment: processEnvironment) let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment) @@ -1070,10 +1073,10 @@ extension UsageStore { switch provider { case .codex: return await codexFetcher.debugRawRateLimits() - case .openai: - return Self.apiKeyDebugLine(openAIDebugContext) - case .azureopenai: - return Self.apiKeyDebugLine(azureOpenAIDebugContext) + // Folded into one case: both read the same helper, and keeping them apart pushed this + // switch past the cyclomatic-complexity cap when the Notion case was added. + case .openai, .azureopenai: + return Self.apiKeyDebugLine(provider == .openai ? openAIDebugContext : azureOpenAIDebugContext) case .claude: guard let claudeDebugConfiguration else { return "Claude debug log configuration unavailable" @@ -1123,6 +1126,12 @@ extension UsageStore { browserDetection: browserDetection, ollamaCookieSource: ollamaCookieSource, ollamaCookieHeader: ollamaCookieHeader) + case .notion: + return await Self.debugNotionLog( + browserDetection: browserDetection, + notionCookieSource: notionCookieSource, + notionCookieHeader: notionCookieHeader, + notionWorkspaceID: notionWorkspaceID) case .openrouter: return Self.apiKeyDebugLine(openRouterDebugContext) case .elevenlabs: diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 4d303b21a5..026f6b8cbb 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -1170,7 +1170,8 @@ enum CLIRenderer { func supportsStandardPace(provider: UsageProvider) -> Bool { switch self { case .session: - provider == .codex || provider == .claude || provider == .ollama || provider == .kimi + provider == .codex || provider == .claude || provider == .ollama || provider == .kimi || + provider == .notion case .weekly: provider == .codex || provider == .claude || provider == .opencode || provider == .ollama || provider == .kimi @@ -1222,7 +1223,9 @@ enum CLIRenderer { guard supportsWindow else { return nil } } // Only pace a real session window here; Claude w/o 5-hour data falls a 7-day window into primary. - if case .session = resolvedKind, let minutes = paceWindow.windowMinutes, minutes > 300 { + // Notion's rolling allowance is a 6-hour window, so it needs the wider ceiling to match the card. + let sessionCeilingMinutes = provider == .notion ? NotionProviderDescriptor.rollingWindowMaxMinutes : 300 + if case .session = resolvedKind, let minutes = paceWindow.windowMinutes, minutes > sessionCeilingMinutes { return nil } if provider == .ollama, paceWindow.windowMinutes == nil { diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 358150a333..b98f9cff3f 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -238,6 +238,14 @@ struct TokenAccountCLIContext { return self.makeSnapshot(mistral: self.makeProviderCookieSettings(cookieSettings)) case .zoommate: return self.makeSnapshot(zoommate: self.makeProviderCookieSettings(cookieSettings)) + case .notion: + // Carries `workspaceID` like OpenCode does; the generic cookie-settings shape would drop it + // and leave the CLI auto-selecting a workspace the user had explicitly pinned. + return self.makeSnapshot( + notion: ProviderSettingsSnapshot.NotionProviderSettings( + cookieSource: cookieSettings.cookieSource, + manualCookieHeader: cookieSettings.manualCookieHeader, + workspaceID: config?.workspaceID)) case .stepfun: let stepfunSettings = self.cookieSettings( provider: provider, @@ -283,7 +291,8 @@ struct TokenAccountCLIContext { mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil, qoder: ProviderSettingsSnapshot.QoderProviderSettings? = nil, stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil, - zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings? = nil) -> ProviderSettingsSnapshot + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings? = nil, + notion: ProviderSettingsSnapshot.NotionProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -305,6 +314,7 @@ struct TokenAccountCLIContext { moonshot: moonshot, amp: amp, zoommate: zoommate, + notion: notion, commandcode: commandcode, ollama: ollama, jetbrains: jetbrains, diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index c59e5d7877..27fe2020e6 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -66,6 +66,7 @@ public enum LogCategories { public static let moonshotUsage = Self.provider(.moonshot, scope: "usage") public static let neuralWattUsage = Self.provider(.neuralwatt, scope: "usage") public static let notifications = "notifications" + public static let notion = Self.provider(.notion) public static let openAIWeb = Self.provider(.openai, scope: "web") public static let openAIWebview = Self.provider(.openai, scope: "webview") public static let ollama = Self.provider(.ollama) diff --git a/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift new file mode 100644 index 0000000000..7cb04d47dd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift @@ -0,0 +1,102 @@ +import Foundation + +public enum NotionProviderDescriptor { + /// Notion reports the rolling allowance as a `6h` window — session-shaped, but wider than the + /// 5-hour ceiling the shared session-pace paths assume. Windows longer than this are not rolling + /// allowances and must not be paced as one. + public static let rollingWindowMaxMinutes = 6 * 60 + + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .notion, + metadata: ProviderMetadata( + id: .notion, + displayName: "Notion AI", + sessionLabel: "Rolling", + weeklyLabel: "Monthly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Notion AI usage", + cliName: "notion", + defaultEnabled: false, + // Not yet supported in widgets. + widgetSelectable: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.chromeOnlyImportOrder, + dashboardURL: "https://app.notion.com/", + statusPageURL: nil, + statusLinkURL: "https://status.notion.so/"), + branding: ProviderBranding( + iconStyle: .init(provider: .notion), + iconResourceName: "ProviderIcon-notion", + // Notion's UI accent blue, not its near-black brand ink: the ink is + // indistinguishable from the unfilled track in a usage gauge. + color: ProviderColor(red: 51 / 255, green: 126 / 255, blue: 169 / 255), + confettiPalette: [ + ProviderColor(hex: 0x337EA9), + ProviderColor(hex: 0xE16259), + ProviderColor(hex: 0x37352F), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Notion AI cost summary is not supported." }), + // The billing-period window renews on a calendar cycle, so pace has to measure the real + // month ending at the reset rather than the 30-day sentinel the snapshot carries. + pace: .calendarMonthResetWindow, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [NotionWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "notion", + aliases: ["notion-ai", "notionai"], + versionDetector: nil)) + } +} + +struct NotionWebFetchStrategy: ProviderFetchStrategy { + let id: String = "notion.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let cookieSource = context.settings?.notion?.cookieSource ?? .auto + guard cookieSource != .off else { return false } + if cookieSource == .manual { + return NotionUsageFetcher.requestContext(from: context.settings?.notion?.manualCookieHeader) != nil + } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = NotionUsageFetcher(browserDetection: context.browserDetection) + let manual = Self.manualCookieHeader(from: context) + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.notion).verbose(msg) } + : nil + let snapshot = try await fetcher.fetch( + cookieHeaderOverride: manual, + preferredSpaceID: context.settings?.notion?.workspaceID, + timeout: context.webTimeout, + logger: logger) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.notion?.cookieSource == .manual else { return nil } + return context.settings?.notion?.manualCookieHeader + } +} diff --git a/Sources/CodexBarCore/Providers/Notion/NotionSessionStore.swift b/Sources/CodexBarCore/Providers/Notion/NotionSessionStore.swift new file mode 100644 index 0000000000..228d7c97e9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Notion/NotionSessionStore.swift @@ -0,0 +1,85 @@ +import Foundation + +#if os(macOS) + +public actor NotionSessionStore { + public struct Session: Codable, Equatable, Sendable { + public let tokenV2: String + public let sourceLabel: String + + public init(tokenV2: String, sourceLabel: String) { + self.tokenV2 = tokenV2 + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + "\(NotionUsageFetcher.sessionCookieName)=\(self.tokenV2)" + } + } + + public static let shared = NotionSessionStore() + + private var session: Session? + private var hasLoadedFromDisk = false + private let fileURL: URL + + private init() { + let fm = FileManager.default + let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fm.temporaryDirectory + self.fileURL = appSupport + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("notion-session.json") + } + + #if DEBUG + init(fileURL: URL) { + self.fileURL = fileURL + } + #endif + + public func setSession(tokenV2: String, sourceLabel: String) { + let token = tokenV2.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { + self.clearSession() + return + } + self.hasLoadedFromDisk = true + self.session = Session(tokenV2: token, sourceLabel: sourceLabel) + self.saveToDisk() + } + + public func getSession() -> Session? { + self.loadFromDiskIfNeeded() + return self.session + } + + public func clearSession() { + self.hasLoadedFromDisk = true + self.session = nil + try? FileManager.default.removeItem(at: self.fileURL) + } + + private func loadFromDiskIfNeeded() { + guard !self.hasLoadedFromDisk else { return } + self.hasLoadedFromDisk = true + CredentialFileWriter.repairPermissions(at: self.fileURL) + guard let data = try? Data(contentsOf: self.fileURL), + let session = try? JSONDecoder().decode(Session.self, from: data), + !session.tokenV2.isEmpty + else { return } + self.session = session + } + + private func saveToDisk() { + guard let session = self.session, + let data = try? JSONEncoder().encode(session) + else { + try? FileManager.default.removeItem(at: self.fileURL) + return + } + try? CredentialFileWriter.writePrivate(data, to: self.fileURL) + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/Notion/NotionUsageFetcher.swift b/Sources/CodexBarCore/Providers/Notion/NotionUsageFetcher.swift new file mode 100644 index 0000000000..2a89b9a869 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Notion/NotionUsageFetcher.swift @@ -0,0 +1,456 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +public enum NotionCookieImporter { + private static let importSessionCacheTTL: TimeInterval = 5 + private static let importSessionCache = ImportSessionCache(ttl: importSessionCacheTTL) + private static let cookieClient = BrowserCookieClient() + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.notion]?.browserCookieOrder ?? Browser.defaultImportOrder + /// The app moved to `app.notion.com`; `notion.so` is kept for sessions that predate the move. + private static let cookieDomains = [ + "app.notion.com", + "www.notion.com", + "notion.com", + "www.notion.so", + "notion.so", + ] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + + var tokenV2: String? { + self.cookies.first(where: { $0.name == NotionUsageFetcher.sessionCookieName })?.value + } + } + + public static func importSession( + browserDetection: BrowserDetection, + browserOrder: BrowserCookieImportOrder? = nil, + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[notion-cookie] \(msg)") } + let now = Date() + // Reading cookie stores touches browser Safe Storage; a short TTL keeps the polling refresh + // from doing that on every tick. + if let cached = self.importSessionCache.load(now: now) { + return cached + } + let importOrder = browserOrder ?? self.cookieImportOrder + let installed = importOrder.cookieImportCandidates(using: browserDetection) + // `cookieImportCandidates` drops Chromium browsers on anything but a user-initiated refresh, + // to avoid a Keychain prompt. Saying "log in" there would be wrong — the session is fine, it + // just cannot be read yet. + if installed.isEmpty, !importOrder.browsersWithProfileData(using: browserDetection).isEmpty { + throw NotionUsageError.cookieImportDeferred + } + + 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) + guard !cookies.isEmpty else { continue } + let deduped = self.deduplicatedByName(cookies) + // `token_v2` is the session cookie; without it the API answers 401 for every call. + guard deduped.contains(where: { $0.name == NotionUsageFetcher.sessionCookieName }) else { + log("\(source.label) has Notion cookies but no session cookie") + continue + } + let names = deduped.map(\.name).joined(separator: ", ") + log("\(source.label) cookies: \(names)") + let session = SessionInfo(cookies: deduped, sourceLabel: source.label) + self.importSessionCache.store(session, now: now) + return session + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + throw NotionUsageError.noSessionCookie + } + + /// A profile can hold the same cookie on several Notion domains — most often a stale `token_v2` + /// left on the legacy `notion.so` alongside the live one. Emitting both puts two `token_v2` + /// pairs in one header and the server picks arbitrarily, so keep the most specific domain's. + static func deduplicatedByName(_ cookies: [HTTPCookie]) -> [HTTPCookie] { + var best: [String: (rank: Int, cookie: HTTPCookie)] = [:] + for cookie in cookies { + let host = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain + let rank = self.cookieDomains.firstIndex(of: host.lowercased()) ?? self.cookieDomains.count + if let existing = best[cookie.name], existing.rank <= rank { + continue + } + best[cookie.name] = (rank, cookie) + } + return best.keys.sorted().compactMap { best[$0]?.cookie } + } + + private final class ImportSessionCache: @unchecked Sendable { + private let ttl: TimeInterval + private let lock = NSLock() + private var entry: (session: SessionInfo, expiresAt: Date)? + + init(ttl: TimeInterval) { + self.ttl = ttl + } + + func load(now: Date) -> SessionInfo? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entry, entry.expiresAt > now else { + self.entry = nil + return nil + } + return entry.session + } + + func store(_ session: SessionInfo, now: Date) { + self.lock.lock() + self.entry = (session, now.addingTimeInterval(self.ttl)) + self.lock.unlock() + } + } +} +#endif + +public struct NotionUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.notion) + static let sessionCookieName = "token_v2" + private static let baseURL = URL(string: "https://app.notion.com")! + private static let refererURL = URL(string: "https://app.notion.com/")! + /// Browser fingerprint defaults are only fallbacks; full cURL captures override these forwarded headers. + 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" + private static let forwardedManualHeaders = [ + "accept": "Accept", + "accept-language": "Accept-Language", + "notion-audit-log-platform": "notion-audit-log-platform", + "notion-client-version": "notion-client-version", + "referer": "Referer", + "sec-fetch-dest": "Sec-Fetch-Dest", + "sec-fetch-mode": "Sec-Fetch-Mode", + "sec-fetch-site": "Sec-Fetch-Site", + "user-agent": "User-Agent", + "x-notion-active-user-header": "x-notion-active-user-header", + // `x-notion-space-id` is deliberately not forwarded: a capture taken in one workspace would + // pin that space while the request body asks for the configured one, and the mismatch would + // surface as another workspace's usage rather than an error. + ] + + public struct RequestContext: Sendable { + /// Normalized at construction so each request can use it as-is. Empty means unusable. + public let cookieHeader: String + public let headers: [String: String] + + public init(cookieHeader: String, headers: [String: String] = [:]) { + self.cookieHeader = CookieHeaderNormalizer.normalize(cookieHeader) ?? "" + self.headers = headers + } + + var isUsable: Bool { + !self.cookieHeader.isEmpty + } + } + + public let browserDetection: BrowserDetection + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + } + + public func fetch( + cookieHeaderOverride: String? = nil, + preferredSpaceID: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> NotionUsageSnapshot + { + let log: (String) -> Void = { msg in logger?("[notion] \(msg)") } + let options = FetchOptions( + preferredSpaceID: preferredSpaceID, + timeout: timeout, + logger: logger, + now: now, + transport: transport) + + if let override = Self.requestContext(from: cookieHeaderOverride) { + log("Using \(override.headers.isEmpty ? "manual cookie header" : "manual cURL capture")") + return try await self.runFetch(context: override, options: options) + } + + #if os(macOS) + // Chromium cookie imports only run on a user-initiated refresh, so a timer tick has no way + // to read the browser store. Reusing the header cached by the last successful import is what + // keeps background refreshes working instead of reporting "no cookies found". + if let cached = CookieHeaderCache.load(provider: .notion), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + log("Using cached cookie header from \(cached.sourceLabel)") + do { + return try await self.runFetch( + context: RequestContext(cookieHeader: cached.cookieHeader), + options: options) + } catch NotionUsageError.invalidCredentials { + CookieHeaderCache.clear(provider: .notion) + await NotionSessionStore.shared.clearSession() + log("Cached session was rejected; cleared persisted copies and retrying with a fresh import") + } + } + + if ProviderInteractionContext.current != .userInitiated, + let stored = await NotionSessionStore.shared.getSession() + { + log("Using stored session from \(stored.sourceLabel)") + do { + return try await self.runFetch( + context: RequestContext(cookieHeader: stored.cookieHeader), + options: options) + } catch NotionUsageError.invalidCredentials { + await NotionSessionStore.shared.clearSession() + log("Stored session was rejected; cleared it and retrying with a fresh import") + } + } + + let session = try NotionCookieImporter.importSession( + browserDetection: self.browserDetection, + logger: logger) + log("Using cookies from \(session.sourceLabel)") + let snapshot = try await self.runFetch( + context: RequestContext(cookieHeader: session.cookieHeader), + options: options) + if let tokenV2 = session.tokenV2 { + await NotionSessionStore.shared.setSession(tokenV2: tokenV2, sourceLabel: session.sourceLabel) + } + CookieHeaderCache.store( + provider: .notion, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return snapshot + #else + throw NotionUsageError.noSessionCookie + #endif + } + + /// The per-call inputs that stay the same across every attempt a single `fetch` makes. + private struct FetchOptions { + let preferredSpaceID: String? + let timeout: TimeInterval + let logger: ((String) -> Void)? + let now: Date + let transport: any ProviderHTTPTransport + } + + private func runFetch( + context: RequestContext, + options: FetchOptions) async throws -> NotionUsageSnapshot + { + let (preferredSpaceID, timeout, logger, now, transport) = + (options.preferredSpaceID, options.timeout, options.logger, options.now, options.transport) + if let logger { + let names = CookieHeaderNormalizer.pairs(from: context.cookieHeader).map(\.name) + if !names.isEmpty { + logger("[notion] Cookie names: \(names.joined(separator: ", "))") + } + if !context.headers.isEmpty { + let headerNames = context.headers.keys.sorted().joined(separator: ", ") + logger("[notion] Forwarding captured headers: \(headerNames)") + } + } + let snapshot = try await Self.fetchUsage( + context: context, + preferredSpaceID: preferredSpaceID, + timeout: timeout, + now: now, + transport: transport) + if let workspace = snapshot.workspace { + logger?("[notion] Using workspace \(workspace.name ?? workspace.id) (\(workspace.id))") + } + return snapshot + } + + public func debugRawProbe( + cookieHeaderOverride: String? = nil, + preferredSpaceID: String? = nil) async -> String + { + let stamp = ISO8601DateFormatter().string(from: Date()) + var lines: [String] = [] + lines.append("=== Notion Debug Probe @ \(stamp) ===") + lines.append("") + + do { + let snapshot = try await self.fetch( + cookieHeaderOverride: cookieHeaderOverride, + preferredSpaceID: preferredSpaceID, + logger: { msg in lines.append(msg) }) + lines.append("") + lines.append("Fetch Success") + lines.append("workspace=\(snapshot.workspace?.name ?? "nil")") + lines.append("tier=\(snapshot.workspace?.subscriptionTier ?? "nil")") + lines.append("status=\(snapshot.rateLimit.status ?? "nil")") + lines.append("enforcement=\(snapshot.rateLimit.enforcement ?? "nil")") + lines.append("rollingWindow=\(snapshot.rateLimit.window?.window ?? "nil")") + lines.append("rollingUsed=\(snapshot.rateLimit.window?.used?.description ?? "nil")") + lines.append("rollingLimit=\(snapshot.rateLimit.window?.limit?.description ?? "nil")") + lines.append("resetsInSeconds=\(snapshot.rateLimit.resetsInSeconds?.description ?? "nil")") + lines.append("billingUsed=\(snapshot.rateLimit.billingPeriodWindow?.used?.description ?? "nil")") + lines.append("billingLimit=\(snapshot.rateLimit.billingPeriodWindow?.limit?.description ?? "nil")") + lines.append("periodEndMs=\(snapshot.rateLimit.billingPeriodWindow?.periodEndMs?.description ?? "nil")") + } catch { + lines.append("") + lines.append("Probe Failed: \(error.localizedDescription)") + } + + return lines.joined(separator: "\n") + } + + public static func fetchUsage( + cookieHeader: String, + preferredSpaceID: String? = nil, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> NotionUsageSnapshot + { + try await self.fetchUsage( + context: RequestContext(cookieHeader: cookieHeader), + preferredSpaceID: preferredSpaceID, + timeout: timeout, + now: now, + transport: transport) + } + + static func fetchUsage( + context: RequestContext, + preferredSpaceID: String?, + timeout: TimeInterval, + now: Date, + transport: any ProviderHTTPTransport) async throws -> NotionUsageSnapshot + { + guard context.isUsable else { + throw NotionUsageError.noSessionCookie + } + + let account = try await self.fetchAccount(context: context, timeout: timeout, transport: transport) + guard let workspace = account.resolveWorkspace(preferredID: preferredSpaceID) else { + throw NotionUsageError.noWorkspace + } + + let data = try await self.post( + endpoint: "getCreditRateLimitStatus", + body: ["spaceId": workspace.id], + context: context, + timeout: timeout, + transport: transport) + let status = try NotionUsageParser.parseRateLimitStatus(data) + + guard !status.isNotApplicable else { + throw NotionUsageError.allowanceNotApplicable(workspace: workspace.name) + } + + return NotionUsageSnapshot( + rateLimit: status, + workspace: workspace, + account: account, + updatedAt: now) + } + + static func fetchAccount( + context: RequestContext, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> NotionAccount + { + let data = try await self.post( + endpoint: "getSpaces", + body: [:], + context: context, + timeout: timeout, + transport: transport) + return try NotionUsageParser.parseSpaces(data) + } + + private static func post( + endpoint: String, + body: [String: String], + context: RequestContext, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> Data + { + guard let url = URL(string: "/api/v3/\(endpoint)", relativeTo: self.baseURL) else { + throw NotionUsageError.apiError("Failed to build \(endpoint) URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) + self.applyDefaultHeaders(to: &request) + for (name, value) in context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + request.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(context.cookieHeader, forHTTPHeaderField: "Cookie") + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + let preview = String(data: response.data.prefix(200), encoding: .utf8) ?? "" + Self.log.error("Notion \(endpoint) returned \(response.statusCode): \(preview)") + if response.statusCode == 401 { + throw NotionUsageError.invalidCredentials + } + throw NotionUsageError.apiError("HTTP \(response.statusCode) from \(endpoint)") + } + return response.data + } + + static func requestContext(from raw: String?) -> RequestContext? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + let headerFields = CurlCaptureParser.headerFields(from: raw) + let headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) + guard let normalized = CookieHeaderNormalizer.normalize( + CurlCaptureParser.headerValue(named: "Cookie", in: headerFields) ?? raw) + else { return nil } + let cookieHeader = CookieHeaderNormalizer.pairs(from: normalized).isEmpty + ? "\(self.sessionCookieName)=\(normalized)" + : normalized + let context = RequestContext( + cookieHeader: cookieHeader, + headers: headers) + return context.isUsable ? context : nil + } + + private static func applyDefaultHeaders(to request: inout URLRequest) { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site") + } +} diff --git a/Sources/CodexBarCore/Providers/Notion/NotionUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Notion/NotionUsageSnapshot.swift new file mode 100644 index 0000000000..ccce666e42 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Notion/NotionUsageSnapshot.swift @@ -0,0 +1,336 @@ +import Foundation + +public enum NotionUsageError: LocalizedError, Sendable, Equatable { + case noSessionCookie + case cookieImportDeferred + case invalidCredentials + case noWorkspace + case allowanceNotApplicable(workspace: String?) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .noSessionCookie: + "No Notion cookies found. Please log in to notion.com in your browser." + case .cookieImportDeferred: + "Notion cookies can only be read during a manual refresh. Refresh CodexBar once to import them." + case .invalidCredentials: + "Notion session cookie is invalid or expired." + case .noWorkspace: + "No Notion workspace found for this account." + case let .allowanceNotApplicable(workspace): + if let workspace { + "Notion AI usage allowance is not tracked for \"\(workspace)\". " + + "Allowances apply to Business and Enterprise workspaces." + } else { + "Notion AI usage allowance is not tracked for this workspace. " + + "Allowances apply to Business and Enterprise workspaces." + } + case let .apiError(message): + "Notion API error: \(message)" + case let .parseFailed(message): + "Could not parse Notion usage: \(message)" + } + } +} + +// MARK: - Account + +/// One workspace ("space") the signed-in account belongs to. +public struct NotionWorkspace: Sendable, Equatable { + public let id: String + public let name: String? + public let planType: String? + public let subscriptionTier: String? + + public init(id: String, name: String?, planType: String?, subscriptionTier: String?) { + self.id = id + self.name = name + self.planType = planType + self.subscriptionTier = subscriptionTier + } + + /// Only paid team plans carry a Notion AI usage allowance; free/personal spaces report `not_applicable`. + public var mayHaveAllowance: Bool { + switch self.subscriptionTier?.lowercased() { + case "business", "enterprise": true + default: false + } + } + + public var displayTier: String? { + guard let raw = self.subscriptionTier?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return raw.prefix(1).uppercased() + raw.dropFirst() + } +} + +public struct NotionAccount: Sendable, Equatable { + public let userID: String? + public let email: String? + public let name: String? + public let workspaces: [NotionWorkspace] + + public init(userID: String?, email: String?, name: String?, workspaces: [NotionWorkspace]) { + self.userID = userID + self.email = email + self.name = name + self.workspaces = workspaces + } + + /// Picks the workspace whose allowance we report: an explicit id when configured, otherwise the first + /// workspace on a plan that actually has an allowance, otherwise the first workspace at all. + public func resolveWorkspace(preferredID: String? = nil) -> NotionWorkspace? { + if let preferredID = Self.normalizeSpaceID(preferredID), + let match = self.workspaces.first(where: { Self.normalizeSpaceID($0.id) == preferredID }) + { + return match + } + // A configured id the account cannot see is almost always a typo. Querying it anyway only + // yields an opaque 403, so fall back to the workspace auto-selection would have picked. + return self.workspaces.first(where: \.mayHaveAllowance) ?? self.workspaces.first + } + + /// Notion accepts both dashed and undashed space ids; normalize to the dashed form the API returns. + static func normalizeSpaceID(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + let compact = trimmed.replacingOccurrences(of: "-", with: "").lowercased() + guard compact.count == 32, compact.allSatisfy(\.isHexDigit) else { return trimmed.lowercased() } + let chars = Array(compact) + let groups = [0..<8, 8..<12, 12..<16, 16..<20, 20..<32] + return groups.map { String(chars[$0]) }.joined(separator: "-") + } +} + +// MARK: - Rate limit payload + +public struct NotionRollingWindow: Decodable, Sendable, Equatable { + public let creditType: String? + public let scope: String? + public let window: String? + public let used: Double? + public let limit: Double? +} + +public struct NotionBillingPeriodWindow: Decodable, Sendable, Equatable { + public let creditType: String? + public let scope: String? + public let cadence: String? + public let used: Double? + public let limit: Double? + public let periodEndMs: Double? +} + +/// Response of `POST /api/v3/getCreditRateLimitStatus`. +public struct NotionCreditRateLimitStatus: Decodable, Sendable, Equatable { + public let status: String? + public let window: NotionRollingWindow? + public let resetsInSeconds: Double? + public let billingPeriodWindow: NotionBillingPeriodWindow? + public let enforcement: String? + + /// Notion returns this when the workspace plan has no allowance to report. + public var isNotApplicable: Bool { + self.status?.lowercased() == "not_applicable" + } +} + +// MARK: - Snapshot + +public struct NotionUsageSnapshot: Sendable { + public let rateLimit: NotionCreditRateLimitStatus + public let workspace: NotionWorkspace? + public let account: NotionAccount? + public let updatedAt: Date + + public init( + rateLimit: NotionCreditRateLimitStatus, + workspace: NotionWorkspace?, + account: NotionAccount?, + updatedAt: Date) + { + self.rateLimit = rateLimit + self.workspace = workspace + self.account = account + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Only report a window we could actually measure. Fabricating 0% for a missing or + // unmeasurable window reads as "plenty of headroom" on a workspace that may be at its cap. + let primary: RateWindow? = self.rateLimit.window.flatMap { window in + Self.percent(used: window.used, limit: window.limit).map { percent in + RateWindow( + usedPercent: percent, + windowMinutes: Self.rollingMinutes(fromWindowToken: window.window), + resetsAt: Self.rollingReset(from: self.rateLimit.resetsInSeconds, now: self.updatedAt), + resetDescription: nil) + } + } + + let secondary: RateWindow? = self.rateLimit.billingPeriodWindow.flatMap { billing in + Self.percent(used: billing.used, limit: billing.limit).map { percent in + RateWindow( + usedPercent: percent, + // Notion reports only `periodEndMs`, so carry the shared monthly sentinel: it is what + // makes `ProviderPaceCapability.calendarMonthResetWindow` match, and resolution then + // replaces it with the real length of the calendar cycle ending at `resetsAt`. + // + // A nil length is not pace-safe on its own. `UsagePace.weekly` substitutes the + // caller's `defaultWindowMinutes` (7 days on every weekly path) rather than skipping + // the window, and the surfaces that do refuse a lengthless window drop it outright — + // so before the sentinel the monthly bar carried no estimate, and removing it now + // would score a billing period against a week. + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: Self.date(fromMilliseconds: billing.periodEndMs), + resetDescription: nil) + } + } + + let identity = ProviderIdentitySnapshot( + providerID: .notion, + accountEmail: self.account?.email, + accountOrganization: self.workspace?.name, + loginMethod: self.workspace?.displayTier, + accountID: self.account?.userID) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: self.updatedAt, + identity: identity) + } + + /// Returns nil when the window carries no measurable allowance. A missing or non-positive limit + /// means "nothing to measure against", not "usage happens to equal this percentage" — the raw + /// credit count is on a different scale and would render as a wildly wrong gauge. + static func percent(used: Double?, limit: Double?) -> Double? { + guard let used, let limit, limit > 0 else { return nil } + return max(0, used / limit * 100) + } + + /// The rolling length, dropped when the token lands on the monthly sentinel. `30d` (and `720h`, + /// `43200m`) parses to exactly `monthlyWindowSentinelMinutes`, which pace matching keys on, so a + /// rolling window carrying one would be resolved as the calendar cycle ending at a reset that is + /// hours away. Reporting no length is wrong by less than mislabeling the window as a billing period. + static func rollingMinutes(fromWindowToken raw: String?) -> Int? { + guard let minutes = Self.minutes(fromWindowToken: raw), + minutes != ProviderPaceCapability.monthlyWindowSentinelMinutes + else { return nil } + return minutes + } + + /// Notion expresses the rolling window as a short token such as `6h`. + static func minutes(fromWindowToken raw: String?) -> Int? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !raw.isEmpty else { + return nil + } + guard let unit = raw.last, let value = Int(raw.dropLast()), value > 0 else { return nil } + switch unit { + case "m": return value + case "h": return value * 60 + case "d": return value * 24 * 60 + case "w": return value * 7 * 24 * 60 + default: return nil + } + } + + /// Zero is a real answer — the window is resetting right now — so only negative values are dropped. + static func rollingReset(from seconds: Double?, now: Date) -> Date? { + guard let seconds, seconds >= 0 else { return nil } + return now.addingTimeInterval(seconds) + } + + static func date(fromMilliseconds raw: Double?) -> Date? { + guard let raw, raw > 0 else { return nil } + return Date(timeIntervalSince1970: raw / 1000) + } +} + +// MARK: - Parsing + +public enum NotionUsageParser { + public static func parseRateLimitStatus(_ data: Data) throws -> NotionCreditRateLimitStatus { + let status: NotionCreditRateLimitStatus + do { + status = try JSONDecoder().decode(NotionCreditRateLimitStatus.self, from: data) + } catch { + throw NotionUsageError.parseFailed(error.localizedDescription) + } + // Every field is optional, so an unrelated 200 body (an error envelope, or a changed shape) + // decodes cleanly into an all-nil status. Refuse it rather than reporting it as 0% used. + guard status.isNotApplicable || status.window != nil || status.billingPeriodWindow != nil else { + throw NotionUsageError.parseFailed("getCreditRateLimitStatus returned no usage windows.") + } + return status + } + + /// Parses `POST /api/v3/getSpaces`, which returns record maps keyed by user id and space id. + public static func parseSpaces(_ data: Data) throws -> NotionAccount { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NotionUsageError.parseFailed("getSpaces response is not a JSON object.") + } + guard let userID = Self.resolveUserID(in: root), let container = root[userID] as? [String: Any] else { + throw NotionUsageError.parseFailed("getSpaces response did not identify a single user.") + } + + var email: String? + var name: String? + if let users = container["notion_user"] as? [String: Any] { + let record = users[userID].flatMap(Self.unwrapRecord) ?? users.values.compactMap(Self.unwrapRecord).first + email = record?["email"] as? String + name = record?["name"] as? String + } + + var workspaces: [NotionWorkspace] = [] + if let spaces = container["space"] as? [String: Any] { + for key in spaces.keys.sorted() { + guard let record = spaces[key].flatMap(Self.unwrapRecord) else { continue } + workspaces.append(NotionWorkspace( + id: (record["id"] as? String) ?? key, + name: record["name"] as? String, + planType: record["plan_type"] as? String, + subscriptionTier: record["subscription_tier"] as? String)) + } + } + + return NotionAccount(userID: userID, email: email, name: name, workspaces: workspaces) + } + + /// The payload is a record map keyed by user id. Pick the key whose own `notion_user` record + /// identifies it, rather than trusting key order, and refuse an ambiguous response outright — + /// binding to the wrong key would report another account's allowance under this account's email. + private static func resolveUserID(in root: [String: Any]) -> String? { + let identified = root.keys.filter { key in + guard let container = root[key] as? [String: Any], + let users = container["notion_user"] as? [String: Any], + let record = users[key].flatMap(Self.unwrapRecord) + else { + return false + } + return record["id"] as? String == key + } + if identified.count == 1 { + return identified.first + } + // Older responses omit the self-identifying id; a single-key payload is still unambiguous. + if identified.isEmpty, root.count == 1 { + return root.keys.first + } + return nil + } + + /// Records arrive as `{"value": {...}}` or, on newer responses, `{"value": {"value": {...}}}`. + private static func unwrapRecord(_ raw: Any) -> [String: Any]? { + guard let outer = raw as? [String: Any] else { return nil } + guard let value = outer["value"] as? [String: Any] else { return outer } + if let inner = value["value"] as? [String: Any] { + return inner + } + return value + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderManifest.swift b/Sources/CodexBarCore/Providers/ProviderManifest.swift index d7771cacd8..48fa949209 100644 --- a/Sources/CodexBarCore/Providers/ProviderManifest.swift +++ b/Sources/CodexBarCore/Providers/ProviderManifest.swift @@ -70,5 +70,6 @@ public enum ProviderManifest { AiAndProviderDescriptor.descriptor, ZoomMateProviderDescriptor.descriptor, XAIProviderDescriptor.descriptor, + NotionProviderDescriptor.descriptor, ] } diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 1f14ab28c5..ef213bc703 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -32,6 +32,7 @@ public struct ProviderSettingsSnapshot: Sendable { amp: AmpProviderSettings? = nil, t3chat: T3ChatProviderSettings? = nil, zoommate: ZoomMateProviderSettings? = nil, + notion: NotionProviderSettings? = nil, devin: DevinProviderSettings? = nil, commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, @@ -68,6 +69,7 @@ public struct ProviderSettingsSnapshot: Sendable { amp: amp, t3chat: t3chat, zoommate: zoommate, + notion: notion, devin: devin, commandcode: commandcode, ollama: ollama, @@ -375,6 +377,22 @@ public struct ProviderSettingsSnapshot: Sendable { } } + /// Deliberately not a `ProviderCookieSettings`: that protocol's initializer takes only the two + /// cookie fields, so conforming would hand every generic construction path a way to build this + /// type with `workspaceID` silently dropped. Build it through the one initializer below. + public struct NotionProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + /// Optional space id override for accounts that belong to more than one workspace. + public let workspaceID: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?, workspaceID: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + self.workspaceID = workspaceID + } + } + public struct DevinProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualBearerToken: String? @@ -515,6 +533,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let amp: AmpProviderSettings? public let t3chat: T3ChatProviderSettings? public let zoommate: ZoomMateProviderSettings? + public let notion: NotionProviderSettings? public let devin: DevinProviderSettings? public let commandcode: CommandCodeProviderSettings? public let ollama: OllamaProviderSettings? @@ -555,6 +574,7 @@ public struct ProviderSettingsSnapshot: Sendable { amp: AmpProviderSettings?, t3chat: T3ChatProviderSettings? = nil, zoommate: ZoomMateProviderSettings? = nil, + notion: NotionProviderSettings? = nil, devin: DevinProviderSettings? = nil, commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings?, @@ -590,6 +610,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.amp = amp self.t3chat = t3chat self.zoommate = zoommate + self.notion = notion self.devin = devin self.commandcode = commandcode self.ollama = ollama @@ -626,6 +647,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case amp(ProviderSettingsSnapshot.AmpProviderSettings) case t3chat(ProviderSettingsSnapshot.T3ChatProviderSettings) case zoommate(ProviderSettingsSnapshot.ZoomMateProviderSettings) + case notion(ProviderSettingsSnapshot.NotionProviderSettings) case devin(ProviderSettingsSnapshot.DevinProviderSettings) case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) @@ -663,6 +685,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings? public var zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings? + public var notion: ProviderSettingsSnapshot.NotionProviderSettings? public var devin: ProviderSettingsSnapshot.DevinProviderSettings? public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? @@ -704,6 +727,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .amp(value): self.amp = value case let .t3chat(value): self.t3chat = value case let .zoommate(value): self.zoommate = value + case let .notion(value): self.notion = value case let .devin(value): self.devin = value case let .commandcode(value): self.commandcode = value case let .ollama(value): self.ollama = value @@ -743,6 +767,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { amp: self.amp, t3chat: self.t3chat, zoommate: self.zoommate, + notion: self.notion, devin: self.devin, commandcode: self.commandcode, ollama: self.ollama, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index e482d6da23..f67e216684 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -69,6 +69,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case aiand case zoommate case xai + case notion } // swiftformat:enable sortDeclarations diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 0e70f874a1..2aa55d5fb1 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -992,6 +992,8 @@ enum WidgetColors { 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 .notion: + Color(red: 51 / 255, green: 126 / 255, blue: 169 / 255) // Notion accent blue case .ollama: Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal case .synthetic: diff --git a/Tests/CodexBarTests/Fixtures/Providers/Notion/get-credit-rate-limit-status.json b/Tests/CodexBarTests/Fixtures/Providers/Notion/get-credit-rate-limit-status.json new file mode 100644 index 0000000000..b04aedffb4 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/Notion/get-credit-rate-limit-status.json @@ -0,0 +1,20 @@ +{ + "status": "within_limit", + "window": { + "creditType": "basic_ai_credits", + "scope": "per_user", + "window": "6h", + "used": 42.5, + "limit": 100 + }, + "resetsInSeconds": 12600, + "billingPeriodWindow": { + "creditType": "basic_ai_credits", + "scope": "per_user", + "cadence": "billing_period", + "used": 18.0, + "limit": 100, + "periodEndMs": 1788000000000 + }, + "enforcement": "preview" +} diff --git a/Tests/CodexBarTests/Fixtures/Providers/Notion/get-spaces.json b/Tests/CodexBarTests/Fixtures/Providers/Notion/get-spaces.json new file mode 100644 index 0000000000..5b0ba621e4 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/Notion/get-spaces.json @@ -0,0 +1,37 @@ +{ + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": { + "notion_user": { + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": { + "value": { + "value": { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "email": "person@example.com", + "name": "Example Person" + } + } + } + }, + "space": { + "66666666-7777-8888-9999-aaaaaaaaaaaa": { + "value": { + "value": { + "id": "66666666-7777-8888-9999-aaaaaaaaaaaa", + "name": "Personal", + "plan_type": "personal", + "subscription_tier": "free" + } + } + }, + "11111111-2222-3333-4444-555555555555": { + "value": { + "value": { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Acme", + "plan_type": "team", + "subscription_tier": "business" + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/NotionMenuCardModelTests.swift b/Tests/CodexBarTests/NotionMenuCardModelTests.swift new file mode 100644 index 0000000000..f6c1d45192 --- /dev/null +++ b/Tests/CodexBarTests/NotionMenuCardModelTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +/// The card is where the calendar-cycle resolution has to land. `ProviderPaceCapability` exposing the +/// right duration is not enough on its own: the card is also handed a precomputed `weeklyPace`, and +/// preferring it would score the billing period as a flat 30 days while the CLI scored the real month. +struct NotionMenuCardModelTests { + /// 2026-02-08T00:00:00Z — 21 days before the cycle ends, 7 days into a 28-day February. + private static let now = Date(timeIntervalSince1970: 1_770_508_800) + /// 2026-03-01T00:00:00Z. + private static let periodEnd = Date(timeIntervalSince1970: 1_772_323_200) + + private static func snapshot() -> UsageSnapshot { + NotionUsageSnapshot( + rateLimit: NotionCreditRateLimitStatus( + status: "enforced", + window: NotionRollingWindow( + creditType: nil, + scope: nil, + window: "6h", + used: 50, + limit: 100), + resetsInSeconds: 3600, + billingPeriodWindow: NotionBillingPeriodWindow( + creditType: nil, + scope: nil, + cadence: nil, + used: 40, + limit: 100, + periodEndMs: self.periodEnd.timeIntervalSince1970 * 1000), + enforcement: nil), + workspace: nil, + account: nil, + updatedAt: self.now) + .toUsageSnapshot() + } + + private static func model(weeklyPace: UsagePace?) throws -> UsageMenuCardView.Model { + let metadata = try #require(ProviderDefaults.metadata[.notion]) + return UsageMenuCardView.Model.make(.init( + provider: .notion, + metadata: metadata, + snapshot: Self.snapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + weeklyPace: weeklyPace, + now: Self.now)) + } + + @Test + func `monthly bar paces against February, not a flat thirty days`() throws { + let monthly = try #require(Self.snapshot().secondary) + // What the store hands the card: a pace measured against the raw 30-day sentinel. Reusing it + // would report 30% expected (9 of 30 days) instead of 25% (7 of February's 28). + let sentinelPace = try #require(UsagePace.weekly( + window: monthly, + now: Self.now, + defaultWindowMinutes: 10080)) + #expect((sentinelPace.expectedUsedPercent * 10).rounded() / 10 == 30) + + let metric = try #require(try Self.model(weeklyPace: sentinelPace).metrics.first { $0.id == "secondary" }) + + #expect(metric.pacePercent == 25) + #expect(metric.detailLeftText == "15% in deficit") + } + + @Test + func `monthly pace is identical whether or not a precomputed pace is supplied`() throws { + let monthly = try #require(Self.snapshot().secondary) + let sentinelPace = UsagePace.weekly(window: monthly, now: Self.now, defaultWindowMinutes: 10080) + + let withPace = try #require(try Self.model(weeklyPace: sentinelPace).metrics.first { $0.id == "secondary" }) + let withoutPace = try #require(try Self.model(weeklyPace: nil).metrics.first { $0.id == "secondary" }) + + #expect(withPace.pacePercent == withoutPace.pacePercent) + #expect(withPace.detailLeftText == withoutPace.detailLeftText) + } + + @Test + func `rolling bar carries a session pace`() throws { + let metric = try #require(try Self.model(weeklyPace: nil).metrics.first { $0.id == "primary" }) + + // Five of the six rolling hours elapsed against 50% used. + #expect(metric.detailLeftText == "33% in reserve") + } +} diff --git a/Tests/CodexBarTests/NotionSessionStoreTests.swift b/Tests/CodexBarTests/NotionSessionStoreTests.swift new file mode 100644 index 0000000000..5291dd44dc --- /dev/null +++ b/Tests/CodexBarTests/NotionSessionStoreTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) + +struct NotionSessionStoreTests { + @Test + func `session files are owner only and round trip`() async throws { + let (directory, fileURL) = try Self.makeSessionLocation() + defer { try? FileManager.default.removeItem(at: directory) } + let writer = NotionSessionStore(fileURL: fileURL) + await writer.setSession(tokenV2: "stored-token", sourceLabel: "Chrome") + + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + + let reader = NotionSessionStore(fileURL: fileURL) + let session = try #require(await reader.getSession()) + #expect(session.tokenV2 == "stored-token") + #expect(session.cookieHeader == "token_v2=stored-token") + #expect(session.sourceLabel == "Chrome") + } + + @Test + func `loading repairs legacy session file permissions`() async throws { + let (directory, fileURL) = try Self.makeSessionLocation() + defer { try? FileManager.default.removeItem(at: directory) } + let writer = NotionSessionStore(fileURL: fileURL) + await writer.setSession(tokenV2: "legacy-token", sourceLabel: "Chrome") + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: fileURL.path) + + let reader = NotionSessionStore(fileURL: fileURL) + #expect(await reader.getSession()?.tokenV2 == "legacy-token") + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + } + + private static func makeSessionLocation() throws -> (URL, URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-notion-session-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return (directory, directory.appendingPathComponent("notion-session.json")) + } +} + +#endif diff --git a/Tests/CodexBarTests/NotionUsageFetcherTests.swift b/Tests/CodexBarTests/NotionUsageFetcherTests.swift new file mode 100644 index 0000000000..368fb714bd --- /dev/null +++ b/Tests/CodexBarTests/NotionUsageFetcherTests.swift @@ -0,0 +1,426 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct NotionUsageFetcherTests { + private static let now = Date(timeIntervalSince1970: 1_785_600_000) + /// Billing period end reported by `getCreditRateLimitStatus` (milliseconds since epoch). + private static let periodEndMilliseconds = 1_788_000_000_000 + private static let periodEndSeconds = Self.periodEndMilliseconds / 1000 + private static let rollingResetSeconds = 12600 + + private static let businessSpaceID = "11111111-2222-3333-4444-555555555555" + private static let personalSpaceID = "66666666-7777-8888-9999-aaaaaaaaaaaa" + private static let userID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + /// Older responses wrap each record once; newer ones wrap twice. Both shapes must parse. + private static let singlyWrappedSpacesResponse = """ + {"\(Self.userID)":{ + "notion_user":{"\(Self.userID)":{"value":{ + "id":"\(Self.userID)","email":"legacy@example.com","name":"Legacy Person"}}}, + "space":{ + "\(Self.businessSpaceID)":{"value":{ + "id":"\(Self.businessSpaceID)","name":"Acme","plan_type":"team","subscription_tier":"business"}}}}} + """ + + private static func rateLimitStatus() throws -> NotionCreditRateLimitStatus { + try NotionUsageParser.parseRateLimitStatus(self.fixtureData("get-credit-rate-limit-status")) + } + + private static func account() throws -> NotionAccount { + try NotionUsageParser.parseSpaces(self.fixtureData("get-spaces")) + } + + private static func fixtureData(_ name: String) throws -> Data { + let url = try #require(Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Fixtures/Providers/Notion")) + return try Data(contentsOf: url) + } + + @Test + func `parses credit rate limit status`() throws { + let status = try Self.rateLimitStatus() + + #expect(status.status == "within_limit") + #expect(status.enforcement == "preview") + #expect(status.window?.window == "6h") + #expect(status.window?.used == 42.5) + #expect(status.window?.limit == 100) + #expect(status.resetsInSeconds == 12600) + #expect(status.billingPeriodWindow?.used == 18.0) + #expect(status.billingPeriodWindow?.cadence == "billing_period") + #expect(status.isNotApplicable == false) + } + + @Test + func `maps rolling and billing windows to usage snapshot`() throws { + let workspace = NotionWorkspace( + id: Self.businessSpaceID, + name: "Acme", + planType: "team", + subscriptionTier: "business") + let account = NotionAccount( + userID: Self.userID, + email: "person@example.com", + name: "Example Person", + workspaces: [workspace]) + let usage = try NotionUsageSnapshot( + rateLimit: Self.rateLimitStatus(), + workspace: workspace, + account: account, + updatedAt: Self.now).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 42.5) + #expect(usage.primary?.windowMinutes == 360) + #expect( + usage.primary?.resetsAt.map { Int($0.timeIntervalSince1970) } + == Int(Self.now.timeIntervalSince1970) + Self.rollingResetSeconds) + #expect(usage.secondary?.usedPercent == 18.0) + // The monthly sentinel, not nil: it is what makes the provider's pace capability match, which is + // what swaps in the real calendar cycle ending at `resetsAt`. + #expect(usage.secondary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) + #expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.periodEndSeconds) + #expect(usage.identity?.providerID == .notion) + #expect(usage.identity?.accountEmail == "person@example.com") + #expect(usage.identity?.accountOrganization == "Acme") + #expect(usage.identity?.loginMethod == "Business") + } + + @Test + func `flags workspaces without an allowance`() throws { + let status = try NotionUsageParser.parseRateLimitStatus(Data(#"{"status":"not_applicable"}"#.utf8)) + + #expect(status.isNotApplicable) + #expect(status.window == nil) + #expect(status.billingPeriodWindow == nil) + } + + @Test + func `parses spaces payload into account and workspaces`() throws { + let account = try Self.account() + + #expect(account.userID == Self.userID) + #expect(account.email == "person@example.com") + #expect(account.name == "Example Person") + #expect(account.workspaces.count == 2) + #expect(account.workspaces.contains { $0.id == Self.businessSpaceID && $0.name == "Acme" }) + } + + @Test + func `prefers a workspace whose plan carries an allowance`() throws { + let account = try Self.account() + + // The personal/free space sorts first by id but reports `not_applicable`, so it must not win. + #expect(account.resolveWorkspace()?.id == Self.businessSpaceID) + } + + @Test + func `honours a configured workspace id in either uuid form`() throws { + let account = try Self.account() + let undashed = Self.personalSpaceID.replacingOccurrences(of: "-", with: "") + + #expect(account.resolveWorkspace(preferredID: Self.personalSpaceID)?.id == Self.personalSpaceID) + #expect(account.resolveWorkspace(preferredID: undashed)?.id == Self.personalSpaceID) + } + + @Test + func `falls back to the first workspace when none carries an allowance`() { + let account = NotionAccount( + userID: Self.userID, + email: nil, + name: nil, + workspaces: [ + NotionWorkspace( + id: Self.personalSpaceID, + name: "Personal", + planType: "personal", + subscriptionTier: "free"), + ]) + + #expect(account.resolveWorkspace()?.id == Self.personalSpaceID) + } + + @Test + func `converts notion window tokens to minutes`() { + #expect(NotionUsageSnapshot.minutes(fromWindowToken: "6h") == 360) + #expect(NotionUsageSnapshot.minutes(fromWindowToken: "30m") == 30) + #expect(NotionUsageSnapshot.minutes(fromWindowToken: "7d") == 10080) + #expect(NotionUsageSnapshot.minutes(fromWindowToken: "1w") == 10080) + #expect(NotionUsageSnapshot.minutes(fromWindowToken: "weekly") == nil) + #expect(NotionUsageSnapshot.minutes(fromWindowToken: nil) == nil) + } + + @Test + func `scales usage against the reported limit`() { + #expect(NotionUsageSnapshot.percent(used: 25, limit: 50) == 50) + #expect(NotionUsageSnapshot.percent(used: 42.5, limit: 100) == 42.5) + // Over-quota values are preserved; display clamping happens downstream. + #expect(NotionUsageSnapshot.percent(used: 120, limit: 100) == 120) + // Without a usable limit there is nothing to measure against, so no percentage is invented. + #expect(NotionUsageSnapshot.percent(used: nil, limit: 100) == nil) + #expect(NotionUsageSnapshot.percent(used: 42, limit: 0) == nil) + #expect(NotionUsageSnapshot.percent(used: 42, limit: nil) == nil) + } + + @Test + func `omits a window that carries no measurable allowance`() { + let status = NotionCreditRateLimitStatus( + status: "within_limit", + window: NotionRollingWindow( + creditType: "basic_ai_credits", + scope: "per_user", + window: "6h", + used: 42, + limit: nil), + resetsInSeconds: 60, + billingPeriodWindow: nil, + enforcement: "preview") + let usage = NotionUsageSnapshot( + rateLimit: status, + workspace: nil, + account: nil, + updatedAt: Self.now).toUsageSnapshot() + + // A fabricated 0% here would read as "plenty of headroom" on a workspace that may be capped. + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + } + + @Test + func `rejects a response that carries no usage windows`() { + let body = Data(#"{"errorId":"abc","name":"UnauthorizedError"}"#.utf8) + + #expect(throws: NotionUsageError.parseFailed("getCreditRateLimitStatus returned no usage windows.")) { + try NotionUsageParser.parseRateLimitStatus(body) + } + } + + @Test + func `keeps a reset that lands exactly now`() { + #expect(NotionUsageSnapshot.rollingReset(from: 0, now: Self.now) == Self.now) + #expect(NotionUsageSnapshot.rollingReset(from: -1, now: Self.now) == nil) + } + + @Test + func `builds a request context from a manual cookie header`() { + let context = NotionUsageFetcher.requestContext(from: "token_v2=abc; notion_user_id=def") + + #expect(context?.cookieHeader.contains("token_v2=abc") == true) + #expect(NotionUsageFetcher.requestContext(from: " ") == nil) + } + + @Test + func `names a manually pasted bare token v2 value`() { + let context = NotionUsageFetcher.requestContext(from: "bare-token-value") + + #expect(context?.cookieHeader == "token_v2=bare-token-value") + } + + @Test + func `defaults automatic imports to Chrome only`() { + #if os(macOS) + #expect(NotionProviderDescriptor.descriptor.metadata.browserCookieOrder == [.chrome]) + #else + #expect(NotionProviderDescriptor.descriptor.metadata.browserCookieOrder == nil) + #endif + } + + @Test + func `parses singly wrapped records`() throws { + let account = try NotionUsageParser.parseSpaces(Data(Self.singlyWrappedSpacesResponse.utf8)) + + #expect(account.email == "legacy@example.com") + #expect(account.workspaces.count == 1) + #expect(account.workspaces.first?.name == "Acme") + } + + @Test + func `refuses a spaces payload naming more than one user`() { + let second = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" + let body = """ + {"\(Self.userID)":{"notion_user":{"\(Self.userID)":{"value":{"value":{"id":"\(Self.userID)"}}}}}, + "\(second)":{"notion_user":{"\(second)":{"value":{"value":{"id":"\(second)"}}}}}} + """ + + // Binding to whichever key sorts first would report the wrong account's allowance. + #expect(throws: NotionUsageError.parseFailed("getSpaces response did not identify a single user.")) { + try NotionUsageParser.parseSpaces(Data(body.utf8)) + } + } + + @Test + func `falls back to auto selection when the configured workspace id is unknown`() throws { + let account = try Self.account() + + // A typo'd id would otherwise be queried anyway and answered with an opaque 403. + #expect(account.resolveWorkspace(preferredID: "00000000-0000-0000-0000-000000000000")?.id + == Self.businessSpaceID) + } + + // MARK: - Transport-backed behaviour + + private struct StubResponse: Sendable { + let statusCode: Int + let body: Data + } + + private struct StubTransport: ProviderHTTPTransport { + let spaces: StubResponse + let rateLimit: StubResponse + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let stub = (request.url?.path.hasSuffix("getSpaces") ?? false) ? self.spaces : self.rateLimit + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: stub.statusCode, + httpVersion: nil, + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + return (stub.body, response) + } + } + + private static func fetchUsage(transport: StubTransport, preferredSpaceID: String? = nil) async throws + -> NotionUsageSnapshot + { + try await NotionUsageFetcher.fetchUsage( + context: NotionUsageFetcher.RequestContext(cookieHeader: "token_v2=abc"), + preferredSpaceID: preferredSpaceID, + timeout: 5, + now: self.now, + transport: transport) + } + + @Test + func `maps an unauthorized response to invalid credentials`() async throws { + let transport = try StubTransport( + spaces: StubResponse(statusCode: 401, body: Data("{}".utf8)), + rateLimit: StubResponse(statusCode: 200, body: Self.fixtureData("get-credit-rate-limit-status"))) + + await #expect(throws: NotionUsageError.invalidCredentials) { + try await Self.fetchUsage(transport: transport) + } + } + + @Test + func `maps a server error to an api error`() async throws { + let transport = try StubTransport( + spaces: StubResponse(statusCode: 200, body: Self.fixtureData("get-spaces")), + rateLimit: StubResponse(statusCode: 500, body: Data("nope".utf8))) + + await #expect(throws: NotionUsageError.apiError("HTTP 500 from getCreditRateLimitStatus")) { + try await Self.fetchUsage(transport: transport) + } + } + + @Test + func `throws when the resolved workspace has no allowance`() async throws { + let transport = try StubTransport( + spaces: StubResponse(statusCode: 200, body: Self.fixtureData("get-spaces")), + rateLimit: StubResponse(statusCode: 200, body: Data(#"{"status":"not_applicable"}"#.utf8))) + + await #expect(throws: NotionUsageError.allowanceNotApplicable(workspace: "Personal")) { + try await Self.fetchUsage(transport: transport, preferredSpaceID: Self.personalSpaceID) + } + } + + @Test + func `returns a snapshot for a workspace that carries an allowance`() async throws { + let transport = try StubTransport( + spaces: StubResponse(statusCode: 200, body: Self.fixtureData("get-spaces")), + rateLimit: StubResponse(statusCode: 200, body: Self.fixtureData("get-credit-rate-limit-status"))) + + let snapshot = try await Self.fetchUsage(transport: transport) + + #expect(snapshot.workspace?.id == Self.businessSpaceID) + #expect(snapshot.account?.email == "person@example.com") + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 42.5) + } + + /// Midnight UTC on the given day, so a cycle length is exactly a whole number of days. + private static func utcDate(year: Int, month: Int, day: Int) throws -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + return try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day))) + } + + private static func monthlyWindow(usedPercent: Double, resetsAt: Date) -> RateWindow { + RateWindow( + usedPercent: usedPercent, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: resetsAt, + resetDescription: nil) + } + + @Test + func `scores the billing window against the real calendar month`() throws { + // The sentinel is a placeholder, not a duration: resolution has to yield the true length of the + // cycle ending at the reset. Asserting only the capability booleans would stay green if the + // descriptor were swapped for a plain 30-day capability, which is the regression to catch. + let pace = ProviderDescriptorRegistry.descriptor(for: .notion).pace + let februaryCycle = try Self.monthlyWindow( + usedPercent: 18, + resetsAt: Self.utcDate(year: 2026, month: 3, day: 1)) + let mayCycle = try Self.monthlyWindow( + usedPercent: 18, + resetsAt: Self.utcDate(year: 2026, month: 6, day: 1)) + + #expect(pace.resolvedResetWindowForPace(februaryCycle).windowMinutes == 28 * 24 * 60) + #expect(pace.resolvedResetWindowForPace(mayCycle).windowMinutes == 31 * 24 * 60) + #expect(pace.resolvedResetWindowForPace(februaryCycle).resetsAt == februaryCycle.resetsAt) + #expect(pace.resolvedResetWindowForPace(februaryCycle).usedPercent == februaryCycle.usedPercent) + } + + @Test + func `a billing window with no length is scored against the caller's default`() throws { + // A nil length is not pace-safe on its own: `UsagePace.weekly` substitutes `defaultWindowMinutes` + // rather than skipping the window, so dropping the sentinel would score a month against a week. + let resetsAt = try Self.utcDate(year: 2026, month: 3, day: 1) + let now = resetsAt.addingTimeInterval(-3 * 24 * 60 * 60) + let lengthless = RateWindow(usedPercent: 37, windowMinutes: nil, resetsAt: resetsAt, resetDescription: nil) + + let weekScored = try #require(UsagePace.weekly(window: lengthless, now: now, defaultWindowMinutes: 10080)) + // Four of seven days elapsed against a week that is really a month. + #expect((weekScored.expectedUsedPercent * 10).rounded() / 10 == 57.1) + + let resolved = ProviderDescriptorRegistry.descriptor(for: .notion).pace + .resolvedResetWindowForPace(Self.monthlyWindow(usedPercent: 37, resetsAt: resetsAt)) + let cycleScored = try #require(UsagePace.weekly(window: resolved, now: now, defaultWindowMinutes: 10080)) + // Twenty-five of February's twenty-eight days elapsed. + #expect((cycleScored.expectedUsedPercent * 10).rounded() / 10 == 89.3) + } + + @Test + func `does not treat the rolling window as a monthly one`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .notion) + let rolling = RateWindow( + usedPercent: 42.5, + windowMinutes: 360, + resetsAt: Self.now.addingTimeInterval(3600), + resetDescription: nil) + + #expect(!descriptor.pace.usesInferredMonthlyDuration(window: rolling)) + } + + @Test + func `drops a rolling length that collides with the monthly sentinel`() { + // `30d`, `720h` and `43200m` all parse to the monthly sentinel, which pace matching keys on, so a + // rolling window carrying one would be resolved as a calendar cycle ending hours from now. + #expect(NotionUsageSnapshot.minutes(fromWindowToken: "30d") + == ProviderPaceCapability.monthlyWindowSentinelMinutes) + #expect(NotionUsageSnapshot.rollingMinutes(fromWindowToken: "30d") == nil) + #expect(NotionUsageSnapshot.rollingMinutes(fromWindowToken: "720h") == nil) + #expect(NotionUsageSnapshot.rollingMinutes(fromWindowToken: "43200m") == nil) + #expect(NotionUsageSnapshot.rollingMinutes(fromWindowToken: "6h") == 360) + } +} diff --git a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift index 43a460b666..5bebc335a8 100644 --- a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift +++ b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift @@ -117,7 +117,7 @@ struct ProviderPaceCapabilityTests { && timeUntilReset <= TimeInterval(windowMinutes) * 60 case .kimi: return window.windowMinutes == self.weeklyWindowMinutes - case .alibaba, .alibabatokenplan, .amp, .doubao, .mimo, .opencodego, .stepfun: + case .alibaba, .alibabatokenplan, .amp, .doubao, .mimo, .notion, .opencodego, .stepfun: return window.windowMinutes == self.monthlyWindowSentinelMinutes default: return false @@ -131,7 +131,7 @@ struct ProviderPaceCapabilityTests { switch provider { case .copilot: window.windowMinutes == nil - case .alibaba, .alibabatokenplan, .amp, .doubao, .mimo, .opencodego, .stepfun: + case .alibaba, .alibabatokenplan, .amp, .doubao, .mimo, .notion, .opencodego, .stepfun: window.windowMinutes == self.monthlyWindowSentinelMinutes default: false diff --git a/docs/configuration.md b/docs/configuration.md index fb509b7c91..dea763d6c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,7 +126,7 @@ All provider fields are optional unless noted. - `cookieHeader`: raw cookie header value (e.g. `key=value; other=...`). - `region`: provider-specific region (e.g. `zai`, `minimax`). - `workspaceID`: provider-specific workspace/deployment/project ID (e.g. Azure OpenAI deployment, OpenAI API project, - `opencode`). + `opencode`, Notion space). - `tokenAccounts`: multi-account tokens for providers in `TokenAccountSupportCatalog`. - `claudeSwapEnabled`: allow the Claude provider to read account usage from claude-swap. - `claudeSwapExecutablePath`: path to the `cswap` executable. @@ -270,7 +270,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`, `qwencloud`, `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`, `xai`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `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`, `xai`, `notion`. ## 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 2bb7f382b3..dc5fff7f43 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 @@

- 66 providers,{mobileBreak}one menu bar + 67 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 28cce05624..eade804c20 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 66 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 67 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 66 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 67 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/steipete/CodexBar diff --git a/docs/notion.md b/docs/notion.md new file mode 100644 index 0000000000..5ac2af99bb --- /dev/null +++ b/docs/notion.md @@ -0,0 +1,143 @@ +--- +summary: "Notion AI provider auth, usage endpoint, and allowance windows." +read_when: + - Adding or modifying the Notion AI provider + - Debugging Notion cookie import or usage parsing + - Explaining Notion AI setup +--- + +# Notion AI Provider + +The Notion AI provider tracks the **Rolling** (6-hour) and **Monthly** (billing period) usage allowance +windows that Notion shows in **Settings → Notion AI → Usage**. + +Notion begins enforcing the AI usage allowance on **August 3, 2026**. Before that date the same endpoint +reports `"enforcement": "preview"` while still returning real usage numbers, so the gauges are accurate +either way. + +> **Unsupported integration:** CodexBar uses Notion's internal, cookie-authenticated `/api/v3` endpoints. +> These endpoints are not a supported public API and may change or break without notice. + +## Requirements + +The usage allowance only exists on **Business** and **Enterprise** workspaces. Free, Plus, and personal +workspaces make the endpoint answer `{"status":"not_applicable"}`, and CodexBar surfaces that as a clear +provider error rather than an empty gauge. + +## Setup + +### Automatic (recommended) + +1. Sign in to Notion in Chrome. +2. Enable **Notion AI** in **Settings → Providers**. + +CodexBar imports your browser session cookie automatically and sends it only to `https://app.notion.com`. +The import requires the `token_v2` session cookie; a browser profile that has Notion cookies but no +`token_v2` is skipped rather than used for a request that would fail with 401. + +**Note**: Automatic import defaults to Chrome only to avoid probing unrelated browser stores. Callers using +the shared browser-cookie plumbing can still supply an explicit browser list. Chrome cookie decryption may +require macOS Keychain approval. + +### Manual + +Set **Cookie source** to **Manual** in the Notion AI provider settings, then paste one of: + +- The bare `token_v2` value, +- A `Cookie: ...` header value copied from a browser network request to `app.notion.com`, or +- A full `curl` command captured from the Notion web app (all `-H` flags are parsed; only the `Cookie` + header and a fixed set of safe request headers are forwarded). + +To capture the cookie manually: + +1. Open [app.notion.com](https://app.notion.com/) in your browser. +2. Open Developer Tools → Network tab. +3. Open **Settings → Notion AI → Usage** and find a `getCreditRateLimitStatus` request. +4. Right-click → Copy → Copy as cURL. +5. Paste the full `curl` command into the **Notion cookie** field in CodexBar settings. + +### Workspace selection + +Accounts that belong to more than one workspace default to the first workspace on a Business or Enterprise +plan. To pin a specific one, set **Workspace ID** in the provider settings, or `workspaceID` on the +`notion` entry in `config.json`. Both dashed and undashed UUID forms are accepted. + +Notion does not support a standalone environment variable or a `--cookie` CLI flag for this provider. The +only manual paths are the Settings fields above and `config.json`. + +## Data Source + +CodexBar sends two POST requests per refresh, both to `https://app.notion.com`: + +1. `/api/v3/getSpaces` — resolves the signed-in user (email, name) and the workspaces the account can see, + including each workspace's `plan_type` and `subscription_tier`. This is what makes automatic workspace + selection and the account identity line possible. +2. `/api/v3/getCreditRateLimitStatus` with `{"spaceId": ""}` — the allowance itself. + +The rate-limit response looks like this: + +```json +{ + "status": "within_limit", + "window": { "creditType": "basic_ai_credits", "scope": "per_user", "window": "6h", "used": 42.5, "limit": 100 }, + "resetsInSeconds": 12600, + "billingPeriodWindow": { + "creditType": "basic_ai_credits", + "scope": "per_user", + "cadence": "billing_period", + "used": 18.0, + "limit": 100, + "periodEndMs": 1788000000000 + }, + "enforcement": "preview" +} +``` + +## Mapping + +| CodexBar window | Notion field | Notes | +| --- | --- | --- | +| Rolling (primary) | `window.used` / `window.limit` | `window.window` (`6h`) sets the window length; `resetsInSeconds` sets the reset time. | +| Monthly (secondary) | `billingPeriodWindow.used` / `.limit` | `periodEndMs` sets the reset time, and the window length is the calendar month ending there. | +| Identity | `getSpaces` | Account email, workspace name, and the capitalized subscription tier. | + +Usage is reported against the returned `limit` rather than assumed to be a percentage, so a future +non-100 limit keeps working. Over-quota values are preserved rather than clamped; display clamping happens +downstream. + +Custom Agents and Workers are **not** covered by this allowance — Notion meters those with Notion credits +(`getAIUsageEligibilityV2`), which this provider does not read. + +### Pace + +Both bars carry an expected-usage estimate on the card and in `codexbar usage`, reading `n% in deficit` +when you are ahead of an even burn and `n% in reserve` when behind. + +The monthly estimate needs a window length, and Notion reports only `periodEndMs`. The snapshot +therefore carries the shared monthly sentinel, which is what makes the provider's +`ProviderPaceCapability` match; resolution then substitutes the real calendar month ending at the reset +— not a flat 30 days, which would misstate expected usage in February and in any 31-day cycle. + +A `nil` length is not a safe alternative. `UsagePace.weekly` substitutes the caller's +`defaultWindowMinutes` (7 days on every weekly path) rather than skipping the window, so a lengthless +billing period would be scored against a week; the surfaces that do refuse a lengthless window drop it +from pacing entirely instead. Every path that scores one of these windows — the card, the menu-bar pace +token, predictive pace warnings, and the CLI — resolves the sentinel first, so they cannot disagree. + +The rolling window is paced as a session window. Its length comes from the API's `6h` token rather than +being fixed, so only a window no longer than six hours is paced this way; anything longer is a billing +period and goes through the reset-window path instead. + +## Status + +Notion publishes a status page at ; CodexBar links to it but does not poll +components. + +## Troubleshooting + +- **"Notion AI usage allowance is not tracked for …"** — the selected workspace is not on a Business or + Enterprise plan. Set **Workspace ID** to a workspace that is. +- **"Notion session cookie is invalid or expired"** — sign in to Notion again, or re-capture the manual + cookie. +- **"No Notion cookies found"** — the browser profile has no `token_v2` cookie for Notion. Sign in, or + switch to a manual cookie. diff --git a/docs/providers.md b/docs/providers.md index 49255e4e75..3b3adceb6c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 66 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 67 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) @@ -96,6 +96,7 @@ scan fails, while provider/account configuration changes replace obsolete result | ai& | API key from config/env → 30-day organization spend summed from the request logs API (`api`). | | xAI | Management key + team ID from config/env → prepaid balance and 30-day daily spend from the Management API (`api`). | | Zed | Zed editor Keychain session → `cloud.zed.dev/client/users/me` for plan and quota data (`local`). | +| Notion AI | Browser cookies → workspace resolution and the AI usage allowance API (`web`). | ## Codex - App Auto: OAuth API first; falls back to CLI only when OAuth credentials are missing or auth/refresh is invalid. @@ -603,4 +604,13 @@ provider-specific cookie validation, endpoints, login detection, and error trans - Prepaid money is not a quota; no session or weekly meters are synthesized. - Details: `docs/xai.md`. +## Notion AI +- Browser cookies (auto-import or manual Cookie header/cURL capture) for `app.notion.com`; the `token_v2` session cookie is required. +- `POST /api/v3/getSpaces` resolves the account and its workspaces, then `POST /api/v3/getCreditRateLimitStatus` returns the allowance for the selected space. +- Shows the Rolling 6-hour window and the Monthly billing-period window that Notion renders in Settings > Notion AI > Usage. Usage is scaled against the returned `limit` rather than assumed to be a percentage. +- Only Business and Enterprise workspaces carry an allowance; anything else answers `not_applicable` and surfaces as a provider error instead of an empty gauge. Multi-workspace accounts default to the first eligible workspace and can pin one with `workspaceID`. +- Notion credits (Custom Agents, Workers) are a separate meter and are not read. +- Status: `https://status.notion.so/` (link only). +- Details: `docs/notion.md`. + See also: `docs/provider.md` for architecture notes. diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index c99368fe05..17616e9e4f 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 66 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", - "meta.ogDescription": "Track usage windows, credits, and resets across 66 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 67 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", + "meta.ogDescription": "Track usage windows, credits, and resets across 67 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": "66 providers,{mobileBreak}one menu bar", + "providers.title": "67 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 菜单栏应用程序,可跟踪 66 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", - "meta.ogDescription": "从您的 macOS 菜单栏跟踪 66 个 AI 编码提供商的使用窗口、积分和重置。", + "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 67 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", + "meta.ogDescription": "从您的 macOS 菜单栏跟踪 67 个 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": "66 个提供商,{mobileBreak}一个菜单栏", + "providers.title": "67 个提供商,{mobileBreak}一个菜单栏", "providers.description": "受欢迎的提供商成为状态项目,具有自己的使用窗口、重置倒计时、图表和提供商菜单。", "providers.yourProvider": "您的提供商", "providers.authoringGuide": "创作指南", @@ -378,8 +378,8 @@ export const localeMessages = { }, "zh-TW": { "meta.title": "CodexBar — 功能表列中的每個 AI 編碼限制", - "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 66 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", - "meta.ogDescription": "從您的 macOS 功能表列追蹤 66 個 AI 編碼提供者的使用視窗、積分和重設。", + "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 67 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", + "meta.ogDescription": "從您的 macOS 功能表列追蹤 67 個 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": "66 個提供者,{mobileBreak}一個選單列", + "providers.title": "67 個提供者,{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 など、66 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", - "meta.ogDescription": "macOS メニュー バーから、66 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", + "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、67 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", + "meta.ogDescription": "macOS メニュー バーから、67 の 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": "66 プロバイダー、{mobileBreak}1つのメニューバー", + "providers.title": "67 プロバイダー、{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 66 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 66 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 67 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 67 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": "66 proveedores,{mobileBreak}una barra de menús", + "providers.title": "67 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 66 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", - "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 66 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 67 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", + "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 67 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": "66 provedores,{mobileBreak}uma barra de menu", + "providers.title": "67 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 등 66개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", - "meta.ogDescription": "macOS 메뉴 표시줄에서 66개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", + "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 67개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", + "meta.ogDescription": "macOS 메뉴 표시줄에서 67개 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": "66개 제공자,{mobileBreak}하나의 메뉴 막대", + "providers.title": "67개 제공자,{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 66 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", - "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 66 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", + "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 67 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", + "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 67 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": "66 Provider,{mobileBreak}eine Menüleiste", + "providers.title": "67 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 66 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 66 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 67 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 67 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": "66 fournisseurs,{mobileBreak}une barre des menus", + "providers.title": "67 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 صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 66 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", - "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 66 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", + "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 67 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", + "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 67 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة 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": "66 مزودًا،{mobileBreak}شريط قوائم واحد", + "providers.title": "67 مزودًا،{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 66 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", - "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 66 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 67 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", + "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 67 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": "66 provider,{mobileBreak}una barra dei menu", + "providers.title": "67 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 66 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 66 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 67 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 67 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": "66 nhà cung cấp,{mobileBreak}một thanh menu", + "providers.title": "67 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 66 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", - "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 66 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 67 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", + "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 67 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": "66 providers,{mobileBreak}één menubalk", + "providers.title": "67 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 66 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", - "meta.ogDescription": "macOS menü çubuğunu kullanarak 66 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 67 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", + "meta.ogDescription": "macOS menü çubuğunu kullanarak 67 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": "66 sağlayıcı,{mobileBreak}bir menü çubuğu", + "providers.title": "67 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 на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 66 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", - "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 66 постачальників кодування ШІ за допомогою панелі меню macOS.", + "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 67 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", + "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 67 постачальників кодування ШІ за допомогою панелі меню 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": "66 провайдерів,{mobileBreak}одна панель меню", + "providers.title": "67 провайдерів,{mobileBreak}одна панель меню", "providers.description": "Популярні постачальники стають елементами статусу з власними вікнами використання, скиданням зворотного відліку, діаграмами та меню постачальників.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Авторський посібник", @@ -2198,8 +2198,8 @@ export const localeMessages = { }, "ru": { "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню", - "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 66 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", - "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 66 AI-провайдеров для кодинга прямо из строки меню macOS.", + "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 67 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", + "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 67 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": "66 провайдеров,{mobileBreak}одна строка меню", + "providers.title": "67 провайдеров,{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 66 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", - "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 66 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 67 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", + "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 67 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": "66 penyedia,{mobileBreak}satu bilah menu", + "providers.title": "67 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 66 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", - "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 66 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 67 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", + "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 67 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": "66 dostawców,{mobileBreak}jeden pasek menu", + "providers.title": "67 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 که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 66 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", - "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 66 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", + "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 67 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", + "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 67 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی 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": "66 ارائه دهنده،{mobileBreak}یک نوار منو", + "providers.title": "67 ارائه دهنده،{mobileBreak}یک نوار منو", "providers.description": "ارائه‌دهندگان محبوب با پنجره‌های استفاده خاص خود، شمارش معکوس، نمودارها و منوهای ارائه‌دهنده را بازنشانی می‌کنند.", "providers.yourProvider": "ارائه دهنده شما", "providers.authoringGuide": "راهنمای نگارش", @@ -2758,8 +2758,8 @@ export const localeMessages = { }, "th": { "meta.title": "CodexBar — ทุกขีดจำกัดการเข้ารหัส AI ในแถบเมนูของคุณ", - "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 66 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", - "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 66 รายจากแถบเมนู macOS", + "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 67 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", + "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 67 รายจากแถบเมนู 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": "ผู้ให้บริการ 66 ราย{mobileBreak}หนึ่งแถบเมนู", + "providers.title": "ผู้ให้บริการ 67 ราย{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 66 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 66 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 67 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 67 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": "66 provedores,{mobileBreak}unha barra de menús", + "providers.title": "67 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: 66 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 66 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: 67 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 67 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": "66 proveïdors,{mobileBreak}una barra de menús", + "providers.title": "67 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 66 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", - "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 66 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 67 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", + "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 67 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": "66 leverantörer,{mobileBreak}en menyrad", + "providers.title": "67 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 b5fa85a9aa..59bf7124c0 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

Every AI coding limit, in your menu bar.

-

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

+

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