diff --git a/CLIProxyAPI b/CLIProxyAPI new file mode 160000 index 000000000..7e9d0db6a --- /dev/null +++ b/CLIProxyAPI @@ -0,0 +1 @@ +Subproject commit 7e9d0db6aac21734d84038f843336306246ecadd diff --git a/Package.swift b/Package.swift index 83cbfca65..28401e8f2 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let sweetCookieKitDependency: Package.Dependency = let package = Package( name: "CodexBar", + defaultLocalization: "en", platforms: [ .macOS(.v14), ], @@ -33,6 +34,9 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "SweetCookieKit", package: "SweetCookieKit"), ], + resources: [ + .process("Resources"), + ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), diff --git a/README.md b/README.md index 757fbf030..ca4bdd080 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Now includes first-class CLIProxyAPI usage paths, plus app language options (System / English / 简体中文). + CodexBar menu screenshot ## Install @@ -28,14 +30,18 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - Open Settings → Providers and enable what you use. - Install/sign in to the provider sources you rely on (e.g. `codex`, `claude`, `gemini`, browser cookies, or OAuth; Antigravity requires the Antigravity app running). - Optional: Settings → Providers → Codex → OpenAI cookies (Automatic or Manual) to add dashboard extras. +- Optional: Settings → General → CLIProxyAPI to set base URL, management key, and optional `auth_index`. ## Providers - [Codex](docs/codex.md) — Local Codex CLI RPC (+ PTY fallback) and optional OpenAI web dashboard extras. +- [CLIProxy Codex](docs/codex.md) — Codex quota via CLIProxyAPI management endpoints, with multi-auth aggregation + per-auth drill-down. - [Claude](docs/claude.md) — OAuth API or browser cookies (+ CLI PTY fallback); session + weekly usage. - [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. - [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies). +- CLIProxy Gemini — Gemini quota via CLIProxyAPI (`gemini` auth entries). - [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth. +- CLIProxy Antigravity — Antigravity quota via CLIProxyAPI (`antigravity` auth entries). - [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing. - [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API. - [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows. @@ -58,12 +64,14 @@ The menu bar icon is a tiny two-bar meter: - Multi-provider menu bar with per-provider toggles (Settings → Providers). - Session + weekly meters with reset countdowns. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). +- CLIProxyAPI integration for Codex/Gemini/Antigravity with multi-auth support. - Local cost-usage scan for Codex + Claude (last 30 days). - Provider status polling with incident badges in the menu and icon overlay. - Merge Icons mode to combine providers into one status item + switcher. - Refresh cadence presets (manual, 1m, 2m, 5m, 15m). - Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex|claude` for local cost usage); Linux CLI builds available. - WidgetKit widget mirrors the menu card snapshot. +- Built-in i18n language switcher: follow system, English, and Simplified Chinese. - Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored). ## Privacy note diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index a6a3754b7..ce20b2ce4 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -190,6 +190,10 @@ cat > "$APP/Contents/Info.plist" <CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 LSUIElement + NSAppTransportSecurity + + NSAllowsArbitraryLoads + CFBundleIconFileIcon NSHumanReadableCopyright© 2025 Peter Steinberger. MIT License. SUFeedURL${FEED_URL} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5c0bb2d9b..0b1887a49 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -11,15 +11,19 @@ struct UsageMenuCardView: View { var labelSuffix: String { switch self { - case .left: "left" - case .used: "used" + case .left: + L10n.tr("menu.card.percent.left", fallback: "left") + case .used: + L10n.tr("menu.card.percent.used", fallback: "used") } } var accessibilityLabel: String { switch self { - case .left: "Usage remaining" - case .used: "Usage used" + case .left: + L10n.tr("menu.card.accessibility.usage_remaining", fallback: "Usage remaining") + case .used: + L10n.tr("menu.card.accessibility.usage_used", fallback: "Usage used") } } } @@ -135,7 +139,7 @@ struct UsageMenuCardView: View { } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(L10n.tr("menu.card.cost.title", fallback: "Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -460,19 +464,22 @@ private struct CreditsBarContent: View { private var scaleText: String { let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) - return "\(scale) tokens" + let format = L10n.tr("menu.card.tokens.unit", fallback: "%@ tokens") + return String(format: format, locale: .current, scale) } var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("Credits") + Text(L10n.tr("menu.card.credits.title", fallback: "Credits")) .font(.body) .fontWeight(.medium) if let percentLeft { UsageProgressBar( percent: percentLeft, tint: self.progressColor, - accessibilityLabel: "Credits remaining") + accessibilityLabel: L10n.tr( + "menu.card.accessibility.credits_remaining", + fallback: "Credits remaining")) HStack(alignment: .firstTextBaseline) { Text(self.creditsText) .font(.caption) @@ -513,7 +520,7 @@ struct UsageMenuCardCostSectionView: View { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(L10n.tr("menu.card.cost.title", fallback: "Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -576,6 +583,7 @@ extension UsageMenuCardView.Model { struct Input { let provider: UsageProvider let metadata: ProviderMetadata + let sourceLabel: String? let snapshot: UsageSnapshot? let credits: CreditsSnapshot? let creditsError: String? @@ -601,10 +609,16 @@ extension UsageMenuCardView.Model { account: input.account, metadata: input.metadata) let metrics = Self.metrics(input: input) + let isCodexCLIProxy = input.provider == .codex && + (input.sourceLabel?.localizedCaseInsensitiveContains("cliproxy-api") ?? false) let creditsText: String? = if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { nil } else { - Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) + Self.creditsLine( + metadata: input.metadata, + credits: input.credits, + error: input.creditsError, + showUnavailableHint: !isCodexCLIProxy) } let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage { nil @@ -613,6 +627,8 @@ extension UsageMenuCardView.Model { } let tokenUsage = Self.tokenUsageSection( provider: input.provider, + sourceLabel: input.sourceLabel, + hasUsageSnapshot: input.snapshot != nil, enabled: input.tokenCostUsageEnabled, snapshot: input.tokenSnapshot, error: input.tokenError) @@ -745,7 +761,7 @@ extension UsageMenuCardView.Model { if let primary = snapshot.primary { metrics.append(Metric( id: "primary", - title: input.metadata.sessionLabel, + title: Self.primaryWindowLabel(for: input.provider, fallback: input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -764,7 +780,7 @@ extension UsageMenuCardView.Model { showUsed: input.usageBarsShowUsed) metrics.append(Metric( id: "secondary", - title: input.metadata.weeklyLabel, + title: Self.secondaryWindowLabel(for: input.provider, fallback: input.metadata.weeklyLabel), percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now), @@ -805,6 +821,20 @@ extension UsageMenuCardView.Model { return metrics } + private static func primaryWindowLabel(for provider: UsageProvider, fallback: String) -> String { + if provider == .codex || provider == .codexproxy { + return L10n.tr("provider.codex.metadata.session_label", fallback: "Session") + } + return fallback + } + + private static func secondaryWindowLabel(for provider: UsageProvider, fallback: String) -> String { + if provider == .codex || provider == .codexproxy { + return L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly") + } + return fallback + } + private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { guard let limit else { return nil } let currentStr = UsageFormatter.tokenCountString(limit.currentValue) @@ -844,12 +874,14 @@ extension UsageMenuCardView.Model { private static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, - error: String?) -> String? + error: String?, + showUnavailableHint: Bool = true) -> String? { guard metadata.supportsCredits else { return nil } if let credits { return UsageFormatter.creditsString(from: credits.remaining) } + guard showUnavailableHint else { return nil } if let error, !error.isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -864,11 +896,19 @@ extension UsageMenuCardView.Model { private static func tokenUsageSection( provider: UsageProvider, + sourceLabel: String?, + hasUsageSnapshot: Bool, enabled: Bool, snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { + return nil + } + if provider == .codex { + guard hasUsageSnapshot else { return nil } + if sourceLabel?.localizedCaseInsensitiveContains("cliproxy-api") == true { return nil } + } guard enabled else { return nil } guard let snapshot else { return nil } @@ -876,9 +916,11 @@ extension UsageMenuCardView.Model { let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLine: String = { if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" + let format = L10n.tr("menu.card.cost.today_with_tokens", fallback: "Today: %@ · %@ tokens") + return String(format: format, locale: .current, sessionCost, sessionTokens) } - return "Today: \(sessionCost)" + let format = L10n.tr("menu.card.cost.today", fallback: "Today: %@") + return String(format: format, locale: .current, sessionCost) }() let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" @@ -887,9 +929,13 @@ extension UsageMenuCardView.Model { let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } let monthLine: String = { if let monthTokens { - return "Last 30 days: \(monthCost) · \(monthTokens) tokens" + let format = L10n.tr( + "menu.card.cost.last_30_days_with_tokens", + fallback: "Last 30 days: %@ · %@ tokens") + return String(format: format, locale: .current, monthCost, monthTokens) } - return "Last 30 days: \(monthCost)" + let format = L10n.tr("menu.card.cost.last_30_days", fallback: "Last 30 days: %@") + return String(format: format, locale: .current, monthCost) }() let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( @@ -912,17 +958,17 @@ extension UsageMenuCardView.Model { let title: String if cost.currencyCode == "Quota" { - title = "Quota usage" + title = L10n.tr("menu.card.provider_cost.quota_usage", fallback: "Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { - title = "Extra usage" + title = L10n.tr("menu.card.provider_cost.extra_usage", fallback: "Extra usage") used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) } let percentUsed = Self.clamped((cost.used / cost.limit) * 100) - let periodLabel = cost.period ?? "This month" + let periodLabel = cost.period ?? L10n.tr("menu.card.provider_cost.this_month", fallback: "This month") return ProviderCostSection( title: title, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 36a9c861b..7cbc15f35 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -117,7 +117,7 @@ struct MenuDescriptor { if let primary = snap.primary { Self.appendRateWindow( entries: &entries, - title: meta.sessionLabel, + title: Self.primaryWindowLabel(for: provider, fallback: meta.sessionLabel), window: primary, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -125,7 +125,7 @@ struct MenuDescriptor { if let weekly = snap.secondary { Self.appendRateWindow( entries: &entries, - title: meta.weeklyLabel, + title: Self.secondaryWindowLabel(for: provider, fallback: meta.weeklyLabel), window: weekly, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -165,6 +165,20 @@ struct MenuDescriptor { return Section(entries: entries) } + private static func primaryWindowLabel(for provider: UsageProvider, fallback: String) -> String { + if provider == .codex || provider == .codexproxy { + return L10n.tr("provider.codex.metadata.session_label", fallback: "Session") + } + return fallback + } + + private static func secondaryWindowLabel(for provider: UsageProvider, fallback: String) -> String { + if provider == .codex || provider == .codexproxy { + return L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly") + } + return fallback + } + private static func accountSection( for provider: UsageProvider, store: UsageStore, @@ -245,11 +259,15 @@ struct MenuDescriptor { settings: store.settings, account: account) } + let hideSwitchAccountAction = Self.shouldHideSwitchAccountAction( + provider: targetProvider, + store: store) // Show "Add Account" if no account, "Switch Account" if logged in if let targetProvider, let implementation = ProviderCatalog.implementation(for: targetProvider), - implementation.supportsLoginFlow + implementation.supportsLoginFlow, + !hideSwitchAccountAction { if let loginContext, let override = implementation.loginMenuAction(context: loginContext) @@ -258,7 +276,9 @@ struct MenuDescriptor { } else { let loginAction = self.switchAccountTarget(for: provider, store: store) let hasAccount = self.hasAccount(for: provider, store: store, account: account) - let accountLabel = hasAccount ? "Switch Account..." : "Add Account..." + let accountLabel = hasAccount + ? L10n.tr("menu.action.switch_account", fallback: "Switch Account...") + : L10n.tr("menu.action.add_account", fallback: "Add Account...") entries.append(.action(accountLabel, loginAction)) } } @@ -274,10 +294,10 @@ struct MenuDescriptor { } if metadata?.dashboardURL != nil { - entries.append(.action("Usage Dashboard", .dashboard)) + entries.append(.action(L10n.tr("menu.action.usage_dashboard", fallback: "Usage Dashboard"), .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { - entries.append(.action("Status Page", .statusPage)) + entries.append(.action(L10n.tr("menu.action.status_page", fallback: "Status Page"), .statusPage)) } if let statusLine = self.statusLine(for: provider, store: store) { @@ -290,12 +310,14 @@ struct MenuDescriptor { private static func metaSection(updateReady: Bool) -> Section { var entries: [Entry] = [] if updateReady { - entries.append(.action("Update ready, restart now?", .installUpdate)) + entries.append(.action( + L10n.tr("menu.action.install_update", fallback: "Update ready, restart now?"), + .installUpdate)) } entries.append(contentsOf: [ - .action("Settings...", .settings), - .action("About CodexBar", .about), - .action("Quit", .quit), + .action(L10n.tr("menu.action.settings", fallback: "Settings..."), .settings), + .action(L10n.tr("menu.action.about", fallback: "About CodexBar"), .about), + .action(L10n.tr("menu.action.quit", fallback: "Quit"), .quit), ]) return Section(entries: entries) } @@ -315,6 +337,14 @@ struct MenuDescriptor { return label } + private static func shouldHideSwitchAccountAction(provider: UsageProvider?, store: UsageStore) -> Bool { + guard provider == .codexproxy else { return false } + let codexSettings = store.settings.codexSettingsSnapshot(tokenOverride: nil) + return CodexCLIProxySettings.resolve( + providerSettings: codexSettings, + environment: ProcessInfo.processInfo.environment) != nil + } + private static func switchAccountTarget(for provider: UsageProvider?, store: UsageStore) -> MenuAction { if let provider { return .switchAccount(provider) } if let enabled = store.enabledProviders().first { return .switchAccount(enabled) } diff --git a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift index 99ec8eef6..89acd2399 100644 --- a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift +++ b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift @@ -420,7 +420,7 @@ final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigat styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) - window.title = "Buy Credits" + window.title = L10n.tr("window.buy_credits.title", fallback: "Buy Credits") window.isReleasedWhenClosed = false window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] window.contentView = container diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index 16e27189e..d4a962dc4 100644 --- a/Sources/CodexBar/PreferencesAboutPane.swift +++ b/Sources/CodexBar/PreferencesAboutPane.swift @@ -1,4 +1,5 @@ import AppKit +import CodexBarCore import SwiftUI @MainActor @@ -51,14 +52,22 @@ struct AboutPane: View { VStack(spacing: 2) { Text("CodexBar") .font(.title3).bold() - Text("Version \(self.versionString)") + Text(String( + format: L10n.tr("settings.about.version", fallback: "Version %@"), + locale: .current, + self.versionString)) .foregroundStyle(.secondary) if let buildTimestamp { - Text("Built \(buildTimestamp)") + Text(String( + format: L10n.tr("settings.about.build", fallback: "Built %@"), + locale: .current, + buildTimestamp)) .font(.footnote) .foregroundStyle(.secondary) } - Text("May your tokens never run out—keep agent limits in view.") + Text(L10n.tr( + "settings.about.tagline", + fallback: "May your tokens never run out—keep agent limits in view.")) .font(.footnote) .foregroundStyle(.secondary) } @@ -66,11 +75,20 @@ struct AboutPane: View { VStack(alignment: .center, spacing: 10) { AboutLinkRow( icon: "chevron.left.slash.chevron.right", - title: "GitHub", + title: L10n.tr("settings.about.link.github", fallback: "GitHub"), url: "https://github.com/steipete/CodexBar") - AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me") - AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete") - AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me") + AboutLinkRow( + icon: "globe", + title: L10n.tr("settings.about.link.website", fallback: "Website"), + url: "https://steipete.me") + AboutLinkRow( + icon: "bird", + title: L10n.tr("settings.about.link.twitter", fallback: "Twitter"), + url: "https://twitter.com/steipete") + AboutLinkRow( + icon: "envelope", + title: L10n.tr("settings.about.link.email", fallback: "Email"), + url: "mailto:peter@steipete.me") } .padding(.top, 8) .frame(maxWidth: .infinity) @@ -80,12 +98,14 @@ struct AboutPane: View { if self.updater.isAvailable { VStack(spacing: 10) { - Toggle("Check for updates automatically", isOn: self.$autoUpdateEnabled) + Toggle( + L10n.tr("settings.about.updates.auto_check", fallback: "Check for updates automatically"), + isOn: self.$autoUpdateEnabled) .toggleStyle(.checkbox) .frame(maxWidth: .infinity, alignment: .center) VStack(spacing: 6) { HStack(spacing: 12) { - Text("Update Channel") + Text(L10n.tr("settings.about.updates.channel", fallback: "Update Channel")) Spacer() Picker("", selection: self.updateChannelBinding) { ForEach(UpdateChannel.allCases) { channel in @@ -102,14 +122,17 @@ struct AboutPane: View { .multilineTextAlignment(.center) .frame(maxWidth: 280) } - Button("Check for Updates…") { self.updater.checkForUpdates(nil) } + Button(L10n.tr("settings.about.updates.check_now", fallback: "Check for Updates…")) { + self.updater.checkForUpdates(nil) + } } } else { - Text(self.updater.unavailableReason ?? "Updates unavailable in this build.") + Text(self.updater.unavailableReason ?? + L10n.tr("settings.about.updates.unavailable", fallback: "Updates unavailable in this build.")) .foregroundStyle(.secondary) } - Text("© 2025 Peter Steinberger. MIT License.") + Text(L10n.tr("settings.about.copyright", fallback: "© 2025 Peter Steinberger. MIT License.")) .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 4) diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f2..2e704068f 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -1,3 +1,4 @@ +import CodexBarCore import KeyboardShortcuts import SwiftUI @@ -11,17 +12,19 @@ struct AdvancedPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 8) { - Text("Keyboard shortcut") + Text(L10n.tr("settings.advanced.keyboard.section", fallback: "Keyboard shortcut")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) HStack(alignment: .center, spacing: 12) { - Text("Open menu") + Text(L10n.tr("settings.advanced.keyboard.open_menu.title", fallback: "Open menu")) .font(.body) Spacer() KeyboardShortcuts.Recorder(for: .openMenu) } - Text("Trigger the menu bar menu from anywhere.") + Text(L10n.tr( + "settings.advanced.keyboard.open_menu.subtitle", + fallback: "Trigger the menu bar menu from anywhere.")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -36,7 +39,7 @@ struct AdvancedPane: View { if self.isInstallingCLI { ProgressView().controlSize(.small) } else { - Text("Install CLI") + Text(L10n.tr("settings.advanced.cli.install", fallback: "Install CLI")) } } .disabled(self.isInstallingCLI) @@ -48,7 +51,9 @@ struct AdvancedPane: View { .lineLimit(2) } } - Text("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.") + Text(L10n.tr( + "settings.advanced.cli.install.subtitle", + fallback: "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -57,12 +62,16 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Show Debug Settings", - subtitle: "Expose troubleshooting tools in the Debug tab.", + title: L10n.tr("settings.advanced.debug.title", fallback: "Show Debug Settings"), + subtitle: L10n.tr( + "settings.advanced.debug.subtitle", + fallback: "Expose troubleshooting tools in the Debug tab."), binding: self.$settings.debugMenuEnabled) PreferenceToggleRow( - title: "Surprise me", - subtitle: "Check if you like your agents having some fun up there.", + title: L10n.tr("settings.advanced.surprise.title", fallback: "Surprise me"), + subtitle: L10n.tr( + "settings.advanced.surprise.subtitle", + fallback: "Check if you like your agents having some fun up there."), binding: self.$settings.randomBlinkEnabled) } @@ -70,22 +79,33 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Hide personal information", - subtitle: "Obscure email addresses in the menu bar and menu UI.", + title: L10n.tr( + "settings.advanced.privacy.hide_personal_info.title", + fallback: "Hide personal information"), + subtitle: L10n.tr( + "settings.advanced.privacy.hide_personal_info.subtitle", + fallback: "Obscure email addresses in the menu bar and menu UI."), binding: self.$settings.hidePersonalInfo) } Divider() SettingsSection( - title: "Keychain access", - caption: """ + title: L10n.tr("settings.advanced.keychain.title", fallback: "Keychain access"), + caption: L10n.tr( + "settings.advanced.keychain.caption", + fallback: """ Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie \ headers manually in Providers. - """) { + """)) + { PreferenceToggleRow( - title: "Disable Keychain access", - subtitle: "Prevents any Keychain access while enabled.", + title: L10n.tr( + "settings.advanced.keychain.disable.title", + fallback: "Disable Keychain access"), + subtitle: L10n.tr( + "settings.advanced.keychain.disable.subtitle", + fallback: "Prevents any Keychain access while enabled."), binding: self.$settings.debugDisableKeychainAccess) } } @@ -105,7 +125,9 @@ extension AdvancedPane { let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/CodexBarCLI") let fm = FileManager.default guard fm.fileExists(atPath: helperURL.path) else { - self.cliStatus = "CodexBarCLI not found in app bundle." + self.cliStatus = L10n.tr( + "settings.advanced.cli.status.helper_not_found", + fallback: "CodexBarCLI not found in app bundle.") return } @@ -119,29 +141,36 @@ extension AdvancedPane { let dir = (dest as NSString).deletingLastPathComponent guard fm.fileExists(atPath: dir) else { continue } guard fm.isWritableFile(atPath: dir) else { - results.append("No write access: \(dir)") + let format = L10n.tr( + "settings.advanced.cli.status.no_write_access", + fallback: "No write access: %@") + results.append(String(format: format, locale: .current, dir)) continue } if fm.fileExists(atPath: dest) { if Self.isLink(atPath: dest, pointingTo: helperURL.path) { - results.append("Installed: \(dir)") + let format = L10n.tr("settings.advanced.cli.status.installed", fallback: "Installed: %@") + results.append(String(format: format, locale: .current, dir)) } else { - results.append("Exists: \(dir)") + let format = L10n.tr("settings.advanced.cli.status.exists", fallback: "Exists: %@") + results.append(String(format: format, locale: .current, dir)) } continue } do { try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path) - results.append("Installed: \(dir)") + let format = L10n.tr("settings.advanced.cli.status.installed", fallback: "Installed: %@") + results.append(String(format: format, locale: .current, dir)) } catch { - results.append("Failed: \(dir)") + let format = L10n.tr("settings.advanced.cli.status.failed", fallback: "Failed: %@") + results.append(String(format: format, locale: .current, dir)) } } self.cliStatus = results.isEmpty - ? "No writable bin dirs found." + ? L10n.tr("settings.advanced.cli.status.no_writable_dirs", fallback: "No writable bin dirs found.") : results.joined(separator: " · ") } diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 003fa27ee..925f395b8 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI @MainActor @@ -8,40 +9,59 @@ struct DisplayPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("Menu bar") + Text(L10n.tr("settings.display.menu_bar.section", fallback: "Menu bar")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Merge Icons", - subtitle: "Use a single menu bar icon with a provider switcher.", + title: L10n.tr("settings.display.menu_bar.merge_icons.title", fallback: "Merge Icons"), + subtitle: L10n.tr( + "settings.display.menu_bar.merge_icons.subtitle", + fallback: "Use a single menu bar icon with a provider switcher."), binding: self.$settings.mergeIcons) PreferenceToggleRow( - title: "Switcher shows icons", - subtitle: "Show provider icons in the switcher (otherwise show a weekly progress line).", + title: L10n.tr( + "settings.display.menu_bar.switcher_icons.title", + fallback: "Switcher shows icons"), + subtitle: L10n.tr( + "settings.display.menu_bar.switcher_icons.subtitle", + fallback: "Show provider icons in the switcher (otherwise show a weekly progress line)."), binding: self.$settings.switcherShowsIcons) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( - title: "Show most-used provider", - subtitle: "Menu bar auto-shows the provider closest to its rate limit.", + title: L10n.tr( + "settings.display.menu_bar.highest_usage.title", + fallback: "Show most-used provider"), + subtitle: L10n.tr( + "settings.display.menu_bar.highest_usage.subtitle", + fallback: "Menu bar auto-shows the provider closest to its rate limit."), binding: self.$settings.menuBarShowsHighestUsage) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( - title: "Menu bar shows percent", - subtitle: "Replace critter bars with provider branding icons and a percentage.", + title: L10n.tr( + "settings.display.menu_bar.brand_percent.title", + fallback: "Menu bar shows percent"), + subtitle: L10n.tr( + "settings.display.menu_bar.brand_percent.subtitle", + fallback: "Replace critter bars with provider branding icons and a percentage."), binding: self.$settings.menuBarShowsBrandIconWithPercent) HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Display mode") + Text(L10n.tr("settings.display.menu_bar.mode.title", fallback: "Display mode")) .font(.body) - Text("Choose what to show in the menu bar (Pace shows usage vs. expected).") + Text(L10n.tr( + "settings.display.menu_bar.mode.subtitle", + fallback: "Choose what to show in the menu bar (Pace shows usage vs. expected).")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Display mode", selection: self.$settings.menuBarDisplayMode) { + Picker( + L10n.tr("settings.display.menu_bar.mode.title", fallback: "Display mode"), + selection: self.$settings.menuBarDisplayMode) + { ForEach(MenuBarDisplayMode.allCases) { mode in Text(mode.label).tag(mode) } @@ -57,25 +77,41 @@ struct DisplayPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Menu content") + Text(L10n.tr("settings.display.menu_content.section", fallback: "Menu content")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Show usage as used", - subtitle: "Progress bars fill as you consume quota (instead of showing remaining).", + title: L10n.tr( + "settings.display.menu_content.usage_as_used.title", + fallback: "Show usage as used"), + subtitle: L10n.tr( + "settings.display.menu_content.usage_as_used.subtitle", + fallback: "Progress bars fill as you consume quota (instead of showing remaining)."), binding: self.$settings.usageBarsShowUsed) PreferenceToggleRow( - title: "Show reset time as clock", - subtitle: "Display reset times as absolute clock values instead of countdowns.", + title: L10n.tr( + "settings.display.menu_content.reset_clock.title", + fallback: "Show reset time as clock"), + subtitle: L10n.tr( + "settings.display.menu_content.reset_clock.subtitle", + fallback: "Display reset times as absolute clock values instead of countdowns."), binding: self.$settings.resetTimesShowAbsolute) PreferenceToggleRow( - title: "Show credits + extra usage", - subtitle: "Show Codex Credits and Claude Extra usage sections in the menu.", + title: L10n.tr( + "settings.display.menu_content.optional_usage.title", + fallback: "Show credits + extra usage"), + subtitle: L10n.tr( + "settings.display.menu_content.optional_usage.subtitle", + fallback: "Show Codex Credits and Claude Extra usage sections in the menu."), binding: self.$settings.showOptionalCreditsAndExtraUsage) PreferenceToggleRow( - title: "Show all token accounts", - subtitle: "Stack token accounts in the menu (otherwise show an account switcher bar).", + title: L10n.tr( + "settings.display.menu_content.all_token_accounts.title", + fallback: "Show all token accounts"), + subtitle: L10n.tr( + "settings.display.menu_content.all_token_accounts.subtitle", + fallback: "Stack token accounts in the menu (otherwise show an account switcher bar)."), binding: self.$settings.showAllTokenAccountsInMenu) } } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 39a95a55f..e7199eada 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -11,20 +11,112 @@ struct GeneralPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("System") + Text(L10n.tr("settings.general.system.section", fallback: "System")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Start at Login", - subtitle: "Automatically opens CodexBar when you start your Mac.", + title: L10n.tr("settings.general.system.start_at_login.title", fallback: "Start at Login"), + subtitle: L10n.tr( + "settings.general.system.start_at_login.subtitle", + fallback: "Automatically opens CodexBar when you start your Mac."), binding: self.$settings.launchAtLogin) + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("settings.general.language.title", fallback: "Language")) + .font(.body) + Text(L10n.tr( + "settings.general.language.subtitle", + fallback: "Choose app display language.")) + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker("", selection: self.$settings.appLanguage) { + ForEach(AppLanguageOption.allCases) { option in + Text(option.label).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + Text(L10n.tr( + "settings.general.language.restart_hint", + fallback: "Language changes apply after restart.")) + .font(.footnote) + .foregroundStyle(.secondary) + HStack { + Spacer() + Button(L10n.tr("settings.general.language.apply_restart", fallback: "Apply & Restart")) { + self.restartApp() + } + .buttonStyle(.bordered) + } + } + } + + Divider() + + SettingsSection(contentSpacing: 12) { + Text(L10n.tr("settings.general.cliproxy.section", fallback: "CLIProxyAPI")) + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("settings.general.cliproxy.url.title", fallback: "Base URL")) + .font(.body) + Text(L10n.tr( + "settings.general.cliproxy.url.subtitle", + fallback: "Global default for providers using API source (for example Codex).")) + .font(.footnote) + .foregroundStyle(.tertiary) + TextField( + L10n.tr( + "settings.general.cliproxy.url.placeholder", + fallback: "http://127.0.0.1:8317"), + text: self.$settings.cliProxyGlobalBaseURL) + .textFieldStyle(.roundedBorder) + .font(.footnote) + } + + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("settings.general.cliproxy.key.title", fallback: "Management Key")) + .font(.body) + SecureField( + L10n.tr( + "settings.general.cliproxy.key.placeholder", + fallback: "Paste management key…"), + text: self.$settings.cliProxyGlobalManagementKey) + .textFieldStyle(.roundedBorder) + .font(.footnote) + } + + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("settings.general.cliproxy.auth_index.title", fallback: "auth_index (optional)")) + .font(.body) + Text(L10n.tr( + "settings.general.cliproxy.auth_index.subtitle", + fallback: "Optional. Set a specific auth file; leave empty to aggregate all matching auth entries.")) + .font(.footnote) + .foregroundStyle(.tertiary) + TextField( + L10n.tr( + "settings.general.cliproxy.auth_index.placeholder", + fallback: "Leave empty to load all matching auth entries"), + text: self.$settings.cliProxyGlobalAuthIndex) + .textFieldStyle(.roundedBorder) + .font(.footnote) + } + } } Divider() SettingsSection(contentSpacing: 12) { - Text("Usage") + Text(L10n.tr("settings.general.usage.section", fallback: "Usage")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -32,18 +124,22 @@ struct GeneralPane: View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 4) { Toggle(isOn: self.$settings.costUsageEnabled) { - Text("Show cost summary") + Text(L10n.tr("settings.general.usage.cost_summary.title", fallback: "Show cost summary")) .font(.body) } .toggleStyle(.checkbox) - Text("Reads local usage logs. Shows today + last 30 days cost in the menu.") + Text(L10n.tr( + "settings.general.usage.cost_summary.subtitle", + fallback: "Reads local usage logs. Shows today + last 30 days cost in the menu.")) .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) if self.settings.costUsageEnabled { - Text("Auto-refresh: hourly · Timeout: 10m") + Text(L10n.tr( + "settings.general.usage.cost_summary.refresh_hint", + fallback: "Auto-refresh: hourly · Timeout: 10m")) .font(.footnote) .foregroundStyle(.tertiary) @@ -57,21 +153,26 @@ struct GeneralPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Automation") + Text(L10n.tr("settings.general.automation.section", fallback: "Automation")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Refresh cadence") + Text(L10n.tr("settings.general.automation.refresh_cadence.title", fallback: "Refresh cadence")) .font(.body) - Text("How often CodexBar polls providers in the background.") + Text(L10n.tr( + "settings.general.automation.refresh_cadence.subtitle", + fallback: "How often CodexBar polls providers in the background.")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Refresh cadence", selection: self.$settings.refreshFrequency) { + Picker( + L10n.tr("settings.general.automation.refresh_cadence.title", fallback: "Refresh cadence"), + selection: self.$settings.refreshFrequency) + { ForEach(RefreshFrequency.allCases) { option in Text(option.label).tag(option) } @@ -81,20 +182,26 @@ struct GeneralPane: View { .frame(maxWidth: 200) } if self.settings.refreshFrequency == .manual { - Text("Auto-refresh is off; use the menu's Refresh command.") + Text(L10n.tr( + "settings.general.automation.refresh_cadence.manual_hint", + fallback: "Auto-refresh is off; use the menu's Refresh command.")) .font(.footnote) .foregroundStyle(.secondary) } } PreferenceToggleRow( - title: "Check provider status", - subtitle: "Polls OpenAI/Claude status pages and Google Workspace for " + - "Gemini/Antigravity, surfacing incidents in the icon and menu.", + title: L10n.tr("settings.general.automation.check_status.title", fallback: "Check provider status"), + subtitle: L10n.tr( + "settings.general.automation.check_status.subtitle", + fallback: "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."), binding: self.$settings.statusChecksEnabled) PreferenceToggleRow( - title: "Session quota notifications", - subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " + - "available again.", + title: L10n.tr( + "settings.general.automation.session_quota.title", + fallback: "Session quota notifications"), + subtitle: L10n.tr( + "settings.general.automation.session_quota.subtitle", + fallback: "Notifies when the 5-hour session quota hits 0% and when it becomes available again."), binding: self.$settings.sessionQuotaNotificationsEnabled) } @@ -103,7 +210,7 @@ struct GeneralPane: View { SettingsSection(contentSpacing: 12) { HStack { Spacer() - Button("Quit CodexBar") { NSApp.terminate(nil) } + Button(L10n.tr("settings.general.quit", fallback: "Quit CodexBar")) { NSApp.terminate(nil) } .buttonStyle(.borderedProminent) .controlSize(.large) } @@ -119,7 +226,8 @@ struct GeneralPane: View { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName guard provider == .claude || provider == .codex else { - return Text("\(name): unsupported") + let format = L10n.tr("settings.general.usage.cost_status.unsupported", fallback: "%@: unsupported") + return Text(String(format: format, locale: .current, name)) .font(.footnote) .foregroundStyle(.tertiary) } @@ -133,14 +241,16 @@ struct GeneralPane: View { formatter.unitsStyle = .abbreviated return formatter.string(from: seconds).map { " (\($0))" } ?? "" }() - return Text("\(name): fetching…\(elapsed)") + let format = L10n.tr("settings.general.usage.cost_status.fetching", fallback: "%@: fetching…%@") + return Text(String(format: format, locale: .current, name, elapsed)) .font(.footnote) .foregroundStyle(.tertiary) } if let snapshot = self.store.tokenSnapshot(for: provider) { let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - return Text("\(name): \(updated) · 30d \(cost)") + let format = L10n.tr("settings.general.usage.cost_status.snapshot", fallback: "%@: %@ · 30d %@") + return Text(String(format: format, locale: .current, name, updated, cost)) .font(.footnote) .foregroundStyle(.tertiary) } @@ -154,12 +264,25 @@ struct GeneralPane: View { let rel = RelativeDateTimeFormatter() rel.unitsStyle = .abbreviated let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) - return Text("\(name): last attempt \(when)") + let format = L10n.tr("settings.general.usage.cost_status.last_attempt", fallback: "%@: last attempt %@") + return Text(String(format: format, locale: .current, name, when)) .font(.footnote) .foregroundStyle(.tertiary) } - return Text("\(name): no data yet") + let format = L10n.tr("settings.general.usage.cost_status.no_data", fallback: "%@: no data yet") + return Text(String(format: format, locale: .current, name)) .font(.footnote) .foregroundStyle(.tertiary) } + + private func restartApp() { + let bundleURL = Bundle.main.bundleURL + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: bundleURL, configuration: configuration) { _, _ in + Task { @MainActor in + NSApp.terminate(nil) + } + } + } } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index f8b843240..ad89f0891 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -38,14 +38,19 @@ struct ProviderDetailView: View { if let errorDisplay { ProviderErrorView( - title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:", + title: String( + format: L10n.tr( + "settings.providers.error.last_fetch_failed", + fallback: "Last %@ fetch failed:"), + locale: .current, + self.store.metadata(for: self.provider).displayName), display: errorDisplay, isExpanded: self.$isErrorExpanded, onCopy: { self.onCopyError(errorDisplay.full) }) } if self.hasSettings { - ProviderSettingsSection(title: "Settings") { + ProviderSettingsSection(title: L10n.tr("settings.providers.section.settings", fallback: "Settings")) { ForEach(self.settingsPickers) { picker in ProviderSettingsPickerRowView(picker: picker) } @@ -61,7 +66,7 @@ struct ProviderDetailView: View { } if !self.settingsToggles.isEmpty { - ProviderSettingsSection(title: "Options") { + ProviderSettingsSection(title: L10n.tr("settings.providers.section.options", fallback: "Options")) { ForEach(self.settingsToggles) { toggle in ProviderSettingsToggleRowView(toggle: toggle) } @@ -82,26 +87,31 @@ struct ProviderDetailView: View { } private var detailLabelWidth: CGFloat { - var infoLabels = ["State", "Source", "Version", "Updated"] + var infoLabels = [ + L10n.tr("settings.providers.detail.label.state", fallback: "State"), + L10n.tr("settings.providers.detail.label.source", fallback: "Source"), + L10n.tr("settings.providers.detail.label.version", fallback: "Version"), + L10n.tr("settings.providers.detail.label.updated", fallback: "Updated"), + ] if self.store.status(for: self.provider) != nil { - infoLabels.append("Status") + infoLabels.append(L10n.tr("settings.providers.detail.label.status", fallback: "Status")) } if !self.model.email.isEmpty { - infoLabels.append("Account") + infoLabels.append(L10n.tr("settings.providers.detail.label.account", fallback: "Account")) } if let plan = self.model.planText, !plan.isEmpty { - infoLabels.append("Plan") + infoLabels.append(L10n.tr("settings.providers.detail.label.plan", fallback: "Plan")) } var metricLabels = self.model.metrics.map(\.title) if self.model.creditsText != nil { - metricLabels.append("Credits") + metricLabels.append(L10n.tr("settings.providers.detail.label.credits", fallback: "Credits")) } if let providerCost = self.model.providerCost { metricLabels.append(providerCost.title) } if self.model.tokenUsage != nil { - metricLabels.append("Cost") + metricLabels.append(L10n.tr("settings.providers.detail.label.cost", fallback: "Cost")) } let infoWidth = ProviderSettingsMetrics.labelWidth( @@ -147,7 +157,7 @@ private struct ProviderDetailHeaderView: View { } .buttonStyle(.bordered) .controlSize(.small) - .help("Refresh") + .help(L10n.tr("settings.providers.detail.help.refresh", fallback: "Refresh")) Toggle("", isOn: self.$isEnabled) .labelsHidden() @@ -207,31 +217,52 @@ private struct ProviderDetailInfoGrid: View { var body: some View { let status = self.store.status(for: self.provider) let source = self.store.sourceLabel(for: self.provider) - let version = self.store.version(for: self.provider) ?? "not detected" + let version = self.store.version(for: self.provider) + ?? L10n.tr("settings.providers.detail.version.not_detected", fallback: "not detected") let updated = self.updatedText let email = self.model.email let plan = self.model.planText ?? "" - let enabledText = self.isEnabled ? "Enabled" : "Disabled" + let enabledText = self.isEnabled + ? L10n.tr("settings.providers.detail.state.enabled", fallback: "Enabled") + : L10n.tr("settings.providers.detail.state.disabled", fallback: "Disabled") Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { - ProviderDetailInfoRow(label: "State", value: enabledText, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: "Source", value: source, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: "Version", value: version, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: "Updated", value: updated, labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.state", fallback: "State"), + value: enabledText, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.source", fallback: "Source"), + value: source, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.version", fallback: "Version"), + value: version, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.updated", fallback: "Updated"), + value: updated, + labelWidth: self.labelWidth) if let status { ProviderDetailInfoRow( - label: "Status", + label: L10n.tr("settings.providers.detail.label.status", fallback: "Status"), value: status.description ?? status.indicator.label, labelWidth: self.labelWidth) } if !email.isEmpty { - ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.account", fallback: "Account"), + value: email, + labelWidth: self.labelWidth) } if !plan.isEmpty { - ProviderDetailInfoRow(label: "Plan", value: plan, labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.plan", fallback: "Plan"), + value: plan, + labelWidth: self.labelWidth) } } .font(.footnote) @@ -243,9 +274,9 @@ private struct ProviderDetailInfoGrid: View { return UsageFormatter.updatedString(from: updated) } if self.store.refreshingProviders.contains(self.provider) { - return "Refreshing" + return L10n.tr("settings.providers.detail.updated.refreshing", fallback: "Refreshing") } - return "Not fetched yet" + return L10n.tr("settings.providers.detail.updated.not_fetched_yet", fallback: "Not fetched yet") } } @@ -273,7 +304,7 @@ struct ProviderMetricsInlineView: View { var body: some View { ProviderSettingsSection( - title: "Usage", + title: L10n.tr("settings.providers.section.usage", fallback: "Usage"), spacing: 8, verticalPadding: 6, horizontalPadding: 0) @@ -294,7 +325,7 @@ struct ProviderMetricsInlineView: View { if let credits = self.model.creditsText { ProviderMetricInlineTextRow( - title: "Credits", + title: L10n.tr("settings.providers.detail.label.credits", fallback: "Credits"), value: credits, labelWidth: self.labelWidth) } @@ -308,7 +339,7 @@ struct ProviderMetricsInlineView: View { if let tokenUsage = self.model.tokenUsage { ProviderMetricInlineTextRow( - title: "Cost", + title: L10n.tr("settings.providers.detail.label.cost", fallback: "Cost"), value: tokenUsage.sessionLine, labelWidth: self.labelWidth) ProviderMetricInlineTextRow( @@ -322,9 +353,12 @@ struct ProviderMetricsInlineView: View { private var placeholderText: String { if !self.isEnabled { - return "Disabled — no recent data" + return L10n.tr( + "settings.providers.metrics.placeholder.disabled_no_data", + fallback: "Disabled — no recent data") } - return self.model.placeholder ?? "No usage yet" + return self.model.placeholder ?? + L10n.tr("settings.providers.metrics.placeholder.no_usage", fallback: "No usage yet") } } @@ -433,11 +467,14 @@ private struct ProviderMetricInlineCostRow: View { UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, - accessibilityLabel: "Usage used") + accessibilityLabel: L10n.tr("settings.providers.cost.accessibility.usage_used", fallback: "Usage used")) .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(String(format: "%.0f%% used", self.section.percentUsed)) + Text(String( + format: L10n.tr("settings.providers.cost.percent_used", fallback: "%.0f%% used"), + locale: .current, + self.section.percentUsed)) .font(.footnote) .foregroundStyle(.secondary) .monospacedDigit() diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift index 55d45fcbb..613a975d2 100644 --- a/Sources/CodexBar/PreferencesProviderErrorView.swift +++ b/Sources/CodexBar/PreferencesProviderErrorView.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI struct ProviderErrorDisplay: Sendable { @@ -26,7 +27,7 @@ struct ProviderErrorView: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help("Copy error") + .help(L10n.tr("settings.providers.error.copy", fallback: "Copy error")) } Text(self.display.preview) @@ -36,7 +37,12 @@ struct ProviderErrorView: View { .fixedSize(horizontal: false, vertical: true) if self.display.preview != self.display.full { - Button(self.isExpanded ? "Hide details" : "Show details") { self.isExpanded.toggle() } + Button(self.isExpanded + ? L10n.tr("settings.providers.error.hide_details", fallback: "Hide details") + : L10n.tr("settings.providers.error.show_details", fallback: "Show details")) + { + self.isExpanded.toggle() + } .buttonStyle(.link) .font(.footnote) } diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 5d7abde9a..d1bf61873 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI struct ProviderSettingsSection: View { @@ -218,7 +219,7 @@ struct ProviderSettingsTokenAccountsRowView: View { let accounts = self.descriptor.accounts() if accounts.isEmpty { - Text("No token accounts yet.") + Text(L10n.tr("settings.providers.token_accounts.empty", fallback: "No token accounts yet.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -235,7 +236,10 @@ struct ProviderSettingsTokenAccountsRowView: View { .pickerStyle(.menu) .controlSize(.small) - Button("Remove selected account") { + Button(L10n.tr( + "settings.providers.token_accounts.remove_selected", + fallback: "Remove selected account")) + { let account = accounts[selectedIndex] self.descriptor.removeAccount(account.id) } @@ -244,13 +248,15 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) + TextField( + L10n.tr("settings.providers.token_accounts.label_placeholder", fallback: "Label"), + text: self.$newLabel) .textFieldStyle(.roundedBorder) .font(.footnote) SecureField(self.descriptor.placeholder, text: self.$newToken) .textFieldStyle(.roundedBorder) .font(.footnote) - Button("Add") { + Button(L10n.tr("settings.providers.token_accounts.add", fallback: "Add")) { let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty, !token.isEmpty else { return } @@ -265,12 +271,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 10) { - Button("Open token file") { + Button(L10n.tr("settings.providers.token_accounts.open_token_file", fallback: "Open token file")) { self.descriptor.openConfigFile() } .buttonStyle(.link) .controlSize(.small) - Button("Reload") { + Button(L10n.tr("settings.providers.token_accounts.reload", fallback: "Reload")) { self.descriptor.reloadFromDisk() } .buttonStyle(.link) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index ee34cb3e7..c23a202dc 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -62,7 +62,7 @@ private struct ProviderSidebarRowView: View { .contentShape(Rectangle()) .padding(.vertical, 4) .padding(.horizontal, 2) - .help("Drag to reorder") + .help(L10n.tr("settings.providers.sidebar.reorder.help", fallback: "Drag to reorder")) .onDrag { self.draggingProvider = self.provider return NSItemProvider(object: self.provider.rawValue as NSString) @@ -106,12 +106,13 @@ private struct ProviderSidebarRowView: View { private var statusText: String { guard !self.isEnabled else { return self.subtitle } let lines = self.subtitle.split(separator: "\n", omittingEmptySubsequences: false) + let format = L10n.tr("settings.providers.sidebar.status.disabled_prefix", fallback: "Disabled — %@") if lines.count >= 2 { let first = lines[0] let rest = lines.dropFirst().joined(separator: "\n") - return "Disabled — \(first)\n\(rest)" + return String(format: format, locale: .current, "\(first)\n\(rest)") } - return "Disabled — \(self.subtitle)" + return String(format: format, locale: .current, self.subtitle) } } @@ -135,7 +136,7 @@ private struct ProviderSidebarReorderHandle: View { width: ProviderSettingsMetrics.reorderHandleSize, height: ProviderSettingsMetrics.reorderHandleSize) .foregroundStyle(.tertiary) - .accessibilityLabel("Reorder") + .accessibilityLabel(L10n.tr("settings.providers.sidebar.accessibility.reorder", fallback: "Reorder")) } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 379487ace..13bc8f53e 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -48,7 +48,7 @@ struct ProvidersPane: View { } }) } else { - Text("Select a provider") + Text(L10n.tr("settings.providers.select_provider", fallback: "Select a provider")) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } @@ -76,7 +76,9 @@ struct ProvidersPane: View { active.onConfirm() self.activeConfirmation = nil } - Button("Cancel", role: .cancel) { self.activeConfirmation = nil } + Button(L10n.tr("settings.providers.alert.cancel", fallback: "Cancel"), role: .cancel) { + self.activeConfirmation = nil + } } }, message: { @@ -113,9 +115,9 @@ struct ProvidersPane: View { let relative = snapshot.updatedAt.relativeDescription() usageText = relative } else if self.store.isStale(provider: provider) { - usageText = "last fetch failed" + usageText = L10n.tr("settings.providers.subtitle.last_fetch_failed", fallback: "last fetch failed") } else { - usageText = "usage not fetched yet" + usageText = L10n.tr("settings.providers.subtitle.not_fetched_yet", fallback: "usage not fetched yet") } let presentationContext = ProviderPresentationContext( @@ -256,24 +258,41 @@ struct ProvidersPane: View { if provider == .zai { return nil } let metadata = self.store.metadata(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) + let primaryFormat = L10n.tr( + "settings.providers.menu_bar_metric.option.primary_with_label", + fallback: "Primary (%@)") + let secondaryFormat = L10n.tr( + "settings.providers.menu_bar_metric.option.secondary_with_label", + fallback: "Secondary (%@)") var options: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.automatic.rawValue, + title: MenuBarMetricPreference.automatic.label), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (\(metadata.sessionLabel))"), + title: String(format: primaryFormat, locale: .current, metadata.sessionLabel)), ProviderSettingsPickerOption( id: MenuBarMetricPreference.secondary.rawValue, - title: "Secondary (\(metadata.weeklyLabel))"), + title: String(format: secondaryFormat, locale: .current, metadata.weeklyLabel)), ] if supportsAverage { + let averageFormat = L10n.tr( + "settings.providers.menu_bar_metric.option.average_with_labels", + fallback: "Average (%@ + %@)") options.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, - title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) + title: String( + format: averageFormat, + locale: .current, + metadata.sessionLabel, + metadata.weeklyLabel))) } return ProviderSettingsPickerDescriptor( id: "menuBarMetric", - title: "Menu bar metric", - subtitle: "Choose which window drives the menu bar percent.", + title: L10n.tr("settings.providers.menu_bar_metric.title", fallback: "Menu bar metric"), + subtitle: L10n.tr( + "settings.providers.menu_bar_metric.subtitle", + fallback: "Choose which window drives the menu bar percent."), binding: Binding( get: { self.settings.menuBarMetricPreference(for: provider).rawValue }, set: { rawValue in @@ -320,6 +339,7 @@ struct ProvidersPane: View { let input = UsageMenuCardView.Model.Input( provider: provider, metadata: metadata, + sourceLabel: self.store.sourceLabel(for: provider), snapshot: snapshot, credits: credits, creditsError: creditsError, diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 39413302c..2952419b9 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -1,4 +1,5 @@ import AppKit +import CodexBarCore import SwiftUI enum PreferencesTab: String, Hashable { @@ -34,28 +35,52 @@ struct PreferencesView: View { var body: some View { TabView(selection: self.$selection.tab) { GeneralPane(settings: self.settings, store: self.store) - .tabItem { Label("General", systemImage: "gearshape") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.general", fallback: "General"), + systemImage: "gearshape") + } .tag(PreferencesTab.general) ProvidersPane(settings: self.settings, store: self.store) - .tabItem { Label("Providers", systemImage: "square.grid.2x2") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.providers", fallback: "Providers"), + systemImage: "square.grid.2x2") + } .tag(PreferencesTab.providers) DisplayPane(settings: self.settings) - .tabItem { Label("Display", systemImage: "eye") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.display", fallback: "Display"), + systemImage: "eye") + } .tag(PreferencesTab.display) AdvancedPane(settings: self.settings) - .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.advanced", fallback: "Advanced"), + systemImage: "slider.horizontal.3") + } .tag(PreferencesTab.advanced) AboutPane(updater: self.updater) - .tabItem { Label("About", systemImage: "info.circle") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.about", fallback: "About"), + systemImage: "info.circle") + } .tag(PreferencesTab.about) if self.settings.debugMenuEnabled { DebugPane(settings: self.settings, store: self.store) - .tabItem { Label("Debug", systemImage: "ladybug") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.debug", fallback: "Debug"), + systemImage: "ladybug") + } .tag(PreferencesTab.debug) } } diff --git a/Sources/CodexBar/ProviderSwitcherButtons.swift b/Sources/CodexBar/ProviderSwitcherButtons.swift index 05ce53c53..6fd47f71d 100644 --- a/Sources/CodexBar/ProviderSwitcherButtons.swift +++ b/Sources/CodexBar/ProviderSwitcherButtons.swift @@ -66,6 +66,13 @@ final class InlineIconToggleButton: NSButton { self.titleField.textColor = color } + func setIconSize(_ size: CGFloat) { + guard self.iconSizeConstraints.count == 2 else { return } + self.iconSizeConstraints[0].constant = size + self.iconSizeConstraints[1].constant = size + if !self.isConfiguring { self.invalidateIntrinsicContentSize() } + } + func setTitleFontSize(_ size: CGFloat) { self.titleField.font = NSFont.systemFont(ofSize: size) } @@ -109,7 +116,7 @@ final class InlineIconToggleButton: NSButton { self.controlSize = .small self.wantsLayer = true - self.iconView.imageScaling = .scaleNone + self.iconView.imageScaling = .scaleProportionallyUpOrDown self.iconView.translatesAutoresizingMaskIntoConstraints = false self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) self.titleField.alignment = .left @@ -195,6 +202,13 @@ final class StackedToggleButton: NSButton { self.titleField.textColor = color } + func setIconSize(_ size: CGFloat) { + guard self.iconSizeConstraints.count == 2 else { return } + self.iconSizeConstraints[0].constant = size + self.iconSizeConstraints[1].constant = size + if !self.isConfiguring { self.invalidateIntrinsicContentSize() } + } + func setTitleFontSize(_ size: CGFloat) { self.titleField.font = NSFont.systemFont(ofSize: size) } @@ -238,7 +252,7 @@ final class StackedToggleButton: NSButton { self.controlSize = .small self.wantsLayer = true - self.iconView.imageScaling = .scaleNone + self.iconView.imageScaling = .scaleProportionallyUpOrDown self.iconView.translatesAutoresizingMaskIntoConstraints = false self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize - 2) self.titleField.alignment = .center diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift new file mode 100644 index 000000000..7fb601c06 --- /dev/null +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct AntigravityProxyProviderImplementation: ProviderImplementation { + let id: UsageProvider = .antigravityproxy + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.cliProxyGlobalBaseURL + _ = settings.cliProxyGlobalManagementKey + _ = settings.cliProxyGlobalAuthIndex + _ = settings.codexCLIProxyBaseURL + _ = settings.codexCLIProxyManagementKey + _ = settings.codexCLIProxyAuthIndex + } + + @MainActor + func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? { + "cliproxy-api" + } + + @MainActor + func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode { + .api + } +} diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 35baa270d..921460c98 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -11,7 +11,8 @@ struct CodexProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { context in - context.store.version(for: context.provider) ?? "not detected" + context.store.version(for: context.provider) + ?? L10n.tr("provider.codex.version.not_detected", fallback: "not detected") } } @@ -20,6 +21,9 @@ struct CodexProviderImplementation: ProviderImplementation { _ = settings.codexUsageDataSource _ = settings.codexCookieSource _ = settings.codexCookieHeader + _ = settings.codexCLIProxyBaseURL + _ = settings.codexCLIProxyManagementKey + _ = settings.codexCLIProxyAuthIndex } @MainActor @@ -49,6 +53,7 @@ struct CodexProviderImplementation: ProviderImplementation { switch context.settings.codexUsageDataSource { case .auto: .auto case .oauth: .oauth + case .api: .auto case .cli: .cli } } @@ -73,8 +78,10 @@ struct CodexProviderImplementation: ProviderImplementation { return [ ProviderSettingsToggleDescriptor( id: "codex-openai-web-extras", - title: "OpenAI web extras", - subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com.", + title: L10n.tr("provider.codex.toggle.openai_web_extras.title", fallback: "OpenAI web extras"), + subtitle: L10n.tr( + "provider.codex.toggle.openai_web_extras.subtitle", + fallback: "Show usage breakdown, credits history, and code review via chatgpt.com."), binding: extrasBinding, statusText: nil, actions: [], @@ -98,7 +105,7 @@ struct CodexProviderImplementation: ProviderImplementation { context.settings.codexCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) - let usageOptions = CodexUsageDataSource.allCases.map { + let usageOptions = CodexUsageDataSource.allCases.filter { $0 != .api }.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } let cookieOptions = ProviderCookieSourceUI.options( @@ -109,16 +116,24 @@ struct CodexProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.codexCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies for dashboard extras.", - manual: "Paste a Cookie header from a chatgpt.com request.", - off: "Disable OpenAI dashboard cookie usage.") + auto: L10n.tr( + "provider.codex.picker.cookie_source.auto", + fallback: "Automatic imports browser cookies for dashboard extras."), + manual: L10n.tr( + "provider.codex.picker.cookie_source.manual", + fallback: "Paste a Cookie header from a chatgpt.com request."), + off: L10n.tr( + "provider.codex.picker.cookie_source.off", + fallback: "Disable OpenAI dashboard cookie usage.")) } return [ ProviderSettingsPickerDescriptor( id: "codex-usage-source", - title: "Usage source", - subtitle: "Auto falls back to the next source if the preferred one fails.", + title: L10n.tr("provider.codex.picker.usage_source.title", fallback: "Usage source"), + subtitle: L10n.tr( + "provider.codex.picker.usage_source.subtitle", + fallback: "Auto falls back to the next source if the preferred one fails."), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -130,8 +145,10 @@ struct CodexProviderImplementation: ProviderImplementation { }), ProviderSettingsPickerDescriptor( id: "codex-cookie-source", - title: "OpenAI cookies", - subtitle: "Automatic imports browser cookies for dashboard extras.", + title: L10n.tr("provider.codex.picker.cookie_source.title", fallback: "OpenAI cookies"), + subtitle: L10n.tr( + "provider.codex.picker.cookie_source.subtitle", + fallback: "Automatic imports browser cookies for dashboard extras."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -140,7 +157,8 @@ struct CodexProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + let format = L10n.tr("provider.codex.cookie.cached", fallback: "Cached: %@ • %@") + return String(format: format, locale: .current, entry.sourceLabel, when) }), ] } @@ -153,7 +171,7 @@ struct CodexProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: L10n.tr("provider.codex.field.cookie_header.placeholder", fallback: "Cookie: …"), binding: context.stringBinding(\.codexCookieHeader), actions: [], isVisible: { @@ -168,13 +186,19 @@ struct CodexProviderImplementation: ProviderImplementation { guard context.settings.showOptionalCreditsAndExtraUsage, context.metadata.supportsCredits else { return } + let isCLIProxySource = context.store.sourceLabel(for: .codex) + .localizedCaseInsensitiveContains("cliproxy-api") if let credits = context.store.credits { - entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary)) + let creditsValue = UsageFormatter.creditsString(from: credits.remaining) + let creditsFormat = L10n.tr("provider.codex.menu.credits", fallback: "Credits: %@") + entries.append(.text(String(format: creditsFormat, locale: .current, creditsValue), .primary)) if let latest = credits.events.first { - entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary)) + let spendValue = UsageFormatter.creditEventSummary(latest) + let spendFormat = L10n.tr("provider.codex.menu.last_spend", fallback: "Last spend: %@") + entries.append(.text(String(format: spendFormat, locale: .current, spendValue), .secondary)) } - } else { + } else if !isCLIProxySource { let hint = context.store.lastCreditsError ?? context.metadata.creditsHint entries.append(.text(hint, .secondary)) } diff --git a/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift new file mode 100644 index 000000000..1b128d4c5 --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct CodexProxyProviderImplementation: ProviderImplementation { + let id: UsageProvider = .codexproxy + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.cliProxyGlobalBaseURL + _ = settings.cliProxyGlobalManagementKey + _ = settings.cliProxyGlobalAuthIndex + _ = settings.codexCLIProxyBaseURL + _ = settings.codexCLIProxyManagementKey + _ = settings.codexCLIProxyAuthIndex + } + + @MainActor + func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? { + "cliproxy-api" + } + + @MainActor + func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode { + .api + } +} diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 335dbf411..5113062c6 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -11,6 +11,7 @@ extension SettingsStore { let source: ProviderSourceMode? = switch newValue { case .auto: .auto case .oauth: .oauth + case .api: .api case .cli: .cli } self.updateProviderConfig(provider: .codex) { entry in @@ -44,15 +45,69 @@ extension SettingsStore { } } + var codexCLIProxyBaseURL: String { + get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIBaseURL ?? "" } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.apiBaseURL = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .codex, field: "apiBaseURL", value: newValue) + } + } + + var codexCLIProxyManagementKey: String { + get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .codex, field: "apiKey", value: newValue) + } + } + + var codexCLIProxyAuthIndex: String { + get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIAuthIndex ?? "" } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.apiAuthIndex = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .codex, field: "apiAuthIndex", value: newValue) + } + } + func ensureCodexCookieLoaded() {} } extension SettingsStore { func codexSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CodexProviderSettings { - ProviderSettingsSnapshot.CodexProviderSettings( + let resolvedBaseURL: String = { + let globalValue = self.cliProxyGlobalBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !globalValue.isEmpty { return globalValue } + let providerValue = self.codexCLIProxyBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !providerValue.isEmpty { return providerValue } + return globalValue + }() + let resolvedManagementKey: String = { + let globalValue = self.cliProxyGlobalManagementKey.trimmingCharacters(in: .whitespacesAndNewlines) + if !globalValue.isEmpty { return globalValue } + let providerValue = self.codexCLIProxyManagementKey.trimmingCharacters(in: .whitespacesAndNewlines) + if !providerValue.isEmpty { return providerValue } + return globalValue + }() + let resolvedAuthIndex: String = { + let globalValue = self.cliProxyGlobalAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines) + if !globalValue.isEmpty { return globalValue } + let providerValue = self.codexCLIProxyAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines) + if !providerValue.isEmpty { return providerValue } + return globalValue + }() + return ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: self.codexUsageDataSource, cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride)) + manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride), + cliProxyBaseURL: resolvedBaseURL, + cliProxyManagementKey: resolvedManagementKey, + cliProxyAuthIndex: resolvedAuthIndex) } private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { @@ -94,4 +149,29 @@ extension SettingsStore { if self.tokenAccounts(for: .codex).isEmpty { return fallback } return .manual } + + func migrateLegacyCodexCLIProxyDefaultsIfNeeded() { + guard let entry = self.configSnapshot.providerConfig(for: .codex) else { return } + + let legacyBaseURL = entry.sanitizedAPIBaseURL?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let legacyManagementKey = entry.sanitizedAPIKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let legacyAuthIndex = entry.sanitizedAPIAuthIndex?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + let globalBaseURL = self.cliProxyGlobalBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldAdoptLegacyBaseURL = globalBaseURL.isEmpty || + (globalBaseURL == CodexCLIProxySettings.defaultBaseURL && !legacyBaseURL.isEmpty && legacyBaseURL != globalBaseURL) + if shouldAdoptLegacyBaseURL, !legacyBaseURL.isEmpty { + self.cliProxyGlobalBaseURL = legacyBaseURL + } + + let globalManagementKey = self.cliProxyGlobalManagementKey.trimmingCharacters(in: .whitespacesAndNewlines) + if globalManagementKey.isEmpty, !legacyManagementKey.isEmpty { + self.cliProxyGlobalManagementKey = legacyManagementKey + } + + let globalAuthIndex = self.cliProxyGlobalAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines) + if globalAuthIndex.isEmpty, !legacyAuthIndex.isEmpty { + self.cliProxyGlobalAuthIndex = legacyAuthIndex + } + } } diff --git a/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift new file mode 100644 index 000000000..658262867 --- /dev/null +++ b/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct GeminiProxyProviderImplementation: ProviderImplementation { + let id: UsageProvider = .geminiproxy + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.cliProxyGlobalBaseURL + _ = settings.cliProxyGlobalManagementKey + _ = settings.cliProxyGlobalAuthIndex + _ = settings.codexCLIProxyBaseURL + _ = settings.codexCLIProxyManagementKey + _ = settings.codexCLIProxyAuthIndex + } + + @MainActor + func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? { + "cliproxy-api" + } + + @MainActor + func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode { + .api + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f6a9b2a3b..0334d0c93 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -13,6 +13,9 @@ enum ProviderImplementationRegistry { private static func makeImplementation(for provider: UsageProvider) -> (any ProviderImplementation) { switch provider { case .codex: CodexProviderImplementation() + case .codexproxy: CodexProxyProviderImplementation() + case .geminiproxy: GeminiProxyProviderImplementation() + case .antigravityproxy: AntigravityProxyProviderImplementation() case .claude: ClaudeProviderImplementation() case .cursor: CursorProviderImplementation() case .opencode: OpenCodeProviderImplementation() diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 0e06b99fc..d053df457 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -274,6 +274,47 @@ extension SettingsStore { } } + var appLanguage: AppLanguageOption { + get { AppLanguageOption(rawValue: self.defaultsState.appLanguageRaw) ?? .system } + set { + self.defaultsState.appLanguageRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "appLanguageCode") + switch newValue { + case .system: + self.userDefaults.removeObject(forKey: "AppleLanguages") + case .english, .simplifiedChinese: + self.userDefaults.set([newValue.rawValue], forKey: "AppleLanguages") + } + } + } + + var cliProxyGlobalBaseURL: String { + get { self.defaultsState.cliProxyGlobalBaseURL } + set { + let normalized = self.normalizedConfigValue(newValue) ?? "" + self.defaultsState.cliProxyGlobalBaseURL = normalized + self.userDefaults.set(normalized, forKey: "cliProxyGlobalBaseURL") + } + } + + var cliProxyGlobalManagementKey: String { + get { self.defaultsState.cliProxyGlobalManagementKey } + set { + let normalized = self.normalizedConfigValue(newValue) ?? "" + self.defaultsState.cliProxyGlobalManagementKey = normalized + self.userDefaults.set(normalized, forKey: "cliProxyGlobalManagementKey") + } + } + + var cliProxyGlobalAuthIndex: String { + get { self.defaultsState.cliProxyGlobalAuthIndex } + set { + let normalized = self.normalizedConfigValue(newValue) ?? "" + self.defaultsState.cliProxyGlobalAuthIndex = normalized + self.userDefaults.set(normalized, forKey: "cliProxyGlobalAuthIndex") + } + } + var debugLoadingPattern: LoadingPattern? { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index e140de0ac..100b1aeda 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -28,12 +28,18 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { var label: String { switch self { - case .manual: "Manual" - case .oneMinute: "1 min" - case .twoMinutes: "2 min" - case .fiveMinutes: "5 min" - case .fifteenMinutes: "15 min" - case .thirtyMinutes: "30 min" + case .manual: + L10n.tr("settings.general.refresh_frequency.manual", fallback: "Manual") + case .oneMinute: + L10n.tr("settings.general.refresh_frequency.one_minute", fallback: "1 min") + case .twoMinutes: + L10n.tr("settings.general.refresh_frequency.two_minutes", fallback: "2 min") + case .fiveMinutes: + L10n.tr("settings.general.refresh_frequency.five_minutes", fallback: "5 min") + case .fifteenMinutes: + L10n.tr("settings.general.refresh_frequency.fifteen_minutes", fallback: "15 min") + case .thirtyMinutes: + L10n.tr("settings.general.refresh_frequency.thirty_minutes", fallback: "30 min") } } } @@ -50,10 +56,35 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { var label: String { switch self { - case .automatic: "Automatic" - case .primary: "Primary" - case .secondary: "Secondary" - case .average: "Average" + case .automatic: + L10n.tr("settings.providers.menu_bar_metric.option.automatic", fallback: "Automatic") + case .primary: + L10n.tr("settings.providers.menu_bar_metric.option.primary", fallback: "Primary") + case .secondary: + L10n.tr("settings.providers.menu_bar_metric.option.secondary", fallback: "Secondary") + case .average: + L10n.tr("settings.providers.menu_bar_metric.option.average", fallback: "Average") + } + } +} + +enum AppLanguageOption: String, CaseIterable, Identifiable { + case system + case english = "en" + case simplifiedChinese = "zh-Hans" + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .system: + return L10n.tr("settings.general.language.option.system", fallback: "System") + case .english: + return L10n.tr("settings.general.language.option.english", fallback: "English") + case .simplifiedChinese: + return L10n.tr("settings.general.language.option.zh_hans", fallback: "简体中文") } } } @@ -139,7 +170,11 @@ final class SettingsStore { self.config = config self.configLoading = true self.defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) + if AppLanguageOption(rawValue: self.defaultsState.appLanguageRaw) == .system { + self.userDefaults.removeObject(forKey: "AppleLanguages") + } self.updateProviderState(config: config) + self.migrateLegacyCodexCLIProxyDefaultsIfNeeded() self.configLoading = false CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled) userDefaults.removeObject(forKey: "showCodexUsage") @@ -215,6 +250,10 @@ extension SettingsStore { let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false + let appLanguageRaw = userDefaults.string(forKey: "appLanguageCode") ?? AppLanguageOption.system.rawValue + let cliProxyGlobalBaseURL = userDefaults.string(forKey: "cliProxyGlobalBaseURL") ?? "" + let cliProxyGlobalManagementKey = userDefaults.string(forKey: "cliProxyGlobalManagementKey") ?? "" + let cliProxyGlobalAuthIndex = userDefaults.string(forKey: "cliProxyGlobalAuthIndex") ?? "" return SettingsDefaultsState( refreshFrequency: refreshFrequency, @@ -244,7 +283,11 @@ extension SettingsStore { mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, selectedMenuProviderRaw: selectedMenuProviderRaw, - providerDetectionCompleted: providerDetectionCompleted) + providerDetectionCompleted: providerDetectionCompleted, + appLanguageRaw: appLanguageRaw, + cliProxyGlobalBaseURL: cliProxyGlobalBaseURL, + cliProxyGlobalManagementKey: cliProxyGlobalManagementKey, + cliProxyGlobalAuthIndex: cliProxyGlobalAuthIndex) } } diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 9d8e833ba..632b01417 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -29,4 +29,8 @@ struct SettingsDefaultsState: Sendable { var switcherShowsIcons: Bool var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool + var appLanguageRaw: String + var cliProxyGlobalBaseURL: String + var cliProxyGlobalManagementKey: String + var cliProxyGlobalAuthIndex: String } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index a86e51444..18def1dd6 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -32,7 +32,11 @@ extension StatusItemController { let meta = self.store.metadata(for: provider) // For Claude, route subscription users to claude.ai/settings/usage instead of console billing - let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() { + let urlString: String? = if self.isCLIProxyDashboardProvider(provider), + let cliProxyDashboardURL = self.cliProxyUsageDashboardURL() + { + cliProxyDashboardURL.absoluteString + } else if provider == .claude, self.store.isClaudeSubscription() { meta.subscriptionDashboardURL ?? meta.dashboardURL } else { meta.dashboardURL @@ -70,6 +74,25 @@ extension StatusItemController { return url.absoluteString } + private func isCLIProxyDashboardProvider(_ provider: UsageProvider) -> Bool { + provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy + } + + private func cliProxyUsageDashboardURL() -> URL? { + let providerSettings = self.settings.codexSettingsSnapshot(tokenOverride: nil) + guard let cliProxySettings = CodexCLIProxySettings.resolve( + providerSettings: providerSettings, + environment: ProcessInfo.processInfo.environment) + else { + return nil + } + + let dashboardURL = cliProxySettings.baseURL.appendingPathComponent("management.html", isDirectory: false) + guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else { return dashboardURL } + components.fragment = "/usage" + return components.url ?? dashboardURL + } + @objc func openStatusPage() { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 2fb70778e..ac2a7cf29 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -187,7 +187,6 @@ extension StatusItemController { let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed - let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent let primaryProvider = self.primaryProviderForUnifiedIcon() let snapshot = self.store.snapshot(for: primaryProvider) @@ -233,15 +232,6 @@ extension StatusItemController { return .none }() - if showBrandPercent, - let brand = ProviderBrandIcon.image(for: primaryProvider) - { - let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) - self.setButtonImage(brand, for: button) - self.setButtonTitle(displayText, for: button) - return - } - self.setButtonTitle(nil, for: button) if let morphProgress { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) @@ -267,16 +257,6 @@ extension StatusItemController { // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". let showUsed = self.settings.usageBarsShowUsed - let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent - - if showBrandPercent, - let brand = ProviderBrandIcon.image(for: provider) - { - let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot) - self.setButtonImage(brand, for: button) - self.setButtonTitle(displayText, for: button) - return - } var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent var credits: Double? = provider == .codex ? self.store.credits?.remaining : nil diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2167b2914..a68cf13e4 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -279,6 +279,47 @@ extension StatusItemController { private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots + let shouldShowAggregateCard = self.isCLIProxyMultiAuthDisplay( + provider: context.currentProvider, + display: tokenAccountDisplay) + if shouldShowAggregateCard, let aggregateModel = self.menuCardModel(for: context.selectedProvider) { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: aggregateModel, width: context.menuWidth), + id: "menuCard-aggregate", + width: context.menuWidth)) + if !accountSnapshots.isEmpty { + menu.addItem(.separator()) + } + } + if shouldShowAggregateCard { + let entries = self.codexCLIProxyCompactEntries( + from: accountSnapshots, + provider: context.currentProvider) + if !entries.isEmpty { + let providerName = self.store.metadata(for: context.currentProvider).displayName + let compactView = CodexCLIProxyAuthCompactGridView( + providerDisplayName: providerName, + entries: entries) + menu.addItem(self.makeMenuCardItem( + compactView, + id: "menuCard-auth-grid", + width: context.menuWidth)) + } + if let inlineCostHistoryItem = self.makeCostHistoryInlineItem( + provider: context.currentProvider, + width: context.menuWidth) + { + if !entries.isEmpty { + menu.addItem(.separator()) + } + menu.addItem(inlineCostHistoryItem) + menu.addItem(.separator()) + } else if !entries.isEmpty { + menu.addItem(.separator()) + } + return false + } + let cards = accountSnapshots.isEmpty ? [] : accountSnapshots.compactMap { accountSnapshot in @@ -287,7 +328,8 @@ extension StatusItemController { snapshotOverride: accountSnapshot.snapshot, errorOverride: accountSnapshot.error) } - if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { + + if cards.isEmpty, !shouldShowAggregateCard, let model = self.menuCardModel(for: context.selectedProvider) { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", @@ -484,6 +526,20 @@ extension StatusItemController { } private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { + if self.isCLIProxyMultiAuthProvider(provider), + let snapshots = self.store.accountSnapshots[provider], + snapshots.count > 1, + self.store.sourceLabel(for: provider).localizedCaseInsensitiveContains("cliproxy-api") + { + return TokenAccountMenuDisplay( + provider: provider, + accounts: snapshots.map(\.account), + snapshots: snapshots, + activeIndex: 0, + showAll: true, + showSwitcher: false) + } + guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } @@ -499,6 +555,50 @@ extension StatusItemController { showSwitcher: !showAll) } + private func isCLIProxyMultiAuthProvider(_ provider: UsageProvider) -> Bool { + provider == .codex || provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy + } + + private func isCLIProxyMultiAuthDisplay( + provider: UsageProvider, + display: TokenAccountMenuDisplay) -> Bool + { + self.isCLIProxyMultiAuthProvider(provider) && + display.showAll && + self.store.sourceLabel(for: provider).localizedCaseInsensitiveContains("cliproxy-api") + } + + private func codexCLIProxyCompactEntries( + from snapshots: [TokenAccountUsageSnapshot], + provider: UsageProvider) -> [CodexCLIProxyAuthCompactGridView.Entry] + { + snapshots.map { snapshot in + let primary = self.percent(for: snapshot.snapshot?.primary) + let secondary = self.percent(for: snapshot.snapshot?.secondary) + let label = snapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let accountTitle: String + if label.isEmpty { + accountTitle = snapshot.snapshot?.accountEmail(for: provider) ?? provider.rawValue + } else { + accountTitle = label + } + return CodexCLIProxyAuthCompactGridView.Entry( + id: snapshot.id, + accountTitle: accountTitle, + primaryPercent: primary, + secondaryPercent: secondary, + hasError: snapshot.error != nil) + } + } + + private func percent(for window: RateWindow?) -> Double? { + guard let window else { return nil } + if self.settings.usageBarsShowUsed { + return max(0, min(100, window.usedPercent)) + } + return max(0, min(100, window.remainingPercent)) + } + private func menuNeedsRefresh(_ menu: NSMenu) -> Bool { let key = ObjectIdentifier(menu) return self.menuVersions[key] != self.menuContentVersion @@ -731,12 +831,25 @@ extension StatusItemController { topPadding: sectionSpacing, bottomPadding: bottomPadding, width: width) - let costSubmenu = webItems.hasCostHistory ? self.makeCostHistorySubmenu(provider: provider) : nil menu.addItem(self.makeMenuCardItem( costView, id: "menuCardCost", - width: width, - submenu: costSubmenu)) + width: width)) + + if let inlineCostHistoryItem = self.makeCostHistoryInlineItem(provider: provider, width: width) { + menu.addItem(.separator()) + menu.addItem(inlineCostHistoryItem) + } else if webItems.hasCostHistory { + let costSubmenu = self.makeCostHistorySubmenu(provider: provider) + if costSubmenu != nil { + // Fallback for non-rendering mode: still expose chart through submenu. + if let lastItem = menu.items.last { + lastItem.submenu = costSubmenu + lastItem.target = self + lastItem.action = #selector(self.menuCardNoOp(_:)) + } + } + } } } @@ -904,8 +1017,139 @@ extension StatusItemController { } } + private struct CodexCLIProxyAuthCompactGridView: View { + struct Entry: Identifiable { + let id: UUID + let accountTitle: String + let primaryPercent: Double? + let secondaryPercent: Double? + let hasError: Bool + } + + let providerDisplayName: String + let entries: [Entry] + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var columns: [GridItem] { + [ + GridItem(.flexible(minimum: 120), spacing: 8), + GridItem(.flexible(minimum: 120), spacing: 8), + ] + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + let titleFormat = L10n.tr( + "menu.cliproxy.auth_grid.title", + fallback: "%@ auth entries (%d)") + Text(String(format: titleFormat, locale: .current, self.providerDisplayName, self.entries.count)) + .font(.footnote.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + + LazyVGrid(columns: self.columns, spacing: 8) { + ForEach(self.entries) { entry in + AccountCell( + entry: entry, + isHighlighted: self.isHighlighted) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + + private struct AccountCell: View { + let entry: CodexCLIProxyAuthCompactGridView.Entry + let isHighlighted: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(self.entry.accountTitle) + .font(.caption.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + + HStack(spacing: 16) { + RingBadge( + percent: self.entry.primaryPercent, + isError: self.entry.hasError, + tint: Color(nsColor: NSColor.systemTeal), + isHighlighted: self.isHighlighted) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + RingBadge( + percent: self.entry.secondaryPercent, + isError: self.entry.hasError, + tint: Color(nsColor: NSColor.systemIndigo), + isHighlighted: self.isHighlighted) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(MenuHighlightStyle.progressTrack(self.isHighlighted))) + } + } + + private struct RingBadge: View { + let percent: Double? + let isError: Bool + let tint: Color + let isHighlighted: Bool + + private var normalizedPercent: Double { + guard let percent else { return 0 } + return max(0, min(100, percent)) + } + + var body: some View { + GeometryReader { proxy in + let diameter = min(proxy.size.width, proxy.size.height) + let lineWidth = max(3, diameter * 0.11) + let fontSize = max(10, diameter * 0.32) + + ZStack { + Circle() + .stroke(MenuHighlightStyle.progressTrack(self.isHighlighted), lineWidth: lineWidth) + Circle() + .trim(from: 0, to: self.normalizedPercent / 100) + .stroke( + MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .rotationEffect(.degrees(-90)) + + if self.isError { + Image(systemName: "xmark") + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + } else if self.percent == nil { + Text("—") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } else { + Text("\(Int(self.normalizedPercent.rounded()))") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + } + } + .frame(width: diameter, height: diameter) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + .aspectRatio(1, contentMode: .fit) + } + } + private func makeBuyCreditsItem() -> NSMenuItem { - let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") + let item = NSMenuItem( + title: L10n.tr("menu.action.buy_credits", fallback: "Buy Credits..."), + action: #selector(self.openCreditsPurchase), + keyEquivalent: "") item.target = self if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) { image.isTemplate = true @@ -918,7 +1162,7 @@ extension StatusItemController { @discardableResult private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu() else { return false } - let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: L10n.tr("menu.action.credits_history", fallback: "Credits history"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -928,7 +1172,7 @@ extension StatusItemController { @discardableResult private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } - let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: L10n.tr("menu.action.usage_breakdown", fallback: "Usage breakdown"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1060,7 +1304,9 @@ extension StatusItemController { } private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { + return nil + } let width = Self.menuCardBaseWidth guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } guard !tokenSnapshot.daily.isEmpty else { return nil } @@ -1096,6 +1342,25 @@ extension StatusItemController { return submenu } + private func makeCostHistoryInlineItem(provider: UsageProvider, width: CGFloat) -> NSMenuItem? { + guard Self.menuCardRenderingEnabled else { return nil } + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { + return nil + } + guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } + guard !tokenSnapshot.daily.isEmpty else { return nil } + + let chartView = CostHistoryChartMenuView( + provider: provider, + daily: tokenSnapshot.daily, + totalCostUSD: tokenSnapshot.last30DaysCostUSD, + width: width) + return self.makeMenuCardItem( + chartView, + id: "menuCardCostHistoryInline", + width: width) + } + private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ "usageBreakdownChart", @@ -1154,6 +1419,13 @@ extension StatusItemController { dashboardError = self.store.lastOpenAIDashboardError tokenSnapshot = self.store.tokenSnapshot(for: target) tokenError = self.store.tokenError(for: target) + } else if target == .codexproxy, snapshotOverride == nil { + credits = nil + creditsError = nil + dashboard = nil + dashboardError = nil + tokenSnapshot = self.store.tokenSnapshot(for: target) + tokenError = self.store.tokenError(for: target) } else if target == .claude || target == .vertexai, snapshotOverride == nil { credits = nil creditsError = nil @@ -1173,6 +1445,7 @@ extension StatusItemController { let input = UsageMenuCardView.Model.Input( provider: target, metadata: metadata, + sourceLabel: self.store.sourceLabel(for: target), snapshot: snapshot, credits: credits, creditsError: creditsError, diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 98dcb693d..4d64affeb 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -100,6 +100,11 @@ final class ProviderSwitcherView: NSView { count: layoutCount, outerPadding: outerPadding, minimumGap: minimumGap) + let iconPointSize = Self.switcherIconPointSize( + stackedIcons: self.stackedIcons, + rowCount: self.rowCount, + segmentCount: self.segments.count, + maxAllowedSegmentWidth: maxAllowedSegmentWidth) func makeButton(index: Int, segment: Segment) -> NSButton { let button: NSButton @@ -113,6 +118,7 @@ final class ProviderSwitcherView: NSView { if self.rowCount >= 4 { stacked.setTitleFontSize(NSFont.smallSystemFontSize - 3) } + stacked.setIconSize(iconPointSize) button = stacked } else if self.showsIcons { let inline = InlineIconToggleButton( @@ -120,6 +126,7 @@ final class ProviderSwitcherView: NSView { image: segment.image, target: self, action: #selector(self.handleSelection(_:))) + inline.setIconSize(iconPointSize) button = inline } else { button = PaddedToggleButton( @@ -151,7 +158,7 @@ final class ProviderSwitcherView: NSView { button.wantsLayer = true button.layer?.cornerRadius = 6 button.state = (selected == segment.provider) ? .on : .off - button.toolTip = nil + button.toolTip = ProviderDescriptorRegistry.descriptor(for: segment.provider).metadata.displayName button.translatesAutoresizingMaskIntoConstraints = false self.buttons.append(button) return button @@ -457,6 +464,21 @@ final class ProviderSwitcherView: NSView { return rows } + private static func switcherIconPointSize( + stackedIcons: Bool, + rowCount: Int, + segmentCount: Int, + maxAllowedSegmentWidth: CGFloat) -> CGFloat + { + if !stackedIcons { + return maxAllowedSegmentWidth < 72 ? 14 : 16 + } + if rowCount >= 4 || maxAllowedSegmentWidth < 44 { return 11 } + if rowCount >= 3 || maxAllowedSegmentWidth < 50 { return 12 } + if segmentCount >= 7 || maxAllowedSegmentWidth < 58 { return 13 } + return 14 + } + private static func switcherOuterPadding(for width: CGFloat, count: Int, minimumGap: CGFloat) -> CGFloat { // Align with the card's left/right content grid when possible. let preferred: CGFloat = 16 @@ -754,7 +776,16 @@ final class ProviderSwitcherView: NSView { } private static func switcherTitle(for provider: UsageProvider) -> String { - ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + switch provider { + case .codexproxy: + "CodexProxy" + case .geminiproxy: + "GemProxy" + case .antigravityproxy: + "AGProxy" + default: + ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + } } } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 0963b8a63..c342d7001 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -2,6 +2,12 @@ import CodexBarCore import Foundation extension UsageStore { + private enum CLIProxyMultiAuthRefreshState { + case notHandled + case success + case failure(Error) + } + /// Force refresh Augment session (called from UI button) func forceRefreshAugmentSession() async { await self.performRuntimeAction(.forceSessionRefresh, for: .augment) @@ -33,13 +39,28 @@ extension UsageStore { defer { self.refreshingProviders.remove(provider) } let tokenAccounts = self.tokenAccounts(for: provider) - if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { + let shouldFetchAllTokenAccounts = self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) + if shouldFetchAllTokenAccounts { await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) return - } else { - _ = await MainActor.run { - self.accountSnapshots.removeValue(forKey: provider) + } + + let cliProxyMultiAuthState = await self.refreshCLIProxyMultiAuthIfNeeded(provider: provider) + switch cliProxyMultiAuthState { + case .notHandled: + break + case .success: + if let runtime = self.providerRuntimes[provider] { + let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) + runtime.providerDidRefresh(context: context, provider: provider) + } + return + case let .failure(error): + if let runtime = self.providerRuntimes[provider] { + let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) + runtime.providerDidFail(context: context, provider: provider, error: error) } + return } let outcome = await spec.fetch() @@ -70,6 +91,13 @@ extension UsageStore { self.handleSessionQuotaTransition(provider: provider, snapshot: scoped) self.snapshots[provider] = scoped self.lastSourceLabels[provider] = result.sourceLabel + if !shouldFetchAllTokenAccounts { + self.accountSnapshots.removeValue(forKey: provider) + } + if provider == .codex { + self.credits = result.credits + self.lastCreditsError = nil + } self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() } @@ -84,6 +112,9 @@ extension UsageStore { let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + if !shouldFetchAllTokenAccounts { + self.accountSnapshots.removeValue(forKey: provider) + } if shouldSurface { self.errors[provider] = error.localizedDescription self.snapshots.removeValue(forKey: provider) @@ -98,4 +129,289 @@ extension UsageStore { } } } + + private func refreshCLIProxyMultiAuthIfNeeded(provider: UsageProvider) async -> CLIProxyMultiAuthRefreshState { + guard self.supportsCLIProxyMultiAuth(provider: provider) else { return .notHandled } + if provider == .codex, self.sourceMode(for: .codex) != .api { + return .notHandled + } + + let settingsSnapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) + let env = ProviderRegistry.makeEnvironment( + base: ProcessInfo.processInfo.environment, + provider: provider, + settings: self.settings, + tokenOverride: nil) + + guard let proxySettings = CodexCLIProxySettings.resolve( + providerSettings: settingsSnapshot.codex, + environment: env) + else { + return .notHandled + } + + guard proxySettings.authIndex == nil else { return .notHandled } + + let client = CodexCLIProxyManagementClient(settings: proxySettings) + let auths: [CodexCLIProxyResolvedAuth] + do { + auths = try await self.listCLIProxyAuths(provider: provider, client: client) + } catch { + return .notHandled + } + + guard auths.count > 1 else { return .notHandled } + + var accountSnapshots: [TokenAccountUsageSnapshot] = [] + accountSnapshots.reserveCapacity(auths.count) + + var successfulUsageSnapshots: [UsageSnapshot] = [] + successfulUsageSnapshots.reserveCapacity(auths.count) + + var creditBalances: [Double] = [] + creditBalances.reserveCapacity(auths.count) + + var firstError: Error? + for auth in auths { + let account = self.codexCLIProxyAccount(for: auth) + do { + let fetchResult = try await self.cliProxyFetchResult(provider: provider, auth: auth, client: client) + let mapped = fetchResult.snapshot + let labeled = self.applyAccountLabel(mapped, provider: provider, account: account) + successfulUsageSnapshots.append(labeled) + if let credits = fetchResult.credits { + creditBalances.append(credits.remaining) + } + accountSnapshots.append(TokenAccountUsageSnapshot( + account: account, + snapshot: labeled, + error: nil, + sourceLabel: "cliproxy-api")) + } catch { + if firstError == nil { firstError = error } + accountSnapshots.append(TokenAccountUsageSnapshot( + account: account, + snapshot: nil, + error: error.localizedDescription, + sourceLabel: "cliproxy-api")) + } + } + + let aggregatedCredits: CreditsSnapshot? = if creditBalances.isEmpty { + nil + } else { + CreditsSnapshot(remaining: creditBalances.reduce(0, +), events: [], updatedAt: Date()) + } + + if let aggregate = self.aggregateCodexCLIProxySnapshot( + successfulUsageSnapshots, + provider: provider, + totalAuthCount: auths.count) + { + await MainActor.run { + self.handleSessionQuotaTransition(provider: provider, snapshot: aggregate) + self.snapshots[provider] = aggregate + self.accountSnapshots[provider] = accountSnapshots + self.lastSourceLabels[provider] = "cliproxy-api" + self.lastFetchAttempts[provider] = [] + self.errors[provider] = nil + if provider == .codex { + self.credits = aggregatedCredits + self.lastCreditsError = nil + } + self.failureGates[provider]?.recordSuccess() + } + return .success + } + + let resolvedError = firstError ?? self.cliProxyMissingAuthError(for: provider, authIndex: nil) + await MainActor.run { + self.snapshots.removeValue(forKey: provider) + self.accountSnapshots[provider] = accountSnapshots + self.lastSourceLabels[provider] = "cliproxy-api" + self.lastFetchAttempts[provider] = [] + self.errors[provider] = resolvedError.localizedDescription + if provider == .codex { + self.credits = nil + self.lastCreditsError = nil + } + } + return .failure(resolvedError) + } + + private func supportsCLIProxyMultiAuth(provider: UsageProvider) -> Bool { + provider == .codex || provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy + } + + private func listCLIProxyAuths( + provider: UsageProvider, + client: CodexCLIProxyManagementClient) async throws -> [CodexCLIProxyResolvedAuth] + { + switch provider { + case .codex, .codexproxy: + return try await client.listCodexAuths() + case .geminiproxy: + return try await client.listGeminiAuths() + case .antigravityproxy: + return try await client.listAntigravityAuths() + default: + return [] + } + } + + private func cliProxyMissingAuthError(for provider: UsageProvider, authIndex: String?) -> CodexCLIProxyError { + switch provider { + case .codex, .codexproxy: + return .missingCodexAuth(authIndex) + case .geminiproxy: + return .missingProviderAuth(provider: "Gemini", authIndex: authIndex) + case .antigravityproxy: + return .missingProviderAuth(provider: "Antigravity", authIndex: authIndex) + default: + return .missingCodexAuth(authIndex) + } + } + + private func cliProxyFetchResult( + provider: UsageProvider, + auth: CodexCLIProxyResolvedAuth, + client: CodexCLIProxyManagementClient) async throws -> (snapshot: UsageSnapshot, credits: CreditsSnapshot?) + { + switch provider { + case .codex, .codexproxy: + let usage = try await client.fetchCodexUsage(auth: auth) + return ( + snapshot: self.codexUsageSnapshot(from: usage, auth: auth, provider: provider), + credits: provider == .codex ? self.codexCreditsSnapshot(from: usage) : nil + ) + case .geminiproxy: + let quota = try await client.fetchGeminiQuota(auth: auth) + return ( + snapshot: CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot( + from: quota, + auth: auth, + provider: .geminiproxy), + credits: nil + ) + case .antigravityproxy: + let quota = try await client.fetchAntigravityQuota(auth: auth) + return ( + snapshot: CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot( + from: quota, + auth: auth, + provider: .antigravityproxy), + credits: nil + ) + default: + throw self.cliProxyMissingAuthError(for: provider, authIndex: auth.authIndex) + } + } + + private func codexCLIProxyAccount(for auth: CodexCLIProxyResolvedAuth) -> ProviderTokenAccount { + ProviderTokenAccount( + id: UUID(), + label: self.codexCLIProxyAccountLabel(auth), + token: "", + addedAt: 0, + lastUsed: nil) + } + + private func codexCLIProxyAccountLabel(_ auth: CodexCLIProxyResolvedAuth) -> String { + if let email = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty { + return email + } + return auth.authIndex + } + + private func aggregateCodexCLIProxySnapshot( + _ snapshots: [UsageSnapshot], + provider: UsageProvider, + totalAuthCount: Int) -> UsageSnapshot? + { + guard !snapshots.isEmpty else { return nil } + + let primary = self.aggregateWindow(snapshots.compactMap(\.primary)) + let secondary = self.aggregateWindow(snapshots.compactMap(\.secondary)) + let tertiary = self.aggregateWindow(snapshots.compactMap(\.tertiary)) + + let loginMethods = Set( + snapshots.compactMap { snapshot in + snapshot.loginMethod(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + }.filter { !$0.isEmpty }) + let loginMethod = loginMethods.count == 1 ? loginMethods.first : nil + + let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let accountLabelFormat = L10n.tr( + "provider.cliproxy.aggregate.account_label", + fallback: "All %@ auth entries (%d)") + let accountLabel = String(format: accountLabelFormat, locale: .current, providerName, totalAuthCount) + let identity = ProviderIdentitySnapshot( + providerID: provider, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + updatedAt: Date(), + identity: identity) + } + + private func aggregateWindow(_ windows: [RateWindow]) -> RateWindow? { + guard !windows.isEmpty else { return nil } + let usedPercent = windows.map(\.usedPercent).reduce(0, +) / Double(windows.count) + let windowMinutes = windows.compactMap(\.windowMinutes).max() + let resetsAt = windows.compactMap(\.resetsAt).min() + let resetDescription = resetsAt.map { UsageFormatter.resetDescription(from: $0) } + return RateWindow( + usedPercent: usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: resetDescription) + } + + private func codexUsageSnapshot( + from usage: CodexUsageResponse, + auth: CodexCLIProxyResolvedAuth, + provider: UsageProvider) -> UsageSnapshot + { + let primary = self.codexRateWindow(from: usage.rateLimit?.primaryWindow) + ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let secondary = self.codexRateWindow(from: usage.rateLimit?.secondaryWindow) + let resolvedPlan = usage.planType?.rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackPlan = auth.planType?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (resolvedPlan?.isEmpty == false) ? resolvedPlan : fallbackPlan + let normalizedEmail = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines) + let identity = ProviderIdentitySnapshot( + providerID: provider, + accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, + accountOrganization: nil, + loginMethod: loginMethod?.isEmpty == true ? nil : loginMethod) + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + updatedAt: Date(), + identity: identity) + .scoped(to: provider) + } + + private func codexRateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let window else { return nil } + let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) + return RateWindow( + usedPercent: Double(window.usedPercent), + windowMinutes: window.limitWindowSeconds / 60, + resetsAt: resetDate, + resetDescription: UsageFormatter.resetDescription(from: resetDate)) + } + + private func codexCreditsSnapshot(from usage: CodexUsageResponse) -> CreditsSnapshot? { + guard let credits = usage.credits, credits.hasCredits, let balance = credits.balance else { return nil } + return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date()) + } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6b3672ed4..b24267b93 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -607,6 +607,15 @@ final class UsageStore { private func refreshCreditsIfNeeded() async { guard self.isEnabled(.codex) else { return } + if self.sourceMode(for: .codex) == .api { + await MainActor.run { + self.credits = nil + self.lastCreditsError = nil + self.lastCreditsSnapshot = nil + self.creditsFailureStreak = 0 + } + return + } do { let credits = try await self.codexFetcher.loadLatestCredits( keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) @@ -1140,6 +1149,20 @@ extension UsageStore { let raw = await self.codexFetcher.debugRawRateLimits() await MainActor.run { self.probeLogs[.codex] = raw } return raw + case .codexproxy: + let text = "CLIProxy Codex uses management API; use CodexBarCLI --provider codexproxy --source api for raw checks." + await MainActor.run { self.probeLogs[.codexproxy] = text } + return text + case .geminiproxy: + let text = + "CLIProxy Gemini uses management API; use CodexBarCLI --provider geminiproxy --source api for raw checks." + await MainActor.run { self.probeLogs[.geminiproxy] = text } + return text + case .antigravityproxy: + let text = + "CLIProxy Antigravity uses management API; use CodexBarCLI --provider antigravityproxy --source api for raw checks." + await MainActor.run { self.probeLogs[.antigravityproxy] = text } + return text case .claude: let text = await self.debugClaudeLog( claudeWebExtrasEnabled: claudeWebExtrasEnabled, @@ -1505,7 +1528,7 @@ extension UsageStore { } private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { - guard provider == .codex || provider == .claude || provider == .vertexai else { + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index d0ab5dfe6..12d9f0357 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -170,6 +170,16 @@ extension CodexBarCLI { tokenContext: TokenAccountCLIContext, command: UsageCommandContext) async -> UsageCommandOutput { + if provider == .codex, + let output = await Self.fetchCodexCLIProxyUsageOutputsIfNeeded( + provider: provider, + status: status, + tokenContext: tokenContext, + command: command) + { + return output + } + let accounts: [ProviderTokenAccount] do { accounts = try tokenContext.resolvedAccounts(for: provider) @@ -189,7 +199,8 @@ extension CodexBarCLI { account: account, status: status, tokenContext: tokenContext, - command: command) + command: command, + environmentOverride: [:]) output.merge(result) } return output @@ -227,13 +238,17 @@ extension CodexBarCLI { account: ProviderTokenAccount?, status: ProviderStatusPayload?, tokenContext: TokenAccountCLIContext, - command: UsageCommandContext) async -> UsageCommandOutput + command: UsageCommandContext, + environmentOverride: [String: String]) async -> UsageCommandOutput { var output = UsageCommandOutput() - let env = tokenContext.environment( + var env = tokenContext.environment( base: ProcessInfo.processInfo.environment, provider: provider, account: account) + for (key, value) in environmentOverride { + env[key] = value + } let settings = tokenContext.settingsSnapshot(for: provider, account: account) let configSource = tokenContext.preferredSourceMode(for: provider) let baseSource = command.sourceModeOverride ?? configSource @@ -350,6 +365,67 @@ extension CodexBarCLI { return output } + private static func fetchCodexCLIProxyUsageOutputsIfNeeded( + provider: UsageProvider, + status: ProviderStatusPayload?, + tokenContext: TokenAccountCLIContext, + command: UsageCommandContext) async -> UsageCommandOutput? + { + let configSource = tokenContext.preferredSourceMode(for: provider) + let baseSource = command.sourceModeOverride ?? configSource + let sourceMode = tokenContext.effectiveSourceMode(base: baseSource, provider: provider, account: nil) + guard sourceMode == .api else { return nil } + + let baseEnv = tokenContext.environment( + base: ProcessInfo.processInfo.environment, + provider: provider, + account: nil) + let settings = tokenContext.settingsSnapshot(for: provider, account: nil) + guard let proxySettings = CodexCLIProxySettings.resolve( + providerSettings: settings?.codex, + environment: baseEnv) + else { + return nil + } + guard proxySettings.authIndex == nil else { return nil } + + let client = CodexCLIProxyManagementClient(settings: proxySettings) + let auths: [CodexCLIProxyResolvedAuth] + do { + auths = try await client.listCodexAuths() + } catch { + return nil + } + guard auths.count > 1 else { return nil } + + var output = UsageCommandOutput() + for auth in auths { + let label = Self.codexCLIProxyAccountLabel(auth) + let account = ProviderTokenAccount( + id: UUID(), + label: label, + token: "", + addedAt: 0, + lastUsed: nil) + let result = await Self.fetchUsageOutput( + provider: provider, + account: account, + status: status, + tokenContext: tokenContext, + command: command, + environmentOverride: [CodexCLIProxySettings.environmentAuthIndexKey: auth.authIndex]) + output.merge(result) + } + return output + } + + private static func codexCLIProxyAccountLabel(_ auth: CodexCLIProxyResolvedAuth) -> String { + if let email = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty { + return email + } + return auth.authIndex + } + private static func fetchAntigravityPlanInfoIfNeeded( provider: UsageProvider, command: UsageCommandContext) async -> AntigravityPlanInfoSummary? diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 4809cfb06..e631ae754 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -82,11 +82,24 @@ struct TokenAccountCLIContext { switch provider { case .codex: + let codexSource = self.resolveCodexUsageDataSource(config) return self.makeSnapshot( codex: ProviderSettingsSnapshot.CodexProviderSettings( - usageDataSource: .auto, + usageDataSource: codexSource, cookieSource: cookieSource, - manualCookieHeader: cookieHeader)) + manualCookieHeader: cookieHeader, + cliProxyBaseURL: config?.sanitizedAPIBaseURL, + cliProxyManagementKey: config?.sanitizedAPIKey, + cliProxyAuthIndex: config?.sanitizedAPIAuthIndex)) + case .codexproxy: + return self.makeSnapshot( + codex: ProviderSettingsSnapshot.CodexProviderSettings( + usageDataSource: .api, + cookieSource: .off, + manualCookieHeader: nil, + cliProxyBaseURL: config?.sanitizedAPIBaseURL, + cliProxyManagementKey: config?.sanitizedAPIKey, + cliProxyAuthIndex: config?.sanitizedAPIAuthIndex)) case .claude: let claudeSource: ClaudeUsageDataSource = if provider == .claude, let account, @@ -147,7 +160,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: + case .geminiproxy, .antigravityproxy, .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: return nil } } @@ -179,6 +192,20 @@ struct TokenAccountCLIContext { jetbrains: jetbrains) } + private func resolveCodexUsageDataSource(_ config: ProviderConfig?) -> CodexUsageDataSource { + guard let source = config?.source else { return .auto } + switch source { + case .auto, .web: + return .auto + case .api: + return .api + case .cli: + return .cli + case .oauth: + return .oauth + } + } + func environment( base: [String: String], provider: UsageProvider, diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index c4bbbc2cc..a7c3182c9 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -77,6 +77,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var enabled: Bool? public var source: ProviderSourceMode? public var apiKey: String? + public var apiBaseURL: String? + public var apiAuthIndex: String? public var cookieHeader: String? public var cookieSource: ProviderCookieSource? public var region: String? @@ -88,6 +90,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { enabled: Bool? = nil, source: ProviderSourceMode? = nil, apiKey: String? = nil, + apiBaseURL: String? = nil, + apiAuthIndex: String? = nil, cookieHeader: String? = nil, cookieSource: ProviderCookieSource? = nil, region: String? = nil, @@ -98,6 +102,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.enabled = enabled self.source = source self.apiKey = apiKey + self.apiBaseURL = apiBaseURL + self.apiAuthIndex = apiAuthIndex self.cookieHeader = cookieHeader self.cookieSource = cookieSource self.region = region @@ -109,6 +115,14 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.apiKey) } + public var sanitizedAPIBaseURL: String? { + Self.clean(self.apiBaseURL) + } + + public var sanitizedAPIAuthIndex: String? { + Self.clean(self.apiAuthIndex) + } + public var sanitizedCookieHeader: String? { Self.clean(self.cookieHeader) } diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index d435a28f6..7c9e7a1c7 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -167,6 +167,30 @@ public enum CodexBarConfigValidator { message: "workspaceID is set but only opencode supports workspaceID.")) } + if let apiBaseURL = entry.apiBaseURL, + !apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + provider != .codex + { + issues.append(CodexBarConfigIssue( + severity: .warning, + provider: provider, + field: "apiBaseURL", + code: "api_base_url_unused", + message: "apiBaseURL is set but only codex currently uses it.")) + } + + if let apiAuthIndex = entry.apiAuthIndex, + !apiAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + provider != .codex + { + issues.append(CodexBarConfigIssue( + severity: .warning, + provider: provider, + field: "apiAuthIndex", + code: "api_auth_index_unused", + message: "apiAuthIndex is set but only codex currently uses it.")) + } + if let tokenAccounts = entry.tokenAccounts, !tokenAccounts.accounts.isEmpty, TokenAccountSupportCatalog.support(for: provider) == nil { diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2243d5218..52824dd07 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -26,18 +26,19 @@ public struct CostUsageFetcher: Sendable { forceRefresh: Bool = false, allowVertexClaudeFallback: Bool = false) async throws -> CostUsageTokenSnapshot { - guard provider == .codex || provider == .claude || provider == .vertexai else { + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { throw CostUsageError.unsupportedProvider(provider) } + let scannedProvider: UsageProvider = (provider == .codexproxy) ? .codex : provider let until = now // Rolling window: last 30 days (inclusive). Use -29 for inclusive boundaries. let since = Calendar.current.date(byAdding: .day, value: -29, to: now) ?? now var options = CostUsageScanner.Options() - if provider == .vertexai { + if scannedProvider == .vertexai { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly - } else if provider == .claude { + } else if scannedProvider == .claude { options.claudeLogProviderFilter = .excludeVertexAI } if forceRefresh { @@ -45,13 +46,13 @@ public struct CostUsageFetcher: Sendable { options.forceRescan = true } var daily = CostUsageScanner.loadDailyReport( - provider: provider, + provider: scannedProvider, since: since, until: until, now: now, options: options) - if provider == .vertexai, + if scannedProvider == .vertexai, !allowVertexClaudeFallback, options.claudeLogProviderFilter == .vertexAIOnly, daily.data.isEmpty @@ -59,7 +60,7 @@ public struct CostUsageFetcher: Sendable { var fallback = options fallback.claudeLogProviderFilter = .all daily = CostUsageScanner.loadDailyReport( - provider: provider, + provider: scannedProvider, since: since, until: until, now: now, diff --git a/Sources/CodexBarCore/Localization.swift b/Sources/CodexBarCore/Localization.swift new file mode 100644 index 000000000..6d7d14a3a --- /dev/null +++ b/Sources/CodexBarCore/Localization.swift @@ -0,0 +1,80 @@ +import Foundation + +public enum L10n { + private static let appLanguageKey = "appLanguageCode" + private static let appleLanguagesKey = "AppleLanguages" + + public static func tr(_ key: String, fallback: String) -> String { + let bundle = self.localizedBundle() + return NSLocalizedString( + key, + tableName: "Localizable", + bundle: bundle, + value: fallback, + comment: "") + } + + private static func localizedBundle() -> Bundle { + let selected = UserDefaults.standard.string(forKey: Self.appLanguageKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let usesSystemLanguage = selected.isEmpty || selected == "system" + + let preferences = usesSystemLanguage + ? self.systemLanguagePreferences() + : self.languageCandidates(for: selected) + + guard let bundle = self.bundle(matching: preferences) else { return .module } + return bundle + } + + private static func systemLanguagePreferences() -> [String] { + if let explicit = UserDefaults.standard.array(forKey: Self.appleLanguagesKey) as? [String], + !explicit.isEmpty + { + return explicit + } + let preferred = Locale.preferredLanguages + if !preferred.isEmpty { return preferred } + return [Locale.current.identifier] + } + + private static func languageCandidates(for raw: String) -> [String] { + let normalized = raw.replacingOccurrences(of: "_", with: "-") + var candidates: [String] = [raw, raw.lowercased(), normalized, normalized.lowercased()] + if normalized.contains("-") { + let parts = normalized.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: true) + if let base = parts.first { + candidates.append(String(base)) + candidates.append(String(base).lowercased()) + } + } + + var seen: Set = [] + return candidates.filter { seen.insert($0).inserted && !$0.isEmpty } + } + + private static func bundle(matching preferences: [String]) -> Bundle? { + let available = Bundle.module.localizations.filter { $0 != "Base" } + guard !available.isEmpty else { return nil } + + let preferred = Bundle.preferredLocalizations(from: available, forPreferences: preferences) + for language in preferred { + if let path = Bundle.module.path(forResource: language, ofType: "lproj"), + let bundle = Bundle(path: path) + { + return bundle + } + } + + for language in preferences { + for candidate in self.languageCandidates(for: language) { + if let path = Bundle.module.path(forResource: candidate, ofType: "lproj"), + let bundle = Bundle(path: path) + { + return bundle + } + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 4a8d9441d..249cc3023 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -12,9 +12,12 @@ public struct OpenAIDashboardFetcher { public var errorDescription: String? { switch self { case .loginRequired: - "OpenAI web access requires login." + return L10n.tr("error.codex.openai_web.login_required", fallback: "OpenAI web access requires login.") case let .noDashboardData(body): - "OpenAI dashboard data not found. Body sample: \(body.prefix(200))" + let format = L10n.tr( + "error.codex.openai_web.no_data_with_body", + fallback: "OpenAI dashboard data not found. Body sample: %@") + return String(format: format, locale: .current, String(body.prefix(200))) } } } @@ -471,9 +474,12 @@ public struct OpenAIDashboardFetcher { public var errorDescription: String? { switch self { case .loginRequired: - "OpenAI web access requires login." + return L10n.tr("error.codex.openai_web.login_required", fallback: "OpenAI web access requires login.") case let .noDashboardData(body): - "OpenAI dashboard data not found. Body sample: \(body.prefix(200))" + let format = L10n.tr( + "error.codex.openai_web.no_data_with_body", + fallback: "OpenAI dashboard data not found. Body sample: %@") + return String(format: format, locale: .current, String(body.prefix(200))) } } } @@ -486,7 +492,10 @@ public struct OpenAIDashboardFetcher { debugDumpHTML _: Bool = false, timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { - throw FetchError.noDashboardData(body: "OpenAI web dashboard fetch is only supported on macOS.") + throw FetchError.noDashboardData( + body: L10n.tr( + "error.codex.openai_web.unsupported_platform", + fallback: "OpenAI web dashboard fetch is only supported on macOS.")) } } #endif diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift new file mode 100644 index 000000000..9376c7a14 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift @@ -0,0 +1,77 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum AntigravityProxyProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .antigravityproxy, + metadata: ProviderMetadata( + id: .antigravityproxy, + displayName: "CLIProxy Antigravity", + sessionLabel: "Pro", + weeklyLabel: "Flash", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show CLIProxy Antigravity usage", + cliName: "antigravity-proxy", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "http://127.0.0.1:8317/management.html#/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .antigravity, + iconResourceName: "ProviderIcon-antigravity", + color: ProviderColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Antigravity cost summary is not supported for CLIProxy source." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [AntigravityCLIProxyFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "antigravity-proxy", + aliases: ["cliproxy-antigravity"], + versionDetector: nil)) + } +} + +private struct AntigravityCLIProxyFetchStrategy: ProviderFetchStrategy { + let id: String = "antigravityproxy.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let settings = CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) + else { + throw CodexCLIProxyError.missingManagementKey + } + + let client = CodexCLIProxyManagementClient(settings: settings) + let auth = try await client.resolveAntigravityAuth() + let quota = try await client.fetchAntigravityQuota(auth: auth) + let snapshot = CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot( + from: quota, + auth: auth, + provider: .antigravityproxy) + + return self.makeResult( + usage: snapshot, + sourceLabel: "cliproxy-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift new file mode 100644 index 000000000..babf0c0e8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift @@ -0,0 +1,545 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum CodexCLIProxyError: LocalizedError, Sendable { + case invalidBaseURL + case missingManagementKey + case invalidResponse + case managementRequestFailed(Int, String?) + case missingCodexAuth(String?) + case missingProviderAuth(provider: String, authIndex: String?) + case apiCallFailed(Int, String?) + case decodeFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidBaseURL: + return L10n.tr("error.codex.cliproxy.invalid_base_url", fallback: "CLIProxyAPI base URL is invalid.") + case .missingManagementKey: + return L10n.tr( + "error.codex.cliproxy.missing_management_key", + fallback: "CLIProxy management key is missing. Please set it in Settings > General > CLIProxyAPI.") + case .invalidResponse: + return L10n.tr("error.codex.cliproxy.invalid_response", fallback: "CLIProxyAPI returned an invalid response.") + case let .managementRequestFailed(status, message): + if let message, !message.isEmpty { + let format = L10n.tr( + "error.codex.cliproxy.management_failed_with_message", + fallback: "CLIProxyAPI management API failed (%d): %@") + return String(format: format, locale: .current, status, message) + } + let format = L10n.tr( + "error.codex.cliproxy.management_failed", + fallback: "CLIProxyAPI management API failed (%d).") + return String(format: format, locale: .current, status) + case let .missingCodexAuth(authIndex): + if let authIndex, !authIndex.isEmpty { + let format = L10n.tr( + "error.codex.cliproxy.missing_auth_with_index", + fallback: "CLIProxyAPI did not find Codex auth_index %@.") + return String(format: format, locale: .current, authIndex) + } + return L10n.tr( + "error.codex.cliproxy.missing_auth", + fallback: "CLIProxyAPI has no available Codex auth entry.") + case let .missingProviderAuth(provider, authIndex): + if let authIndex, !authIndex.isEmpty { + let format = L10n.tr( + "error.codex.cliproxy.missing_provider_auth_with_index", + fallback: "CLIProxyAPI did not find %@ auth_index %@.") + return String(format: format, locale: .current, provider, authIndex) + } + let format = L10n.tr( + "error.codex.cliproxy.missing_provider_auth", + fallback: "CLIProxyAPI has no available %@ auth entry.") + return String(format: format, locale: .current, provider) + case let .apiCallFailed(status, message): + if let message, !message.isEmpty { + let format = L10n.tr( + "error.codex.cliproxy.api_call_failed_with_message", + fallback: "CLIProxyAPI api-call failed (%d): %@") + return String(format: format, locale: .current, status, message) + } + let format = L10n.tr( + "error.codex.cliproxy.api_call_failed", + fallback: "CLIProxyAPI api-call failed (%d).") + return String(format: format, locale: .current, status) + case let .decodeFailed(message): + let format = L10n.tr( + "error.codex.cliproxy.decode_failed", + fallback: "Failed to decode CLIProxyAPI response: %@") + return String(format: format, locale: .current, message) + } + } +} + +public struct CodexCLIProxyResolvedAuth: Sendable { + public let authIndex: String + public let email: String? + public let chatGPTAccountID: String? + public let planType: String? +} + +public struct CLIProxyGeminiQuotaBucket: Sendable { + public let modelID: String + public let remainingFraction: Double + public let resetTime: Date? + + public init(modelID: String, remainingFraction: Double, resetTime: Date?) { + self.modelID = modelID + self.remainingFraction = remainingFraction + self.resetTime = resetTime + } +} + +public struct CLIProxyGeminiQuotaResponse: Sendable { + public let buckets: [CLIProxyGeminiQuotaBucket] + + public init(buckets: [CLIProxyGeminiQuotaBucket]) { + self.buckets = buckets + } +} + +private enum CLIProxyAuthProvider: Sendable { + case codex + case gemini + case antigravity + + var displayName: String { + switch self { + case .codex: "Codex" + case .gemini: "Gemini" + case .antigravity: "Antigravity" + } + } + + var providerValues: Set { + switch self { + case .codex: ["codex"] + case .gemini: ["gemini-cli", "gemini"] + case .antigravity: ["antigravity"] + } + } + + var typeValues: Set { + switch self { + case .codex: ["codex"] + case .gemini: ["gemini-cli", "gemini"] + case .antigravity: ["antigravity"] + } + } + + func matches(provider: String?, type: String?) -> Bool { + let normalizedProvider = provider?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedType = type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.providerValues.contains(normalizedProvider ?? "") + || self.typeValues.contains(normalizedType ?? "") + } + + func missingAuthError(authIndex: String?) -> CodexCLIProxyError { + switch self { + case .codex: + return .missingCodexAuth(authIndex) + case .gemini, .antigravity: + return .missingProviderAuth(provider: self.displayName, authIndex: authIndex) + } + } +} + +public struct CodexCLIProxyManagementClient: Sendable { + private let settings: CodexCLIProxySettings + private let session: URLSession + private static let geminiQuotaURL = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota" + private static let geminiLoadCodeAssistURL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + private static let geminiFallbackProjectID = "just-well-nxk81" + private static let geminiHeaders = [ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "gl-node/22.17.0", + "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", + ] + + public init(settings: CodexCLIProxySettings, session: URLSession = .shared) { + self.settings = settings + self.session = session + } + + public func resolveCodexAuth() async throws -> CodexCLIProxyResolvedAuth { + try await self.resolveAuth(for: .codex) + } + + public func listCodexAuths() async throws -> [CodexCLIProxyResolvedAuth] { + try await self.listAuths(for: .codex) + } + + public func resolveGeminiAuth() async throws -> CodexCLIProxyResolvedAuth { + try await self.resolveAuth(for: .gemini) + } + + public func listGeminiAuths() async throws -> [CodexCLIProxyResolvedAuth] { + try await self.listAuths(for: .gemini) + } + + public func resolveAntigravityAuth() async throws -> CodexCLIProxyResolvedAuth { + try await self.resolveAuth(for: .antigravity) + } + + public func listAntigravityAuths() async throws -> [CodexCLIProxyResolvedAuth] { + try await self.listAuths(for: .antigravity) + } + + public func fetchGeminiQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse { + try await self.fetchGeminiLikeQuota(auth: auth) + } + + public func fetchAntigravityQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse { + try await self.fetchGeminiLikeQuota(auth: auth) + } + + public func fetchCodexUsage(auth: CodexCLIProxyResolvedAuth) async throws -> CodexUsageResponse { + let usageURL = "https://chatgpt.com/backend-api/wham/usage" + var headers = [ + "Authorization": "Bearer $TOKEN$", + "Accept": "application/json", + "User-Agent": "CodexBar", + ] + if let accountID = auth.chatGPTAccountID, !accountID.isEmpty { + headers["ChatGPT-Account-Id"] = accountID + } + + let body = APICallRequest( + authIndex: auth.authIndex, + method: "GET", + url: usageURL, + header: headers, + data: nil) + let callResponse = try await self.post(path: "/api-call", body: body) + + let statusCode = callResponse.statusCode + guard (200...299).contains(statusCode) else { + throw CodexCLIProxyError.apiCallFailed(statusCode, callResponse.compactBody) + } + + guard let bodyString = callResponse.body else { + throw CodexCLIProxyError.invalidResponse + } + let payload = Data(bodyString.utf8) + do { + return try JSONDecoder().decode(CodexUsageResponse.self, from: payload) + } catch { + throw CodexCLIProxyError.decodeFailed(error.localizedDescription) + } + } + + private func resolveAuth(for provider: CLIProxyAuthProvider) async throws -> CodexCLIProxyResolvedAuth { + let auths = try await self.listAuths(for: provider) + + if let preferred = self.settings.authIndex?.trimmingCharacters(in: .whitespacesAndNewlines), + !preferred.isEmpty + { + guard let selected = auths.first(where: { $0.authIndex == preferred }) else { + throw provider.missingAuthError(authIndex: preferred) + } + return selected + } + + guard let selected = auths.first else { + throw provider.missingAuthError(authIndex: nil) + } + return selected + } + + private func listAuths(for provider: CLIProxyAuthProvider) async throws -> [CodexCLIProxyResolvedAuth] { + let response = try await self.fetchAuthFiles() + let auths = response.files.filter { provider.matches(provider: $0.provider, type: $0.type) } + + let enabledAuths = auths.filter { !($0.disabled ?? false) } + let pool = enabledAuths.isEmpty ? auths : enabledAuths + let mapped = pool.compactMap { auth -> CodexCLIProxyResolvedAuth? in + let resolved = self.mapResolvedAuth(auth) + guard !resolved.authIndex.isEmpty else { return nil } + return resolved + } + return mapped.sorted { left, right in + let l = left.email?.lowercased() ?? left.authIndex.lowercased() + let r = right.email?.lowercased() ?? right.authIndex.lowercased() + return l < r + } + } + + private func fetchGeminiLikeQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse { + let projectID = await self.resolveGeminiProjectID(auth: auth) ?? Self.geminiFallbackProjectID + let payload = try await self.fetchGeminiLikeQuota(auth: auth, projectID: projectID) + if !payload.buckets.isEmpty { return payload } + if projectID != Self.geminiFallbackProjectID { + return try await self.fetchGeminiLikeQuota(auth: auth, projectID: Self.geminiFallbackProjectID) + } + return payload + } + + private func fetchGeminiLikeQuota( + auth: CodexCLIProxyResolvedAuth, + projectID: String) async throws -> CLIProxyGeminiQuotaResponse + { + let bodyPayload = GeminiQuotaRequestPayload(project: projectID) + let requestData = try JSONEncoder().encode(bodyPayload) + guard let requestString = String(data: requestData, encoding: .utf8) else { + throw CodexCLIProxyError.invalidResponse + } + + let body = APICallRequest( + authIndex: auth.authIndex, + method: "POST", + url: Self.geminiQuotaURL, + header: Self.geminiHeaders, + data: requestString) + let callResponse = try await self.post(path: "/api-call", body: body) + let statusCode = callResponse.statusCode + guard (200...299).contains(statusCode) else { + throw CodexCLIProxyError.apiCallFailed(statusCode, callResponse.compactBody) + } + guard let bodyString = callResponse.body else { + throw CodexCLIProxyError.invalidResponse + } + + let responseData = Data(bodyString.utf8) + do { + let decoded = try JSONDecoder().decode(GeminiQuotaResponsePayload.self, from: responseData) + let buckets = decoded.buckets.compactMap { bucket -> CLIProxyGeminiQuotaBucket? in + guard let modelID = bucket.modelID?.trimmingCharacters(in: .whitespacesAndNewlines), + !modelID.isEmpty, + let remainingFraction = bucket.remainingFraction + else { + return nil + } + return CLIProxyGeminiQuotaBucket( + modelID: modelID, + remainingFraction: remainingFraction, + resetTime: self.parseGeminiResetDate(bucket.resetTime)) + } + return CLIProxyGeminiQuotaResponse(buckets: buckets) + } catch { + throw CodexCLIProxyError.decodeFailed(error.localizedDescription) + } + } + + private func resolveGeminiProjectID(auth: CodexCLIProxyResolvedAuth) async -> String? { + let body = APICallRequest( + authIndex: auth.authIndex, + method: "POST", + url: Self.geminiLoadCodeAssistURL, + header: Self.geminiHeaders, + data: "{}") + + guard let response = try? await self.post(path: "/api-call", body: body), + (200 ... 299).contains(response.statusCode), + let bodyString = response.body, + let data = bodyString.data(using: .utf8), + let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + + if let project = raw["cloudaicompanionProject"] as? String { + let normalized = project.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + + if let project = raw["cloudaicompanionProject"] as? [String: Any] { + if let id = project["id"] as? String { + let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalized.isEmpty { return normalized } + } + if let projectID = project["projectId"] as? String { + let normalized = projectID.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalized.isEmpty { return normalized } + } + } + + return nil + } + + private func parseGeminiResetDate(_ raw: String?) -> Date? { + guard let raw else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: raw) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: raw) + } + + private func fetchAuthFiles() async throws -> AuthFilesResponse { + let (data, statusCode) = try await self.get(path: "/auth-files") + guard (200...299).contains(statusCode) else { + let message = String(data: data, encoding: .utf8) + throw CodexCLIProxyError.managementRequestFailed(statusCode, message) + } + do { + return try JSONDecoder().decode(AuthFilesResponse.self, from: data) + } catch { + throw CodexCLIProxyError.decodeFailed(error.localizedDescription) + } + } + + private func get(path: String) async throws -> (Data, Int) { + let request = try self.makeRequest(path: path, method: "GET", body: nil) + let (data, response) = try await self.session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw CodexCLIProxyError.invalidResponse + } + return (data, http.statusCode) + } + + private func post(path: String, body: T) async throws -> APICallResponse { + let requestBody = try JSONEncoder().encode(body) + let request = try self.makeRequest(path: path, method: "POST", body: requestBody) + let (data, response) = try await self.session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw CodexCLIProxyError.invalidResponse + } + guard (200...299).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) + throw CodexCLIProxyError.managementRequestFailed(http.statusCode, message) + } + do { + return try JSONDecoder().decode(APICallResponse.self, from: data) + } catch { + throw CodexCLIProxyError.decodeFailed(error.localizedDescription) + } + } + + private func makeRequest(path: String, method: String, body: Data?) throws -> URLRequest { + guard let base = self.managementURL(path: path) else { + throw CodexCLIProxyError.invalidBaseURL + } + var request = URLRequest(url: base) + request.httpMethod = method + request.timeoutInterval = 30 + request.setValue("Bearer \(self.settings.managementKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + + private func managementURL(path: String) -> URL? { + let trimmedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + let resolvedBaseURL = self.resolvedManagementBaseURL() + return resolvedBaseURL?.appendingPathComponent(trimmedPath) + } + + private func resolvedManagementBaseURL() -> URL? { + let base = self.settings.baseURL + var normalized = base + let path = normalized.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if path.lowercased().hasSuffix("v0/management") { + return normalized + } + normalized.appendPathComponent("v0", isDirectory: false) + normalized.appendPathComponent("management", isDirectory: false) + return normalized + } + + private func mapResolvedAuth(_ auth: AuthFileEntry) -> CodexCLIProxyResolvedAuth { + let authIndex = auth.authIndex?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return CodexCLIProxyResolvedAuth( + authIndex: authIndex, + email: auth.email, + chatGPTAccountID: auth.idToken?.chatGPTAccountID, + planType: auth.idToken?.planType) + } +} + +private struct AuthFilesResponse: Decodable { + let files: [AuthFileEntry] +} + +private struct AuthFileEntry: Decodable { + let authIndex: String? + let type: String? + let provider: String? + let email: String? + let disabled: Bool? + let idToken: IDTokenClaims? + + enum CodingKeys: String, CodingKey { + case authIndex = "auth_index" + case type + case provider + case email + case disabled + case idToken = "id_token" + } +} + +private struct IDTokenClaims: Decodable { + let chatGPTAccountID: String? + let planType: String? + + enum CodingKeys: String, CodingKey { + case chatGPTAccountID = "chatgpt_account_id" + case planType = "plan_type" + } +} + +private struct APICallRequest: Encodable { + let authIndex: String + let method: String + let url: String + let header: [String: String] + let data: String? + + enum CodingKeys: String, CodingKey { + case authIndex = "auth_index" + case method + case url + case header + case data + } +} + +private struct APICallResponse: Decodable { + let statusCode: Int + let header: [String: [String]]? + let body: String? + + enum CodingKeys: String, CodingKey { + case statusCode = "status_code" + case header + case body + } + + var compactBody: String? { + guard let body else { return nil } + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed.count > 320 ? String(trimmed.prefix(320)) + "…" : trimmed + } +} + +private struct GeminiQuotaRequestPayload: Encodable { + let project: String +} + +private struct GeminiQuotaResponsePayload: Decodable { + let buckets: [GeminiQuotaBucketPayload] +} + +private struct GeminiQuotaBucketPayload: Decodable { + let remainingFraction: Double? + let resetTime: String? + let modelID: String? + let tokenType: String? + + enum CodingKeys: String, CodingKey { + case remainingFraction + case resetTime + case modelID = "modelId" + case tokenType + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift new file mode 100644 index 000000000..978c91495 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift @@ -0,0 +1,63 @@ +import Foundation + +public struct CodexCLIProxySettings: Sendable { + public static let defaultBaseURL = "http://127.0.0.1:8317" + public static let environmentBaseURLKey = "CODEX_CLIPROXY_BASE_URL" + public static let environmentManagementKeyKey = "CODEX_CLIPROXY_MANAGEMENT_KEY" + public static let environmentAuthIndexKey = "CODEX_CLIPROXY_AUTH_INDEX" + + public let baseURL: URL + public let managementKey: String + public let authIndex: String? + + public init(baseURL: URL, managementKey: String, authIndex: String?) { + self.baseURL = baseURL + self.managementKey = managementKey + self.authIndex = authIndex + } + + public static func resolve( + providerSettings: ProviderSettingsSnapshot.CodexProviderSettings?, + environment: [String: String]) -> CodexCLIProxySettings? + { + let managementKey = self.cleaned(providerSettings?.cliProxyManagementKey) + ?? self.cleaned(environment[Self.environmentManagementKeyKey]) + guard let managementKey else { return nil } + + let rawBaseURL = self.cleaned(providerSettings?.cliProxyBaseURL) + ?? self.cleaned(environment[Self.environmentBaseURLKey]) + ?? Self.defaultBaseURL + guard let baseURL = self.normalizedURL(rawBaseURL) else { return nil } + + let authIndex = self.cleaned(providerSettings?.cliProxyAuthIndex) + ?? self.cleaned(environment[Self.environmentAuthIndexKey]) + + return CodexCLIProxySettings(baseURL: baseURL, managementKey: managementKey, authIndex: authIndex) + } + + public static func normalizedURL(_ raw: String) -> URL? { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + + if let url = URL(string: value), url.scheme != nil { + return url + } + return URL(string: "http://\(value)") + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 12b68cd22..a9358c00e 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -10,13 +10,15 @@ public enum CodexProviderDescriptor { metadata: ProviderMetadata( id: .codex, displayName: "Codex", - sessionLabel: "Session", - weeklyLabel: "Weekly", + sessionLabel: L10n.tr("provider.codex.metadata.session_label", fallback: "Session"), + weeklyLabel: L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly"), opusLabel: nil, supportsOpus: false, supportsCredits: true, - creditsHint: "Credits unavailable; keep Codex running to refresh.", - toggleTitle: "Show Codex usage", + creditsHint: L10n.tr( + "provider.codex.metadata.credits_hint", + fallback: "Credits unavailable; keep Codex running to refresh."), + toggleTitle: L10n.tr("provider.codex.metadata.toggle_title", fallback: "Show Codex usage"), cliName: "codex", defaultEnabled: true, isPrimaryProvider: true, @@ -49,12 +51,12 @@ public enum CodexProviderDescriptor { switch context.sourceMode { case .oauth: return [oauth] + case .api: + return [web, cli] case .web: return [web] case .cli: return [cli] - case .api: - return [] case .auto: return [web, cli] } @@ -62,12 +64,12 @@ public enum CodexProviderDescriptor { switch context.sourceMode { case .oauth: return [oauth] + case .api: + return [oauth, cli] case .cli: return [cli] case .web: return [web] - case .api: - return [] case .auto: return [oauth, cli] } @@ -84,7 +86,10 @@ public enum CodexProviderDescriptor { } ?? "\(home)/.codex" let sessions = "\(base)/sessions" let archived = "\(base)/archived_sessions" - return "No Codex sessions found in \(sessions) or \(archived)." + let format = L10n.tr( + "provider.codex.no_data_message", + fallback: "No Codex sessions found in %@ or %@.") + return String(format: format, locale: .current, sessions, archived) } public static func resolveUsageStrategy( @@ -151,8 +156,11 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { accountId: credentials.accountId) return self.makeResult( - usage: Self.mapUsage(usage, credentials: credentials), - credits: Self.mapCredits(usage.credits), + usage: CodexUsageSnapshotMapper.usageSnapshot( + from: usage, + accountEmail: Self.resolveAccountEmail(from: credentials), + fallbackLoginMethod: Self.resolvePlan(response: usage, credentials: credentials)), + credits: CodexUsageSnapshotMapper.creditsSnapshot(from: usage.credits), sourceLabel: "oauth") } @@ -161,40 +169,6 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { return true } - private static func mapUsage(_ response: CodexUsageResponse, credentials: CodexOAuthCredentials) -> UsageSnapshot { - let primary = Self.makeWindow(response.rateLimit?.primaryWindow) - let secondary = Self.makeWindow(response.rateLimit?.secondaryWindow) - - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: Self.resolveAccountEmail(from: credentials), - accountOrganization: nil, - loginMethod: Self.resolvePlan(response: response, credentials: credentials)) - - return UsageSnapshot( - primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: secondary, - tertiary: nil, - updatedAt: Date(), - identity: identity) - } - - private static func mapCredits(_ credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? { - guard let credits, let balance = credits.balance else { return nil } - return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date()) - } - - private static func makeWindow(_ window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { - guard let window else { return nil } - let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) - let resetDescription = UsageFormatter.resetDescription(from: resetDate) - return RateWindow( - usedPercent: Double(window.usedPercent), - windowMinutes: window.limitWindowSeconds / 60, - resetsAt: resetDate, - resetDescription: resetDescription) - } - private static func resolveAccountEmail(from credentials: CodexOAuthCredentials) -> String? { guard let idToken = credentials.idToken, let payload = UsageFetcher.parseJWT(idToken) @@ -220,11 +194,51 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } } +struct CodexCLIProxyFetchStrategy: ProviderFetchStrategy { + let id: String = "codex.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + if context.sourceMode == .api { return true } + return CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let settings = CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) + else { + throw CodexCLIProxyError.missingManagementKey + } + + let client = CodexCLIProxyManagementClient(settings: settings) + let auth = try await client.resolveCodexAuth() + let usage = try await client.fetchCodexUsage(auth: auth) + + return self.makeResult( + usage: CodexUsageSnapshotMapper.usageSnapshot( + from: usage, + accountEmail: auth.email, + fallbackLoginMethod: auth.planType), + credits: CodexUsageSnapshotMapper.creditsSnapshot(from: usage.credits), + sourceLabel: "cliproxy-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} + #if DEBUG extension CodexOAuthFetchStrategy { static func _mapUsageForTesting(_ data: Data, credentials: CodexOAuthCredentials) throws -> UsageSnapshot { let usage = try JSONDecoder().decode(CodexUsageResponse.self, from: data) - return Self.mapUsage(usage, credentials: credentials) + return CodexUsageSnapshotMapper.usageSnapshot( + from: usage, + accountEmail: Self.resolveAccountEmail(from: credentials), + fallbackLoginMethod: Self.resolvePlan(response: usage, credentials: credentials)) } } #endif diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift new file mode 100644 index 000000000..f0cc44e4b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift @@ -0,0 +1,78 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum CodexProxyProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .codexproxy, + metadata: ProviderMetadata( + id: .codexproxy, + displayName: "CLIProxy Codex", + sessionLabel: L10n.tr("provider.codex.metadata.session_label", fallback: "Session"), + weeklyLabel: L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly"), + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show CLIProxy Codex usage", + cliName: "codex-proxy", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "http://127.0.0.1:8317/management.html#/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .codex, + iconResourceName: "ProviderIcon-codex", + color: ProviderColor(red: 73 / 255, green: 163 / 255, blue: 176 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "No Codex sessions found in local logs for CLIProxy Codex." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CodexProxyCLIProxyFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "codex-proxy", + aliases: ["cliproxy-codex"], + versionDetector: nil)) + } +} + +private struct CodexProxyCLIProxyFetchStrategy: ProviderFetchStrategy { + let id: String = "codexproxy.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let settings = CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) + else { + throw CodexCLIProxyError.missingManagementKey + } + + let client = CodexCLIProxyManagementClient(settings: settings) + let auth = try await client.resolveCodexAuth() + let usage = try await client.fetchCodexUsage(auth: auth) + let snapshot = CodexUsageSnapshotMapper.usageSnapshot( + from: usage, + accountEmail: auth.email, + fallbackLoginMethod: auth.planType) + .scoped(to: .codexproxy) + + return self.makeResult( + usage: snapshot, + sourceLabel: "cliproxy-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index ff644cf2b..08442c525 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -34,13 +34,18 @@ public enum CodexStatusProbeError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .codexNotInstalled: - "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart." + return L10n.tr( + "error.codex.status.missing_cli", + fallback: "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart.") case .parseFailed: - "Could not parse Codex status; will retry shortly." + return L10n.tr( + "error.codex.status.parse_failed", + fallback: "Could not parse Codex status; will retry shortly.") case .timedOut: - "Codex status probe timed out." + return L10n.tr("error.codex.status.timed_out", fallback: "Codex status probe timed out.") case let .updateRequired(msg): - "Codex CLI update needed: \(msg)" + let format = L10n.tr("error.codex.status.update_required", fallback: "Codex CLI update needed: %@") + return String(format: format, locale: .current, msg) } } } @@ -93,7 +98,9 @@ public struct CodexStatusProbe { } if self.containsUpdatePrompt(clean) { throw CodexStatusProbeError.updateRequired( - "Run `bun install -g @openai/codex` to continue (update prompt blocking /status).") + L10n.tr( + "error.codex.status.update_required_action", + fallback: "Run `bun install -g @openai/codex` to continue (update prompt blocking /status).")) } let credits = TextParsing.firstNumber(pattern: #"Credits:\s*([0-9][0-9.,]*)"#, text: clean) // Pull reset info from the same lines that contain the percentages. diff --git a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift index b3bb16cd0..910c6b754 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift @@ -3,6 +3,7 @@ import Foundation public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { case auto case oauth + case api case cli public var id: String { @@ -11,9 +12,14 @@ public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { public var displayName: String { switch self { - case .auto: "Auto" - case .oauth: "OAuth API" - case .cli: "CLI (RPC/PTY)" + case .auto: + L10n.tr("provider.codex.source.auto", fallback: "Auto") + case .oauth: + L10n.tr("provider.codex.source.oauth", fallback: "OAuth API") + case .api: + L10n.tr("provider.codex.source.api", fallback: "CLIProxyAPI") + case .cli: + L10n.tr("provider.codex.source.cli", fallback: "CLI (RPC/PTY)") } } @@ -23,6 +29,8 @@ public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { "auto" case .oauth: "oauth" + case .api: + "cliproxy-api" case .cli: "cli" } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift b/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift new file mode 100644 index 000000000..19fbb9352 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift @@ -0,0 +1,46 @@ +import Foundation + +enum CodexUsageSnapshotMapper { + static func usageSnapshot( + from response: CodexUsageResponse, + accountEmail: String?, + fallbackLoginMethod: String?) -> UsageSnapshot + { + let primary = self.makeWindow(response.rateLimit?.primaryWindow) + let secondary = self.makeWindow(response.rateLimit?.secondaryWindow) + + let resolvedPlan = response.planType?.rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackPlan = fallbackLoginMethod?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (resolvedPlan?.isEmpty == false) ? resolvedPlan : fallbackPlan + let normalizedEmail = accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, + accountOrganization: nil, + loginMethod: loginMethod?.isEmpty == true ? nil : loginMethod) + + return UsageSnapshot( + primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: secondary, + tertiary: nil, + updatedAt: Date(), + identity: identity) + } + + static func creditsSnapshot(from credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? { + guard let credits, let balance = credits.balance else { return nil } + return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date()) + } + + private static func makeWindow(_ window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let window else { return nil } + let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) + let resetDescription = UsageFormatter.resetDescription(from: resetDate) + return RateWindow( + usedPercent: Double(window.usedPercent), + windowMinutes: window.limitWindowSeconds / 60, + resetsAt: resetDate, + resetDescription: resetDescription) + } +} diff --git a/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift b/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift new file mode 100644 index 000000000..45cb2248c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift @@ -0,0 +1,65 @@ +import Foundation + +public enum CLIProxyGeminiQuotaSnapshotMapper { + public static func usageSnapshot( + from response: CLIProxyGeminiQuotaResponse, + auth: CodexCLIProxyResolvedAuth, + provider: UsageProvider) -> UsageSnapshot + { + let modelBuckets = self.reduceByModel(response.buckets) + let proBucket = self.lowestBucket(matching: "pro", from: modelBuckets) + let flashBucket = self.lowestBucket(matching: "flash", from: modelBuckets) + let fallbackBucket = modelBuckets.min(by: { $0.remainingFraction < $1.remainingFraction }) + + let primary = self.makeWindow(proBucket ?? fallbackBucket) + ?? RateWindow(usedPercent: 0, windowMinutes: 1440, resetsAt: nil, resetDescription: nil) + let secondary = self.makeWindow(flashBucket) + + let normalizedEmail = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines) + let identity = ProviderIdentitySnapshot( + providerID: provider, + accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, + accountOrganization: nil, + loginMethod: nil) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + updatedAt: Date(), + identity: identity) + .scoped(to: provider) + } + + private static func reduceByModel(_ buckets: [CLIProxyGeminiQuotaBucket]) -> [CLIProxyGeminiQuotaBucket] { + var byModel: [String: CLIProxyGeminiQuotaBucket] = [:] + for bucket in buckets { + guard !bucket.modelID.isEmpty else { continue } + if let existing = byModel[bucket.modelID], existing.remainingFraction <= bucket.remainingFraction { + continue + } + byModel[bucket.modelID] = bucket + } + return byModel.values.sorted { $0.modelID < $1.modelID } + } + + private static func lowestBucket( + matching token: String, + from buckets: [CLIProxyGeminiQuotaBucket]) -> CLIProxyGeminiQuotaBucket? + { + buckets + .filter { $0.modelID.localizedCaseInsensitiveContains(token) } + .min(by: { $0.remainingFraction < $1.remainingFraction }) + } + + private static func makeWindow(_ bucket: CLIProxyGeminiQuotaBucket?) -> RateWindow? { + guard let bucket else { return nil } + let usedPercent = max(0, min(100, (1 - bucket.remainingFraction) * 100)) + let resetDescription = bucket.resetTime.map { UsageFormatter.resetDescription(from: $0) } + return RateWindow( + usedPercent: usedPercent, + windowMinutes: 1440, + resetsAt: bucket.resetTime, + resetDescription: resetDescription) + } +} diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift new file mode 100644 index 000000000..e5ca42c8a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift @@ -0,0 +1,77 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum GeminiProxyProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .geminiproxy, + metadata: ProviderMetadata( + id: .geminiproxy, + displayName: "CLIProxy Gemini", + sessionLabel: "Pro", + weeklyLabel: "Flash", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show CLIProxy Gemini usage", + cliName: "gemini-proxy", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "http://127.0.0.1:8317/management.html#/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .gemini, + iconResourceName: "ProviderIcon-gemini", + color: ProviderColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Gemini cost summary is not supported for CLIProxy source." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [GeminiCLIProxyFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "gemini-proxy", + aliases: ["cliproxy-gemini"], + versionDetector: nil)) + } +} + +private struct GeminiCLIProxyFetchStrategy: ProviderFetchStrategy { + let id: String = "geminiproxy.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let settings = CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) + else { + throw CodexCLIProxyError.missingManagementKey + } + + let client = CodexCLIProxyManagementClient(settings: settings) + let auth = try await client.resolveGeminiAuth() + let quota = try await client.fetchGeminiQuota(auth: auth) + let snapshot = CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot( + from: quota, + auth: auth, + provider: .geminiproxy) + + return self.makeResult( + usage: snapshot, + sourceLabel: "cliproxy-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6aff83695..09d01e8fc 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -54,6 +54,9 @@ public enum ProviderDescriptorRegistry { private static let store = Store() private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [ .codex: CodexProviderDescriptor.descriptor, + .codexproxy: CodexProxyProviderDescriptor.descriptor, + .geminiproxy: GeminiProxyProviderDescriptor.descriptor, + .antigravityproxy: AntigravityProxyProviderDescriptor.descriptor, .claude: ClaudeProviderDescriptor.descriptor, .cursor: CursorProviderDescriptor.descriptor, .opencode: OpenCodeProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index f83cb9fd1..bb40303da 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -38,15 +38,24 @@ public struct ProviderSettingsSnapshot: Sendable { public let usageDataSource: CodexUsageDataSource public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let cliProxyBaseURL: String? + public let cliProxyManagementKey: String? + public let cliProxyAuthIndex: String? public init( usageDataSource: CodexUsageDataSource, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + cliProxyBaseURL: String?, + cliProxyManagementKey: String?, + cliProxyAuthIndex: String?) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.cliProxyBaseURL = cliProxyBaseURL + self.cliProxyManagementKey = cliProxyManagementKey + self.cliProxyAuthIndex = cliProxyAuthIndex } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index a267fb953..af4693a4a 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -4,6 +4,9 @@ import SweetCookieKit // swiftformat:disable sortDeclarations public enum UsageProvider: String, CaseIterable, Sendable, Codable { case codex + case codexproxy + case geminiproxy + case antigravityproxy case claude case cursor case opencode diff --git a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..5d371dc1f --- /dev/null +++ b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings @@ -0,0 +1,261 @@ +"provider.codex.source.auto" = "Auto"; +"provider.codex.source.oauth" = "OAuth API"; +"provider.codex.source.api" = "CLIProxyAPI"; +"provider.codex.source.cli" = "CLI (RPC/PTY)"; + +"provider.codex.version.not_detected" = "not detected"; +"provider.codex.cookie.cached" = "Cached: %@ • %@"; +"provider.codex.menu.credits" = "Credits: %@"; +"provider.codex.menu.last_spend" = "Last spend: %@"; + +"provider.codex.metadata.session_label" = "Session"; +"provider.codex.metadata.weekly_label" = "Weekly"; +"provider.codex.metadata.credits_hint" = "Credits unavailable; keep Codex running to refresh."; +"provider.codex.metadata.toggle_title" = "Show Codex usage"; +"provider.codex.no_data_message" = "No Codex sessions found in %@ or %@."; + +"provider.codex.toggle.openai_web_extras.title" = "OpenAI web extras"; +"provider.codex.toggle.openai_web_extras.subtitle" = "Show usage breakdown, credits history, and code review via chatgpt.com."; + +"provider.codex.picker.usage_source.title" = "Usage source"; +"provider.codex.picker.usage_source.subtitle" = "Auto falls back to the next source if the preferred one fails."; + +"provider.codex.picker.cookie_source.title" = "OpenAI cookies"; +"provider.codex.picker.cookie_source.subtitle" = "Automatic imports browser cookies for dashboard extras."; +"provider.codex.picker.cookie_source.auto" = "Automatic imports browser cookies for dashboard extras."; +"provider.codex.picker.cookie_source.manual" = "Paste a Cookie header from a chatgpt.com request."; +"provider.codex.picker.cookie_source.off" = "Disable OpenAI dashboard cookie usage."; + +"provider.codex.field.cliproxy_url.title" = "CLIProxyAPI URL"; +"provider.codex.field.cliproxy_url.subtitle" = "Management API base URL (defaults to http://127.0.0.1:8317)."; +"provider.codex.field.cliproxy_url.placeholder" = "http://127.0.0.1:8317"; +"provider.codex.field.cliproxy_management_key.title" = "CLIProxy management key"; +"provider.codex.field.cliproxy_management_key.subtitle" = "Sent as Authorization Bearer token to /v0/management/* endpoints."; +"provider.codex.field.cliproxy_management_key.placeholder" = "Paste management key…"; +"provider.codex.field.cliproxy_auth_index.title" = "CLIProxy auth_index (optional)"; +"provider.codex.field.cliproxy_auth_index.subtitle" = "Leave empty for automatic Codex auth selection."; +"provider.codex.field.cliproxy_auth_index.placeholder" = "Optional auth_index"; +"provider.codex.field.cookie_header.placeholder" = "Cookie: …"; + +"error.codex.cliproxy.invalid_base_url" = "CLIProxyAPI base URL is invalid."; +"error.codex.cliproxy.missing_management_key" = "CLIProxy management key is missing. Please set it in Settings > General > CLIProxyAPI."; +"error.codex.cliproxy.invalid_response" = "CLIProxyAPI returned an invalid response."; +"error.codex.cliproxy.management_failed" = "CLIProxyAPI management API failed (%d)."; +"error.codex.cliproxy.management_failed_with_message" = "CLIProxyAPI management API failed (%d): %@"; +"error.codex.cliproxy.missing_auth" = "CLIProxyAPI has no available Codex auth entry."; +"error.codex.cliproxy.missing_auth_with_index" = "CLIProxyAPI did not find Codex auth_index %@."; +"error.codex.cliproxy.missing_provider_auth" = "CLIProxyAPI has no available %@ auth entry."; +"error.codex.cliproxy.missing_provider_auth_with_index" = "CLIProxyAPI did not find %@ auth_index %@."; +"error.codex.cliproxy.api_call_failed" = "CLIProxyAPI api-call failed (%d)."; +"error.codex.cliproxy.api_call_failed_with_message" = "CLIProxyAPI api-call failed (%d): %@"; +"error.codex.cliproxy.decode_failed" = "Failed to decode CLIProxyAPI response: %@"; + +"error.codex.status.missing_cli" = "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart."; +"error.codex.status.parse_failed" = "Could not parse Codex status; will retry shortly."; +"error.codex.status.timed_out" = "Codex status probe timed out."; +"error.codex.status.update_required" = "Codex CLI update needed: %@"; +"error.codex.status.update_required_action" = "Run `bun install -g @openai/codex` to continue (update prompt blocking /status)."; + +"error.codex.rpc.start_failed" = "Codex not running. Try running a Codex command first. (%@)"; +"error.codex.rpc.request_failed" = "Codex connection failed: %@"; +"error.codex.rpc.malformed" = "Codex returned invalid data: %@"; +"error.codex.rpc.chatgpt_auth_required" = "ChatGPT authentication required to read rate limits."; + +"error.codex.openai_web.login_required" = "OpenAI web access requires login."; +"error.codex.openai_web.no_data_with_body" = "OpenAI dashboard data not found. Body sample: %@"; +"error.codex.openai_web.unsupported_platform" = "OpenAI web dashboard fetch is only supported on macOS."; + +"menu.action.switch_account" = "Switch Account..."; +"menu.action.add_account" = "Add Account..."; +"menu.action.usage_dashboard" = "Usage Dashboard"; +"menu.action.status_page" = "Status Page"; +"menu.action.install_update" = "Update ready, restart now?"; +"menu.action.settings" = "Settings..."; +"menu.action.about" = "About CodexBar"; +"menu.action.quit" = "Quit"; +"menu.action.buy_credits" = "Buy Credits..."; +"menu.action.credits_history" = "Credits history"; +"menu.action.usage_breakdown" = "Usage breakdown"; +"menu.codex.cliproxy.auth_grid.title" = "Codex auth entries (%d)"; +"menu.cliproxy.auth_grid.title" = "%@ auth entries (%d)"; + +"menu.card.percent.left" = "left"; +"menu.card.percent.used" = "used"; +"menu.card.accessibility.usage_remaining" = "Usage remaining"; +"menu.card.accessibility.usage_used" = "Usage used"; +"menu.card.accessibility.credits_remaining" = "Credits remaining"; +"menu.card.credits.title" = "Credits"; +"menu.card.cost.title" = "Cost"; +"menu.card.tokens.unit" = "%@ tokens"; +"menu.card.cost.today_with_tokens" = "Today: %@ · %@ tokens"; +"menu.card.cost.today" = "Today: %@"; +"menu.card.cost.last_30_days_with_tokens" = "Last 30 days: %@ · %@ tokens"; +"menu.card.cost.last_30_days" = "Last 30 days: %@"; +"menu.card.provider_cost.quota_usage" = "Quota usage"; +"menu.card.provider_cost.extra_usage" = "Extra usage"; +"menu.card.provider_cost.this_month" = "This month"; + +"window.buy_credits.title" = "Buy Credits"; + +"settings.general.language.title" = "Language"; +"settings.general.language.subtitle" = "Choose app display language."; +"settings.general.language.restart_hint" = "Language changes apply after restart."; +"settings.general.language.apply_restart" = "Apply & Restart"; +"settings.general.language.option.system" = "System"; +"settings.general.language.option.english" = "English"; +"settings.general.language.option.zh_hans" = "Simplified Chinese"; + +"settings.general.cliproxy.section" = "CLIProxyAPI"; +"settings.general.cliproxy.url.title" = "Base URL"; +"settings.general.cliproxy.url.subtitle" = "Global default for providers using API source (for example Codex)."; +"settings.general.cliproxy.url.placeholder" = "http://127.0.0.1:8317"; +"settings.general.cliproxy.key.title" = "Management Key"; +"settings.general.cliproxy.key.placeholder" = "Paste management key…"; +"settings.general.cliproxy.auth_index.title" = "auth_index (optional)"; +"settings.general.cliproxy.auth_index.subtitle" = "Optional. Set a specific auth file; leave empty to aggregate all matching auth entries."; +"settings.general.cliproxy.auth_index.placeholder" = "Leave empty to load all matching auth entries"; + +"provider.codex.cliproxy.aggregate.account_label" = "All Codex auth entries (%d)"; +"provider.cliproxy.aggregate.account_label" = "All %@ auth entries (%d)"; + +"settings.general.system.section" = "System"; +"settings.general.system.start_at_login.title" = "Start at Login"; +"settings.general.system.start_at_login.subtitle" = "Automatically opens CodexBar when you start your Mac."; +"settings.general.usage.section" = "Usage"; +"settings.general.usage.cost_summary.title" = "Show cost summary"; +"settings.general.usage.cost_summary.subtitle" = "Reads local usage logs. Shows today + last 30 days cost in the menu."; +"settings.general.usage.cost_summary.refresh_hint" = "Auto-refresh: hourly · Timeout: 10m"; +"settings.general.automation.section" = "Automation"; +"settings.general.automation.refresh_cadence.title" = "Refresh cadence"; +"settings.general.automation.refresh_cadence.subtitle" = "How often CodexBar polls providers in the background."; +"settings.general.automation.refresh_cadence.manual_hint" = "Auto-refresh is off; use the menu's Refresh command."; +"settings.general.automation.check_status.title" = "Check provider status"; +"settings.general.automation.check_status.subtitle" = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."; +"settings.general.automation.session_quota.title" = "Session quota notifications"; +"settings.general.automation.session_quota.subtitle" = "Notifies when the 5-hour session quota hits 0% and when it becomes available again."; +"settings.general.quit" = "Quit CodexBar"; +"settings.general.usage.cost_status.unsupported" = "%@: unsupported"; +"settings.general.usage.cost_status.fetching" = "%@: fetching…%@"; +"settings.general.usage.cost_status.snapshot" = "%@: %@ · 30d %@"; +"settings.general.usage.cost_status.last_attempt" = "%@: last attempt %@"; +"settings.general.usage.cost_status.no_data" = "%@: no data yet"; + +"settings.general.refresh_frequency.manual" = "Manual"; +"settings.general.refresh_frequency.one_minute" = "1 min"; +"settings.general.refresh_frequency.two_minutes" = "2 min"; +"settings.general.refresh_frequency.five_minutes" = "5 min"; +"settings.general.refresh_frequency.fifteen_minutes" = "15 min"; +"settings.general.refresh_frequency.thirty_minutes" = "30 min"; + +"settings.providers.menu_bar_metric.title" = "Menu bar metric"; +"settings.providers.menu_bar_metric.subtitle" = "Choose which window drives the menu bar percent."; +"settings.providers.menu_bar_metric.option.automatic" = "Automatic"; +"settings.providers.menu_bar_metric.option.primary" = "Primary"; +"settings.providers.menu_bar_metric.option.secondary" = "Secondary"; +"settings.providers.menu_bar_metric.option.average" = "Average"; +"settings.providers.menu_bar_metric.option.primary_with_label" = "Primary (%@)"; +"settings.providers.menu_bar_metric.option.secondary_with_label" = "Secondary (%@)"; +"settings.providers.menu_bar_metric.option.average_with_labels" = "Average (%@ + %@)"; + +"settings.preferences.tab.general" = "General"; +"settings.preferences.tab.providers" = "Providers"; +"settings.preferences.tab.display" = "Display"; +"settings.preferences.tab.advanced" = "Advanced"; +"settings.preferences.tab.about" = "About"; +"settings.preferences.tab.debug" = "Debug"; + +"settings.display.menu_bar.section" = "Menu bar"; +"settings.display.menu_bar.merge_icons.title" = "Merge Icons"; +"settings.display.menu_bar.merge_icons.subtitle" = "Use a single menu bar icon with a provider switcher."; +"settings.display.menu_bar.switcher_icons.title" = "Switcher shows icons"; +"settings.display.menu_bar.switcher_icons.subtitle" = "Show provider icons in the switcher (otherwise show a weekly progress line)."; +"settings.display.menu_bar.highest_usage.title" = "Show most-used provider"; +"settings.display.menu_bar.highest_usage.subtitle" = "Menu bar auto-shows the provider closest to its rate limit."; +"settings.display.menu_bar.brand_percent.title" = "Menu bar shows percent"; +"settings.display.menu_bar.brand_percent.subtitle" = "Replace critter bars with provider branding icons and a percentage."; +"settings.display.menu_bar.mode.title" = "Display mode"; +"settings.display.menu_bar.mode.subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; +"settings.display.menu_content.section" = "Menu content"; +"settings.display.menu_content.usage_as_used.title" = "Show usage as used"; +"settings.display.menu_content.usage_as_used.subtitle" = "Progress bars fill as you consume quota (instead of showing remaining)."; +"settings.display.menu_content.reset_clock.title" = "Show reset time as clock"; +"settings.display.menu_content.reset_clock.subtitle" = "Display reset times as absolute clock values instead of countdowns."; +"settings.display.menu_content.optional_usage.title" = "Show credits + extra usage"; +"settings.display.menu_content.optional_usage.subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu."; +"settings.display.menu_content.all_token_accounts.title" = "Show all token accounts"; +"settings.display.menu_content.all_token_accounts.subtitle" = "Stack token accounts in the menu (otherwise show an account switcher bar)."; + +"settings.advanced.keyboard.section" = "Keyboard shortcut"; +"settings.advanced.keyboard.open_menu.title" = "Open menu"; +"settings.advanced.keyboard.open_menu.subtitle" = "Trigger the menu bar menu from anywhere."; +"settings.advanced.cli.install" = "Install CLI"; +"settings.advanced.cli.install.subtitle" = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; +"settings.advanced.debug.title" = "Show Debug Settings"; +"settings.advanced.debug.subtitle" = "Expose troubleshooting tools in the Debug tab."; +"settings.advanced.surprise.title" = "Surprise me"; +"settings.advanced.surprise.subtitle" = "Check if you like your agents having some fun up there."; +"settings.advanced.privacy.hide_personal_info.title" = "Hide personal information"; +"settings.advanced.privacy.hide_personal_info.subtitle" = "Obscure email addresses in the menu bar and menu UI."; +"settings.advanced.keychain.title" = "Keychain access"; +"settings.advanced.keychain.caption" = "Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers."; +"settings.advanced.keychain.disable.title" = "Disable Keychain access"; +"settings.advanced.keychain.disable.subtitle" = "Prevents any Keychain access while enabled."; +"settings.advanced.cli.status.helper_not_found" = "CodexBarCLI not found in app bundle."; +"settings.advanced.cli.status.no_write_access" = "No write access: %@"; +"settings.advanced.cli.status.installed" = "Installed: %@"; +"settings.advanced.cli.status.exists" = "Exists: %@"; +"settings.advanced.cli.status.failed" = "Failed: %@"; +"settings.advanced.cli.status.no_writable_dirs" = "No writable bin dirs found."; + +"settings.about.version" = "Version %@"; +"settings.about.build" = "Built %@"; +"settings.about.tagline" = "May your tokens never run out—keep agent limits in view."; +"settings.about.link.github" = "GitHub"; +"settings.about.link.website" = "Website"; +"settings.about.link.twitter" = "Twitter"; +"settings.about.link.email" = "Email"; +"settings.about.updates.auto_check" = "Check for updates automatically"; +"settings.about.updates.channel" = "Update Channel"; +"settings.about.updates.check_now" = "Check for Updates…"; +"settings.about.updates.unavailable" = "Updates unavailable in this build."; +"settings.about.copyright" = "© 2025 Peter Steinberger. MIT License."; + +"settings.providers.select_provider" = "Select a provider"; +"settings.providers.alert.cancel" = "Cancel"; +"settings.providers.subtitle.last_fetch_failed" = "last fetch failed"; +"settings.providers.subtitle.not_fetched_yet" = "usage not fetched yet"; +"settings.providers.error.last_fetch_failed" = "Last %@ fetch failed:"; +"settings.providers.error.copy" = "Copy error"; +"settings.providers.error.hide_details" = "Hide details"; +"settings.providers.error.show_details" = "Show details"; +"settings.providers.section.settings" = "Settings"; +"settings.providers.section.options" = "Options"; +"settings.providers.section.usage" = "Usage"; +"settings.providers.detail.label.state" = "State"; +"settings.providers.detail.label.source" = "Source"; +"settings.providers.detail.label.version" = "Version"; +"settings.providers.detail.label.updated" = "Updated"; +"settings.providers.detail.label.status" = "Status"; +"settings.providers.detail.label.account" = "Account"; +"settings.providers.detail.label.plan" = "Plan"; +"settings.providers.detail.label.credits" = "Credits"; +"settings.providers.detail.label.cost" = "Cost"; +"settings.providers.detail.help.refresh" = "Refresh"; +"settings.providers.detail.version.not_detected" = "not detected"; +"settings.providers.detail.state.enabled" = "Enabled"; +"settings.providers.detail.state.disabled" = "Disabled"; +"settings.providers.detail.updated.refreshing" = "Refreshing"; +"settings.providers.detail.updated.not_fetched_yet" = "Not fetched yet"; +"settings.providers.metrics.placeholder.disabled_no_data" = "Disabled — no recent data"; +"settings.providers.metrics.placeholder.no_usage" = "No usage yet"; +"settings.providers.cost.accessibility.usage_used" = "Usage used"; +"settings.providers.cost.percent_used" = "%.0f%% used"; +"settings.providers.token_accounts.empty" = "No token accounts yet."; +"settings.providers.token_accounts.remove_selected" = "Remove selected account"; +"settings.providers.token_accounts.label_placeholder" = "Label"; +"settings.providers.token_accounts.add" = "Add"; +"settings.providers.token_accounts.open_token_file" = "Open token file"; +"settings.providers.token_accounts.reload" = "Reload"; +"settings.providers.sidebar.reorder.help" = "Drag to reorder"; +"settings.providers.sidebar.status.disabled_prefix" = "Disabled — %@"; +"settings.providers.sidebar.accessibility.reorder" = "Reorder"; diff --git a/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..79e8931b5 --- /dev/null +++ b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,261 @@ +"provider.codex.source.auto" = "自动"; +"provider.codex.source.oauth" = "OAuth 接口"; +"provider.codex.source.api" = "CLIProxyAPI"; +"provider.codex.source.cli" = "CLI(RPC/PTY)"; + +"provider.codex.version.not_detected" = "未检测到"; +"provider.codex.cookie.cached" = "已缓存:%@ • %@"; +"provider.codex.menu.credits" = "积分:%@"; +"provider.codex.menu.last_spend" = "最近消费:%@"; + +"provider.codex.metadata.session_label" = "5 小时使用上限"; +"provider.codex.metadata.weekly_label" = "每周使用上限"; +"provider.codex.metadata.credits_hint" = "积分暂不可用;保持 Codex 运行后会自动刷新。"; +"provider.codex.metadata.toggle_title" = "显示 Codex 用量"; +"provider.codex.no_data_message" = "在 %@ 或 %@ 中未找到 Codex 会话。"; + +"provider.codex.toggle.openai_web_extras.title" = "OpenAI 网页扩展"; +"provider.codex.toggle.openai_web_extras.subtitle" = "在 chatgpt.com 显示用量拆分、积分历史和代码审查信息。"; + +"provider.codex.picker.usage_source.title" = "用量来源"; +"provider.codex.picker.usage_source.subtitle" = "自动模式会在首选来源失败时回退到下一个来源。"; + +"provider.codex.picker.cookie_source.title" = "OpenAI Cookies"; +"provider.codex.picker.cookie_source.subtitle" = "自动模式会导入浏览器 Cookies 以获取仪表盘扩展数据。"; +"provider.codex.picker.cookie_source.auto" = "自动模式会导入浏览器 Cookies 以获取仪表盘扩展数据。"; +"provider.codex.picker.cookie_source.manual" = "粘贴 chatgpt.com 请求中的 Cookie 头。"; +"provider.codex.picker.cookie_source.off" = "禁用 OpenAI 仪表盘 Cookie。"; + +"provider.codex.field.cliproxy_url.title" = "CLIProxyAPI 地址"; +"provider.codex.field.cliproxy_url.subtitle" = "管理 API 基础地址(默认 http://127.0.0.1:8317)。"; +"provider.codex.field.cliproxy_url.placeholder" = "http://127.0.0.1:8317"; +"provider.codex.field.cliproxy_management_key.title" = "CLIProxy 管理密钥"; +"provider.codex.field.cliproxy_management_key.subtitle" = "以 Authorization Bearer 形式发送到 /v0/management/* 接口。"; +"provider.codex.field.cliproxy_management_key.placeholder" = "粘贴管理密钥…"; +"provider.codex.field.cliproxy_auth_index.title" = "CLIProxy auth_index(可选)"; +"provider.codex.field.cliproxy_auth_index.subtitle" = "留空时自动选择 Codex 认证条目。"; +"provider.codex.field.cliproxy_auth_index.placeholder" = "可选 auth_index"; +"provider.codex.field.cookie_header.placeholder" = "Cookie:…"; + +"error.codex.cliproxy.invalid_base_url" = "CLIProxyAPI 地址无效。"; +"error.codex.cliproxy.missing_management_key" = "缺少 CLIProxy 管理密钥。请到 设置 > General > CLIProxyAPI 中填写。"; +"error.codex.cliproxy.invalid_response" = "CLIProxyAPI 返回了无效响应。"; +"error.codex.cliproxy.management_failed" = "CLIProxyAPI 管理接口请求失败(%d)。"; +"error.codex.cliproxy.management_failed_with_message" = "CLIProxyAPI 管理接口请求失败(%d):%@"; +"error.codex.cliproxy.missing_auth" = "CLIProxyAPI 中没有可用的 Codex 认证条目。"; +"error.codex.cliproxy.missing_auth_with_index" = "CLIProxyAPI 未找到 Codex auth_index %@。"; +"error.codex.cliproxy.missing_provider_auth" = "CLIProxyAPI 中没有可用的 %@ 认证条目。"; +"error.codex.cliproxy.missing_provider_auth_with_index" = "CLIProxyAPI 未找到 %@ auth_index %@。"; +"error.codex.cliproxy.api_call_failed" = "CLIProxyAPI api-call 请求失败(%d)。"; +"error.codex.cliproxy.api_call_failed_with_message" = "CLIProxyAPI api-call 请求失败(%d):%@"; +"error.codex.cliproxy.decode_failed" = "解析 CLIProxyAPI 响应失败:%@"; + +"error.codex.status.missing_cli" = "未检测到 Codex CLI。请执行 `npm i -g @openai/codex`(或 bun 安装)后重试。"; +"error.codex.status.parse_failed" = "无法解析 Codex 状态,将稍后重试。"; +"error.codex.status.timed_out" = "Codex 状态探测超时。"; +"error.codex.status.update_required" = "需要更新 Codex CLI:%@"; +"error.codex.status.update_required_action" = "请执行 `bun install -g @openai/codex` 后继续(更新提示会阻塞 /status)。"; + +"error.codex.rpc.start_failed" = "Codex 未运行。请先运行一次 Codex 命令。(%@)"; +"error.codex.rpc.request_failed" = "Codex 连接失败:%@"; +"error.codex.rpc.malformed" = "Codex 返回了无效数据:%@"; +"error.codex.rpc.chatgpt_auth_required" = "需要 ChatGPT 登录才能读取限额。"; + +"error.codex.openai_web.login_required" = "OpenAI 网页访问需要登录。"; +"error.codex.openai_web.no_data_with_body" = "未找到 OpenAI 仪表盘数据。返回内容示例:%@"; +"error.codex.openai_web.unsupported_platform" = "OpenAI 网页仪表盘仅支持在 macOS 上抓取。"; + +"menu.action.switch_account" = "切换账号..."; +"menu.action.add_account" = "添加账号..."; +"menu.action.usage_dashboard" = "用量仪表盘"; +"menu.action.status_page" = "状态页"; +"menu.action.install_update" = "发现更新,立即重启?"; +"menu.action.settings" = "设置..."; +"menu.action.about" = "关于 CodexBar"; +"menu.action.quit" = "退出"; +"menu.action.buy_credits" = "购买积分..."; +"menu.action.credits_history" = "积分历史"; +"menu.action.usage_breakdown" = "用量拆分"; +"menu.codex.cliproxy.auth_grid.title" = "Codex 认证条目(%d 个)"; +"menu.cliproxy.auth_grid.title" = "%@ 认证条目(%d 个)"; + +"menu.card.percent.left" = "剩余"; +"menu.card.percent.used" = "已用"; +"menu.card.accessibility.usage_remaining" = "用量剩余"; +"menu.card.accessibility.usage_used" = "用量已用"; +"menu.card.accessibility.credits_remaining" = "积分剩余"; +"menu.card.credits.title" = "积分"; +"menu.card.cost.title" = "花费"; +"menu.card.tokens.unit" = "%@ tokens"; +"menu.card.cost.today_with_tokens" = "今天:%@ · %@ tokens"; +"menu.card.cost.today" = "今天:%@"; +"menu.card.cost.last_30_days_with_tokens" = "近 30 天:%@ · %@ tokens"; +"menu.card.cost.last_30_days" = "近 30 天:%@"; +"menu.card.provider_cost.quota_usage" = "配额使用"; +"menu.card.provider_cost.extra_usage" = "额外用量"; +"menu.card.provider_cost.this_month" = "本月"; + +"window.buy_credits.title" = "购买积分"; + +"settings.general.language.title" = "语言"; +"settings.general.language.subtitle" = "选择应用界面语言。"; +"settings.general.language.restart_hint" = "语言变更在重启后生效。"; +"settings.general.language.apply_restart" = "应用并重启"; +"settings.general.language.option.system" = "跟随系统"; +"settings.general.language.option.english" = "English"; +"settings.general.language.option.zh_hans" = "简体中文"; + +"settings.general.cliproxy.section" = "CLIProxyAPI"; +"settings.general.cliproxy.url.title" = "基础地址"; +"settings.general.cliproxy.url.subtitle" = "作为使用 API 源的 Provider(例如 Codex)的全局默认值。"; +"settings.general.cliproxy.url.placeholder" = "http://127.0.0.1:8317"; +"settings.general.cliproxy.key.title" = "管理密钥"; +"settings.general.cliproxy.key.placeholder" = "粘贴 management key…"; +"settings.general.cliproxy.auth_index.title" = "auth_index(可选)"; +"settings.general.cliproxy.auth_index.subtitle" = "可选。指定某个认证文件;留空则聚合所有匹配的认证条目。"; +"settings.general.cliproxy.auth_index.placeholder" = "留空将加载全部匹配的认证条目"; + +"provider.codex.cliproxy.aggregate.account_label" = "全部 Codex 认证条目(%d 个)"; +"provider.cliproxy.aggregate.account_label" = "全部 %@ 认证条目(%d 个)"; + +"settings.general.system.section" = "系统"; +"settings.general.system.start_at_login.title" = "开机启动"; +"settings.general.system.start_at_login.subtitle" = "在 Mac 启动时自动打开 CodexBar。"; +"settings.general.usage.section" = "用量"; +"settings.general.usage.cost_summary.title" = "显示花费摘要"; +"settings.general.usage.cost_summary.subtitle" = "读取本地 usage 日志,在菜单中显示今天和近 30 天花费。"; +"settings.general.usage.cost_summary.refresh_hint" = "自动刷新:每小时 · 超时:10 分钟"; +"settings.general.automation.section" = "自动化"; +"settings.general.automation.refresh_cadence.title" = "刷新频率"; +"settings.general.automation.refresh_cadence.subtitle" = "CodexBar 在后台轮询各 Provider 的频率。"; +"settings.general.automation.refresh_cadence.manual_hint" = "已关闭自动刷新;可使用菜单中的 Refresh 手动刷新。"; +"settings.general.automation.check_status.title" = "检查服务状态"; +"settings.general.automation.check_status.subtitle" = "轮询 OpenAI/Claude 状态页和 Gemini/Antigravity 对应的 Google Workspace 状态,并在图标与菜单中提示故障。"; +"settings.general.automation.session_quota.title" = "会话配额通知"; +"settings.general.automation.session_quota.subtitle" = "当 5 小时会话额度降至 0% 或恢复可用时发送通知。"; +"settings.general.quit" = "退出 CodexBar"; +"settings.general.usage.cost_status.unsupported" = "%@:不支持"; +"settings.general.usage.cost_status.fetching" = "%@:获取中…%@"; +"settings.general.usage.cost_status.snapshot" = "%@:%@ · 30 天 %@"; +"settings.general.usage.cost_status.last_attempt" = "%@:上次尝试 %@"; +"settings.general.usage.cost_status.no_data" = "%@:暂无数据"; + +"settings.general.refresh_frequency.manual" = "手动"; +"settings.general.refresh_frequency.one_minute" = "1 分钟"; +"settings.general.refresh_frequency.two_minutes" = "2 分钟"; +"settings.general.refresh_frequency.five_minutes" = "5 分钟"; +"settings.general.refresh_frequency.fifteen_minutes" = "15 分钟"; +"settings.general.refresh_frequency.thirty_minutes" = "30 分钟"; + +"settings.providers.menu_bar_metric.title" = "菜单栏指标"; +"settings.providers.menu_bar_metric.subtitle" = "选择菜单栏百分比依据的窗口。"; +"settings.providers.menu_bar_metric.option.automatic" = "自动"; +"settings.providers.menu_bar_metric.option.primary" = "主窗口"; +"settings.providers.menu_bar_metric.option.secondary" = "次窗口"; +"settings.providers.menu_bar_metric.option.average" = "平均"; +"settings.providers.menu_bar_metric.option.primary_with_label" = "主窗口(%@)"; +"settings.providers.menu_bar_metric.option.secondary_with_label" = "次窗口(%@)"; +"settings.providers.menu_bar_metric.option.average_with_labels" = "平均(%@ + %@)"; + +"settings.preferences.tab.general" = "通用"; +"settings.preferences.tab.providers" = "提供方"; +"settings.preferences.tab.display" = "显示"; +"settings.preferences.tab.advanced" = "高级"; +"settings.preferences.tab.about" = "关于"; +"settings.preferences.tab.debug" = "调试"; + +"settings.display.menu_bar.section" = "菜单栏"; +"settings.display.menu_bar.merge_icons.title" = "合并图标"; +"settings.display.menu_bar.merge_icons.subtitle" = "使用单个菜单栏图标,并通过切换器切换 Provider。"; +"settings.display.menu_bar.switcher_icons.title" = "切换器显示图标"; +"settings.display.menu_bar.switcher_icons.subtitle" = "在切换器中显示 Provider 图标(否则显示周用量进度线)。"; +"settings.display.menu_bar.highest_usage.title" = "显示最高使用率 Provider"; +"settings.display.menu_bar.highest_usage.subtitle" = "菜单栏会自动显示最接近限额的 Provider。"; +"settings.display.menu_bar.brand_percent.title" = "菜单栏显示百分比"; +"settings.display.menu_bar.brand_percent.subtitle" = "使用 Provider 品牌图标和百分比替代小动物条。"; +"settings.display.menu_bar.mode.title" = "显示模式"; +"settings.display.menu_bar.mode.subtitle" = "选择菜单栏显示内容(Pace 显示当前用量相对预期节奏)。"; +"settings.display.menu_content.section" = "菜单内容"; +"settings.display.menu_content.usage_as_used.title" = "按已用显示用量"; +"settings.display.menu_content.usage_as_used.subtitle" = "进度条随配额消耗而填充(而不是显示剩余)。"; +"settings.display.menu_content.reset_clock.title" = "重置时间显示为时刻"; +"settings.display.menu_content.reset_clock.subtitle" = "将重置时间显示为绝对时刻而非倒计时。"; +"settings.display.menu_content.optional_usage.title" = "显示积分与额外用量"; +"settings.display.menu_content.optional_usage.subtitle" = "在菜单中显示 Codex 积分与 Claude 额外用量区块。"; +"settings.display.menu_content.all_token_accounts.title" = "显示全部 token 账户"; +"settings.display.menu_content.all_token_accounts.subtitle" = "在菜单中堆叠所有 token 账户(否则显示账户切换条)。"; + +"settings.advanced.keyboard.section" = "快捷键"; +"settings.advanced.keyboard.open_menu.title" = "打开菜单"; +"settings.advanced.keyboard.open_menu.subtitle" = "在任何位置触发菜单栏菜单。"; +"settings.advanced.cli.install" = "安装 CLI"; +"settings.advanced.cli.install.subtitle" = "将 CodexBarCLI 软链接到 /usr/local/bin 和 /opt/homebrew/bin,并命名为 codexbar。"; +"settings.advanced.debug.title" = "显示调试设置"; +"settings.advanced.debug.subtitle" = "在“调试”标签页显示排障工具。"; +"settings.advanced.surprise.title" = "来点惊喜"; +"settings.advanced.surprise.subtitle" = "看看你是否喜欢你的 agents 在上面玩点花样。"; +"settings.advanced.privacy.hide_personal_info.title" = "隐藏个人信息"; +"settings.advanced.privacy.hide_personal_info.subtitle" = "在菜单栏和菜单 UI 中遮蔽邮箱地址。"; +"settings.advanced.keychain.title" = "钥匙串访问"; +"settings.advanced.keychain.caption" = "禁用所有钥匙串读写。浏览器 Cookie 导入将不可用;请在 Providers 中手动粘贴 Cookie 头。"; +"settings.advanced.keychain.disable.title" = "禁用钥匙串访问"; +"settings.advanced.keychain.disable.subtitle" = "启用后将阻止任何钥匙串访问。"; +"settings.advanced.cli.status.helper_not_found" = "在应用包中未找到 CodexBarCLI。"; +"settings.advanced.cli.status.no_write_access" = "无写权限:%@"; +"settings.advanced.cli.status.installed" = "已安装:%@"; +"settings.advanced.cli.status.exists" = "已存在:%@"; +"settings.advanced.cli.status.failed" = "失败:%@"; +"settings.advanced.cli.status.no_writable_dirs" = "未找到可写的 bin 目录。"; + +"settings.about.version" = "版本 %@"; +"settings.about.build" = "构建于 %@"; +"settings.about.tagline" = "愿你的 tokens 永不见底——让 agent 限额始终可见。"; +"settings.about.link.github" = "GitHub"; +"settings.about.link.website" = "网站"; +"settings.about.link.twitter" = "Twitter"; +"settings.about.link.email" = "邮箱"; +"settings.about.updates.auto_check" = "自动检查更新"; +"settings.about.updates.channel" = "更新通道"; +"settings.about.updates.check_now" = "检查更新…"; +"settings.about.updates.unavailable" = "当前构建不可用更新功能。"; +"settings.about.copyright" = "© 2025 Peter Steinberger. MIT 许可证。"; + +"settings.providers.select_provider" = "请选择一个 Provider"; +"settings.providers.alert.cancel" = "取消"; +"settings.providers.subtitle.last_fetch_failed" = "上次获取失败"; +"settings.providers.subtitle.not_fetched_yet" = "尚未获取用量"; +"settings.providers.error.last_fetch_failed" = "%@ 上次获取失败:"; +"settings.providers.error.copy" = "复制错误"; +"settings.providers.error.hide_details" = "隐藏详情"; +"settings.providers.error.show_details" = "显示详情"; +"settings.providers.section.settings" = "设置"; +"settings.providers.section.options" = "选项"; +"settings.providers.section.usage" = "用量"; +"settings.providers.detail.label.state" = "状态"; +"settings.providers.detail.label.source" = "来源"; +"settings.providers.detail.label.version" = "版本"; +"settings.providers.detail.label.updated" = "更新时间"; +"settings.providers.detail.label.status" = "服务状态"; +"settings.providers.detail.label.account" = "账户"; +"settings.providers.detail.label.plan" = "计划"; +"settings.providers.detail.label.credits" = "积分"; +"settings.providers.detail.label.cost" = "花费"; +"settings.providers.detail.help.refresh" = "刷新"; +"settings.providers.detail.version.not_detected" = "未检测到"; +"settings.providers.detail.state.enabled" = "已启用"; +"settings.providers.detail.state.disabled" = "已禁用"; +"settings.providers.detail.updated.refreshing" = "刷新中"; +"settings.providers.detail.updated.not_fetched_yet" = "尚未获取"; +"settings.providers.metrics.placeholder.disabled_no_data" = "已禁用 — 暂无最近数据"; +"settings.providers.metrics.placeholder.no_usage" = "暂无用量"; +"settings.providers.cost.accessibility.usage_used" = "已使用用量"; +"settings.providers.cost.percent_used" = "已使用 %.0f%%"; +"settings.providers.token_accounts.empty" = "暂无 token 账户。"; +"settings.providers.token_accounts.remove_selected" = "移除当前账户"; +"settings.providers.token_accounts.label_placeholder" = "标签"; +"settings.providers.token_accounts.add" = "添加"; +"settings.providers.token_accounts.open_token_file" = "打开 token 文件"; +"settings.providers.token_accounts.reload" = "重新加载"; +"settings.providers.sidebar.reorder.help" = "拖拽以排序"; +"settings.providers.sidebar.status.disabled_prefix" = "已禁用 — %@"; +"settings.providers.sidebar.accessibility.reorder" = "排序"; diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index ca300ea9f..7760a33b7 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -282,13 +282,34 @@ private enum RPCWireError: Error, LocalizedError { var errorDescription: String? { switch self { case let .startFailed(message): - "Codex not running. Try running a Codex command first. (\(message))" + let format = L10n.tr( + "error.codex.rpc.start_failed", + fallback: "Codex not running. Try running a Codex command first. (%@)") + return String(format: format, locale: .current, Self.localizedMessage(message)) case let .requestFailed(message): - "Codex connection failed: \(message)" + let format = L10n.tr( + "error.codex.rpc.request_failed", + fallback: "Codex connection failed: %@") + return String(format: format, locale: .current, Self.localizedMessage(message)) case let .malformed(message): - "Codex returned invalid data: \(message)" + let format = L10n.tr( + "error.codex.rpc.malformed", + fallback: "Codex returned invalid data: %@") + return String(format: format, locale: .current, Self.localizedMessage(message)) } } + + private static func localizedMessage(_ message: String) -> String { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return message } + let normalized = trimmed.lowercased() + if normalized.contains("chatgpt authentication required to read rate limits") { + return L10n.tr( + "error.codex.rpc.chatgpt_auth_required", + fallback: "ChatGPT authentication required to read rate limits.") + } + return trimmed + } } /// RPC helper used on background tasks; safe because we confine it to the owning task. diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index d47d7d557..d9c22a30c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -61,6 +61,12 @@ enum CostUsageScanner { switch provider { case .codex: return self.loadCodexDaily(range: range, now: now, options: options) + case .codexproxy: + return self.loadCodexDaily(range: range, now: now, options: options) + case .geminiproxy: + return CostUsageDailyReport(data: [], summary: nil) + case .antigravityproxy: + return CostUsageDailyReport(data: [], summary: nil) case .claude: return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) case .zai: diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 1634611ee..d7763a977 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -42,6 +42,9 @@ enum ProviderChoice: String, AppEnum { init?(provider: UsageProvider) { switch provider { case .codex: self = .codex + case .codexproxy: return nil // CLIProxy Codex not yet supported in widgets + case .geminiproxy: return nil // CLIProxy Gemini not yet supported in widgets + case .antigravityproxy: return nil // CLIProxy Antigravity not yet supported in widgets case .claude: self = .claude case .gemini: self = .gemini case .antigravity: self = .antigravity diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index ed39b4506..d298e746d 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -258,6 +258,9 @@ private struct ProviderSwitchChip: View { private var shortLabel: String { switch self.provider { case .codex: "Codex" + case .codexproxy: "CdxProxy" + case .geminiproxy: "GemProxy" + case .antigravityproxy: "AntiProxy" case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" @@ -571,6 +574,12 @@ enum WidgetColors { switch provider { case .codex: Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) + case .codexproxy: + Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) + case .geminiproxy: + Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255) + case .antigravityproxy: + Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) case .gemini: diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index a735f6a19..c68d832ff 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -42,6 +42,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: updatedSnap, credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), creditsError: nil, @@ -103,6 +104,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -153,6 +155,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -195,6 +198,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .claude, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -223,6 +227,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: nil, credits: nil, creditsError: nil, @@ -264,6 +269,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -291,6 +297,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .claude, metadata: metadata, + sourceLabel: nil, snapshot: nil, credits: nil, creditsError: nil, @@ -331,6 +338,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), creditsError: nil, @@ -371,6 +379,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .claude, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -410,6 +419,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 9e05c7462..9c2d035bd 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -123,6 +123,50 @@ struct ProviderSettingsDescriptorTests { #expect(pickers.contains(where: { $0.id == "codex-cookie-source" })) } + @Test + func codexHidesCLIProxyFieldsWhenAPISelected() throws { + let suite = "ProviderSettingsDescriptorTests-codex-cliproxy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.codexUsageDataSource = .api + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let context = ProviderSettingsContext( + provider: .codex, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let fields = CodexProviderImplementation().settingsFields(context: context) + #expect(fields.contains(where: { $0.id == "codex-cliproxy-base-url" }) == false) + #expect(fields.contains(where: { $0.id == "codex-cliproxy-management-key" }) == false) + #expect(fields.contains(where: { $0.id == "codex-cliproxy-auth-index" }) == false) + } + @Test func claudeExposesUsageAndCookiePickers() throws { let suite = "ProviderSettingsDescriptorTests-claude" diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index fad99a763..93f68f547 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -143,6 +143,36 @@ struct SettingsStoreTests { #expect(store.codexUsageDataSource == .auto) } + @Test + func persistsCodexCLIProxySettingsAcrossInstances() throws { + let suite = "SettingsStoreTests-codex-cliproxy" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + storeA.codexUsageDataSource = .api + storeA.codexCLIProxyBaseURL = "http://127.0.0.1:8317" + storeA.codexCLIProxyManagementKey = "test-management-key" + storeA.codexCLIProxyAuthIndex = "auth-index-123" + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.codexUsageDataSource == .api) + #expect(storeB.codexCLIProxyBaseURL == "http://127.0.0.1:8317") + #expect(storeB.codexCLIProxyManagementKey == "test-management-key") + #expect(storeB.codexCLIProxyAuthIndex == "auth-index-123") + } + @Test @MainActor func applyExternalConfigDoesNotBroadcast() throws { diff --git a/docs/codex.md b/docs/codex.md index 9b23ed49c..509a5e112 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -1,5 +1,5 @@ --- -summary: "Codex provider data sources: OpenAI web dashboard, Codex CLI RPC/PTY, credits, and local cost usage." +summary: "Codex provider data sources: OAuth API, CLIProxyAPI management API, OpenAI web dashboard, CLI RPC/PTY, and local cost usage." read_when: - Debugging Codex usage/credits parsing - Updating OpenAI dashboard scraping or cookie import @@ -9,7 +9,7 @@ read_when: # Codex provider -Codex has four usage data paths (OAuth API, web dashboard, CLI RPC, CLI PTY) plus a local cost-usage scanner. +Codex has five usage data paths (OAuth API, CLIProxyAPI management API, web dashboard, CLI RPC, CLI PTY) plus a local cost-usage scanner. The OAuth API is the default app source when credentials are available; web access is optional for dashboard extras. ## Data sources + fallback order @@ -21,7 +21,7 @@ The OAuth API is the default app source when credentials are available; web acce `primary + openai-web`. Usage source picker: -- Preferences → Providers → Codex → Usage source (Auto/OAuth/CLI). +- Preferences → Providers → Codex → Usage source (Auto/OAuth/CLIProxyAPI/CLI). ### CLI default selection (`--source auto`) 1) OpenAI web dashboard (when available).