From f9ea7cef9013417c77ccefa0ef9de8ba2935ac03 Mon Sep 17 00:00:00 2001 From: waffensam Date: Sun, 22 Mar 2026 09:11:10 +0800 Subject: [PATCH 1/2] Resolve PR 3 localization conflict --- Package.swift | 3 + Sources/CodexBar/About.swift | 4 +- .../CodexBar/KeychainPromptCoordinator.swift | 122 ++++++------ Sources/CodexBar/MenuDescriptor.swift | 38 ++-- Sources/CodexBar/PreferencesAboutPane.swift | 15 +- .../CodexBar/PreferencesAdvancedPane.swift | 50 ++--- Sources/CodexBar/PreferencesDebugPane.swift | 146 ++++++++------- Sources/CodexBar/PreferencesGeneralPane.swift | 52 +++--- Sources/CodexBar/PreferencesView.swift | 13 +- .../StatusItemController+Actions.swift | 28 +-- Sources/CodexBarCore/Localization/L10n.swift | 9 + .../Resources/en.lproj/Localizable.strings | 174 ++++++++++++++++++ 12 files changed, 418 insertions(+), 236 deletions(-) create mode 100644 Sources/CodexBarCore/Localization/L10n.swift create mode 100644 Sources/CodexBarCore/Resources/en.lproj/Localizable.strings diff --git a/Package.swift b/Package.swift index 311f08fe2..0817d53b6 100644 --- a/Package.swift +++ b/Package.swift @@ -34,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/Sources/CodexBar/About.swift b/Sources/CodexBar/About.swift index 677ea6e5a..e40d4ad2d 100644 --- a/Sources/CodexBar/About.swift +++ b/Sources/CodexBar/About.swift @@ -21,7 +21,7 @@ func showAbout() { ]) } - let credits = NSMutableAttributedString(string: "Peter Steinberger — MIT License\n") + let credits = NSMutableAttributedString(string: L10n.tr("about.credits", default: "Peter Steinberger — MIT License\n")) credits.append(makeLink("GitHub", urlString: "https://github.com/steipete/CodexBar")) credits.append(separator) credits.append(makeLink("Website", urlString: "https://codexbar.app")) @@ -30,7 +30,7 @@ func showAbout() { credits.append(separator) credits.append(makeLink("Email", urlString: "mailto:peter@steipete.me")) if let buildTimestamp, let formatted = formattedBuildTimestamp(buildTimestamp) { - var builtLine = "Built \(formatted)" + var builtLine = L10n.tr("about.built", default: "Built %@", formatted) if let gitCommit, !gitCommit.isEmpty, gitCommit != "unknown" { builtLine += " (\(gitCommit)" #if DEBUG diff --git a/Sources/CodexBar/KeychainPromptCoordinator.swift b/Sources/CodexBar/KeychainPromptCoordinator.swift index a6add39ab..2aaad319e 100644 --- a/Sources/CodexBar/KeychainPromptCoordinator.swift +++ b/Sources/CodexBar/KeychainPromptCoordinator.swift @@ -22,94 +22,80 @@ enum KeychainPromptCoordinator { } private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) { - let title = "Keychain Access Required" - let message = [ - "CodexBar will ask macOS Keychain for “\(context.label)” so it can decrypt browser cookies", - "and authenticate your account. Click OK to continue.", - ].joined(separator: " ") + let title = L10n.tr("keychain.title", default: "Keychain Access Required") + let message = L10n.tr( + "keychain.browserCookie.message", + default: "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue.", + context.label) self.log.info("Browser cookie keychain prompt requested", metadata: ["label": context.label]) self.presentAlert(title: title, message: message) } private static func keychainCopy(for context: KeychainPromptContext) -> (title: String, message: String) { - let title = "Keychain Access Required" - switch context.kind { + let title = L10n.tr("keychain.title", default: "Keychain Access Required") + let message: String = switch context.kind { case .claudeOAuth: - return (title, [ - "CodexBar will ask macOS Keychain for the Claude Code OAuth token", - "so it can fetch your Claude usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.claudeOAuth.message", + default: "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue.") case .codexCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your OpenAI cookie header", - "so it can fetch Codex dashboard extras. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.codexCookie.message", + default: "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue.") case .claudeCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Claude cookie header", - "so it can fetch Claude web usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.claudeCookie.message", + default: "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue.") case .cursorCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Cursor cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.cursorCookie.message", + default: "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue.") case .opencodeCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your OpenCode cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.opencodeCookie.message", + default: "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue.") case .factoryCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Factory cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.factoryCookie.message", + default: "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue.") case .zaiToken: - return (title, [ - "CodexBar will ask macOS Keychain for your z.ai API token", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.zaiToken.message", + default: "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue.") case .syntheticToken: - return (title, [ - "CodexBar will ask macOS Keychain for your Synthetic API key", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.syntheticToken.message", + default: "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue.") case .copilotToken: - return (title, [ - "CodexBar will ask macOS Keychain for your GitHub Copilot token", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.copilotToken.message", + default: "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue.") case .kimiToken: - return (title, [ - "CodexBar will ask macOS Keychain for your Kimi auth token", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.kimiToken.message", + default: "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue.") case .kimiK2Token: - return (title, [ - "CodexBar will ask macOS Keychain for your Kimi K2 API key", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.kimik2Token.message", + default: "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue.") case .minimaxCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your MiniMax cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.minimaxCookie.message", + default: "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue.") case .minimaxToken: - return (title, [ - "CodexBar will ask macOS Keychain for your MiniMax API token", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.minimaxToken.message", + default: "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue.") case .augmentCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Augment cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.augmentCookie.message", + default: "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue.") case .ampCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Amp cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + L10n.tr( + "keychain.ampCookie.message", + default: "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue.") } + return (title, message) } private static func presentAlert(title: String, message: String) { @@ -134,7 +120,7 @@ enum KeychainPromptCoordinator { let alert = NSAlert() alert.messageText = title alert.informativeText = message - alert.addButton(withTitle: "OK") + alert.addButton(withTitle: L10n.tr("common.ok", default: "OK")) _ = alert.runModal() } } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..9cd0cb059 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -87,7 +87,7 @@ struct MenuDescriptor { sections.append(accountSection) } } else { - sections.append(Section(entries: [.text("No usage configured.", .secondary)])) + sections.append(Section(entries: [.text(L10n.tr("menu.noUsageConfigured", default: "No usage configured."), .secondary)])) } } @@ -174,7 +174,7 @@ struct MenuDescriptor { if meta.supportsOpus, let opus = snap.tertiary { Self.appendRateWindow( entries: &entries, - title: meta.opusLabel ?? "Sonnet", + title: meta.opusLabel ?? L10n.tr("provider.label.sonnet", default: "Sonnet"), window: opus, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -184,11 +184,11 @@ struct MenuDescriptor { if cost.currencyCode == "Quota" { let used = String(format: "%.0f", cost.used) let limit = String(format: "%.0f", cost.limit) - entries.append(.text("Quota: \(used) / \(limit)", .primary)) + entries.append(.text(L10n.tr("menu.quota", default: "Quota: %@ / %@", used, limit), .primary)) } } } else { - entries.append(.text("No usage yet", .secondary)) + entries.append(.text(L10n.tr("menu.noUsageYet", default: "No usage yet"), .secondary)) } let usageContext = ProviderMenuUsageContext( @@ -236,27 +236,27 @@ struct MenuDescriptor { let redactedEmail = PersonalInfoRedactor.redactEmail(emailText, isEnabled: hidePersonalInfo) if let emailText, !emailText.isEmpty { - entries.append(.text("Account: \(redactedEmail)", .secondary)) + entries.append(.text(L10n.tr("menu.account", default: "Account: %@", redactedEmail), .secondary)) } if provider == .kilo { let kiloLogin = self.kiloLoginParts(loginMethod: loginMethodText) if let pass = kiloLogin.pass { - entries.append(.text("Plan: \(AccountFormatter.plan(pass))", .secondary)) + entries.append(.text(L10n.tr("menu.plan", default: "Plan: %@", AccountFormatter.plan(pass)), .secondary)) } for detail in kiloLogin.details { - entries.append(.text("Activity: \(detail)", .secondary)) + entries.append(.text(L10n.tr("menu.activity", default: "Activity: %@", detail), .secondary)) } } else if let loginMethodText, !loginMethodText.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .secondary)) + entries.append(.text(L10n.tr("menu.plan", default: "Plan: %@", AccountFormatter.plan(loginMethodText)), .secondary)) } if metadata.usesAccountFallback { if emailText?.isEmpty ?? true, let fallbackEmail = fallback.email, !fallbackEmail.isEmpty { let redacted = PersonalInfoRedactor.redactEmail(fallbackEmail, isEnabled: hidePersonalInfo) - entries.append(.text("Account: \(redacted)", .secondary)) + entries.append(.text(L10n.tr("menu.account", default: "Account: %@", redacted), .secondary)) } if loginMethodText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(fallbackPlan))", .secondary)) + entries.append(.text(L10n.tr("menu.plan", default: "Plan: %@", AccountFormatter.plan(fallbackPlan)), .secondary)) } } @@ -327,7 +327,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.switchAccount", default: "Switch Account...") + : L10n.tr("menu.addAccount", default: "Add Account...") entries.append(.action(accountLabel, loginAction)) } } @@ -343,10 +345,10 @@ struct MenuDescriptor { } if metadata?.dashboardURL != nil { - entries.append(.action("Usage Dashboard", .dashboard)) + entries.append(.action(L10n.tr("menu.dashboard", default: "Usage Dashboard"), .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { - entries.append(.action("Status Page", .statusPage)) + entries.append(.action(L10n.tr("menu.statusPage", default: "Status Page"), .statusPage)) } if let statusLine = self.statusLine(for: provider, store: store) { @@ -359,12 +361,12 @@ 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.updateReady", default: "Update ready, restart now?"), .installUpdate)) } entries.append(contentsOf: [ - .action("Settings...", .settings), - .action("About CodexBar", .about), - .action("Quit", .quit), + .action(L10n.tr("menu.settings", default: "Settings..."), .settings), + .action(L10n.tr("menu.about", default: "About CodexBar"), .about), + .action(L10n.tr("menu.quit", default: "Quit"), .quit), ]) return Section(entries: entries) } @@ -417,7 +419,7 @@ struct MenuDescriptor { { let line = UsageFormatter .usageLine(remaining: window.remainingPercent, used: window.usedPercent, showUsed: showUsed) - entries.append(.text("\(title): \(line)", .primary)) + entries.append(.text(L10n.tr("menu.rateWindow", default: "%@: %@", title, line), .primary)) if let resetOverride { entries.append(.text(resetOverride, .secondary)) } else if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) { diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index 16e27189e..c8e135fb4 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,14 @@ struct AboutPane: View { VStack(spacing: 2) { Text("CodexBar") .font(.title3).bold() - Text("Version \(self.versionString)") + Text(L10n.tr("preferences.about.version", default: "Version %@", self.versionString)) .foregroundStyle(.secondary) if let buildTimestamp { - Text("Built \(buildTimestamp)") + Text(L10n.tr("preferences.about.built", default: "Built %@", buildTimestamp)) .font(.footnote) .foregroundStyle(.secondary) } - Text("May your tokens never run out—keep agent limits in view.") + Text(L10n.tr("preferences.about.tagline", default: "May your tokens never run out—keep agent limits in view.")) .font(.footnote) .foregroundStyle(.secondary) } @@ -80,12 +81,12 @@ struct AboutPane: View { if self.updater.isAvailable { VStack(spacing: 10) { - Toggle("Check for updates automatically", isOn: self.$autoUpdateEnabled) + Toggle(L10n.tr("preferences.about.autoUpdates", default: "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("preferences.about.updateChannel", default: "Update Channel")) Spacer() Picker("", selection: self.updateChannelBinding) { ForEach(UpdateChannel.allCases) { channel in @@ -102,10 +103,10 @@ struct AboutPane: View { .multilineTextAlignment(.center) .frame(maxWidth: 280) } - Button("Check for Updates…") { self.updater.checkForUpdates(nil) } + Button(L10n.tr("preferences.about.checkForUpdates", default: "Check for Updates…")) { self.updater.checkForUpdates(nil) } } } else { - Text(self.updater.unavailableReason ?? "Updates unavailable in this build.") + Text(self.updater.unavailableReason ?? L10n.tr("preferences.about.updatesUnavailable", default: "Updates unavailable in this build.")) .foregroundStyle(.secondary) } diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f2..6ffc9c338 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -1,4 +1,5 @@ import KeyboardShortcuts +import CodexBarCore import SwiftUI @MainActor @@ -11,17 +12,17 @@ struct AdvancedPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 8) { - Text("Keyboard shortcut") + Text(L10n.tr("preferences.advanced.section.shortcut", default: "Keyboard shortcut")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) HStack(alignment: .center, spacing: 12) { - Text("Open menu") + Text(L10n.tr("preferences.advanced.openMenu.title", default: "Open menu")) .font(.body) Spacer() KeyboardShortcuts.Recorder(for: .openMenu) } - Text("Trigger the menu bar menu from anywhere.") + Text(L10n.tr("preferences.advanced.openMenu.subtitle", default: "Trigger the menu bar menu from anywhere.")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -36,7 +37,7 @@ struct AdvancedPane: View { if self.isInstallingCLI { ProgressView().controlSize(.small) } else { - Text("Install CLI") + Text(L10n.tr("preferences.advanced.installCLI.button", default: "Install CLI")) } } .disabled(self.isInstallingCLI) @@ -48,7 +49,7 @@ struct AdvancedPane: View { .lineLimit(2) } } - Text("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.") + Text(L10n.tr("preferences.advanced.installCLI.subtitle", default: "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -57,12 +58,12 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Show Debug Settings", - subtitle: "Expose troubleshooting tools in the Debug tab.", + title: L10n.tr("preferences.advanced.showDebug.title", default: "Show Debug Settings"), + subtitle: L10n.tr("preferences.advanced.showDebug.subtitle", default: "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("preferences.advanced.surprise.title", default: "Surprise me"), + subtitle: L10n.tr("preferences.advanced.surprise.subtitle", default: "Check if you like your agents having some fun up there."), binding: self.$settings.randomBlinkEnabled) } @@ -70,22 +71,21 @@ 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("preferences.advanced.hidePII.title", default: "Hide personal information"), + subtitle: L10n.tr("preferences.advanced.hidePII.subtitle", default: "Obscure email addresses in the menu bar and menu UI."), binding: self.$settings.hidePersonalInfo) } Divider() SettingsSection( - title: "Keychain access", - caption: """ - Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie \ - headers manually in Providers. - """) { + title: L10n.tr("preferences.advanced.keychain.title", default: "Keychain access"), + caption: L10n.tr( + "preferences.advanced.keychain.caption", + default: "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("preferences.advanced.disableKeychain.title", default: "Disable Keychain access"), + subtitle: L10n.tr("preferences.advanced.disableKeychain.subtitle", default: "Prevents any Keychain access while enabled."), binding: self.$settings.debugDisableKeychainAccess) } } @@ -105,7 +105,7 @@ 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("preferences.advanced.installCLI.missing", default: "CodexBarCLI not found in app bundle.") return } @@ -119,29 +119,29 @@ 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)") + results.append(L10n.tr("preferences.advanced.installCLI.noWriteAccess", default: "No write access: %@", dir)) continue } if fm.fileExists(atPath: dest) { if Self.isLink(atPath: dest, pointingTo: helperURL.path) { - results.append("Installed: \(dir)") + results.append(L10n.tr("preferences.advanced.installCLI.installed", default: "Installed: %@", dir)) } else { - results.append("Exists: \(dir)") + results.append(L10n.tr("preferences.advanced.installCLI.exists", default: "Exists: %@", dir)) } continue } do { try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path) - results.append("Installed: \(dir)") + results.append(L10n.tr("preferences.advanced.installCLI.installed", default: "Installed: %@", dir)) } catch { - results.append("Failed: \(dir)") + results.append(L10n.tr("preferences.advanced.installCLI.failed", default: "Failed: %@", dir)) } } self.cliStatus = results.isEmpty - ? "No writable bin dirs found." + ? L10n.tr("preferences.advanced.installCLI.noDirs", default: "No writable bin dirs found.") : results.joined(separator: " · ") } diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index a86a55434..0292deeb2 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -26,10 +26,10 @@ struct DebugPane: View { var body: some View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 20) { - SettingsSection(title: "Logging") { + SettingsSection(title: L10n.tr("preferences.debug.logging.title", default: "Logging")) { PreferenceToggleRow( - title: "Enable file logging", - subtitle: "Write logs to \(self.fileLogPath) for debugging.", + title: L10n.tr("preferences.debug.logging.enable.title", default: "Enable file logging"), + subtitle: L10n.tr("preferences.debug.logging.enable.subtitle", default: "Write logs to %@ for debugging.", self.fileLogPath), binding: self.$debugFileLoggingEnabled) .onChange(of: self.debugFileLoggingEnabled) { _, newValue in if self.settings.debugFileLoggingEnabled != newValue { @@ -39,14 +39,14 @@ struct DebugPane: View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Verbosity") + Text(L10n.tr("preferences.debug.logging.verbosity", default: "Verbosity")) .font(.body) - Text("Controls how much detail is logged.") + Text(L10n.tr("preferences.debug.logging.verbosity.subtitle", default: "Controls how much detail is logged.")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Verbosity", selection: self.$settings.debugLogLevel) { + Picker(L10n.tr("preferences.debug.logging.verbosity", default: "Verbosity"), selection: self.$settings.debugLogLevel) { ForEach(CodexBarLog.Level.allCases) { level in Text(level.displayName).tag(level) } @@ -59,31 +59,31 @@ struct DebugPane: View { Button { NSWorkspace.shared.open(CodexBarLog.fileLogURL) } label: { - Label("Open log file", systemImage: "doc.text.magnifyingglass") + Label(L10n.tr("preferences.debug.logging.openFile", default: "Open log file"), systemImage: "doc.text.magnifyingglass") } .controlSize(.small) } SettingsSection { PreferenceToggleRow( - title: "Force animation on next refresh", - subtitle: "Temporarily shows the loading animation after the next refresh.", + title: L10n.tr("preferences.debug.forceAnimation.title", default: "Force animation on next refresh"), + subtitle: L10n.tr("preferences.debug.forceAnimation.subtitle", default: "Temporarily shows the loading animation after the next refresh."), binding: self.$store.debugForceAnimation) } SettingsSection( - title: "Loading animations", - caption: "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior.") + title: L10n.tr("preferences.debug.animations.title", default: "Loading animations"), + caption: L10n.tr("preferences.debug.animations.caption", default: "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior.")) { - Picker("Animation pattern", selection: self.animationPatternBinding) { - Text("Random (default)").tag(nil as LoadingPattern?) + Picker(L10n.tr("preferences.debug.animations.pattern", default: "Animation pattern"), selection: self.animationPatternBinding) { + Text(L10n.tr("preferences.debug.animations.random", default: "Random (default)")).tag(nil as LoadingPattern?) ForEach(LoadingPattern.allCases) { pattern in Text(pattern.displayName).tag(Optional(pattern)) } } .pickerStyle(.radioGroup) - Button("Replay selected animation") { + Button(L10n.tr("preferences.debug.animations.replay", default: "Replay selected animation")) { self.replaySelectedAnimation() } .keyboardShortcut(.defaultAction) @@ -91,16 +91,16 @@ struct DebugPane: View { Button { NotificationCenter.default.post(name: .codexbarDebugBlinkNow, object: nil) } label: { - Label("Blink now", systemImage: "eyes") + Label(L10n.tr("preferences.debug.animations.blink", default: "Blink now"), systemImage: "eyes") } .controlSize(.small) } SettingsSection( - title: "Probe logs", - caption: "Fetch the latest probe output for debugging; Copy keeps the full text.") + title: L10n.tr("preferences.debug.probeLogs.title", default: "Probe logs"), + caption: L10n.tr("preferences.debug.probeLogs.caption", default: "Fetch the latest probe output for debugging; Copy keeps the full text.")) { - Picker("Provider", selection: self.$currentLogProvider) { + Picker(L10n.tr("preferences.debug.provider", default: "Provider"), selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) Text("Cursor").tag(UsageProvider.cursor) @@ -113,23 +113,23 @@ struct DebugPane: View { HStack(spacing: 12) { Button { self.loadLog(self.currentLogProvider) } label: { - Label("Fetch log", systemImage: "arrow.clockwise") + Label(L10n.tr("preferences.debug.probeLogs.fetch", default: "Fetch log"), systemImage: "arrow.clockwise") } .disabled(self.isLoadingLog) Button { self.copyToPasteboard(self.logText) } label: { - Label("Copy", systemImage: "doc.on.doc") + Label(L10n.tr("common.copy", default: "Copy"), systemImage: "doc.on.doc") } .disabled(self.logText.isEmpty) Button { self.saveLog(self.currentLogProvider) } label: { - Label("Save to file", systemImage: "externaldrive.badge.plus") + Label(L10n.tr("preferences.debug.probeLogs.save", default: "Save to file"), systemImage: "externaldrive.badge.plus") } .disabled(self.isLoadingLog && self.logText.isEmpty) if self.currentLogProvider == .claude { Button { self.loadClaudeDump() } label: { - Label("Load parse dump", systemImage: "doc.text.magnifyingglass") + Label(L10n.tr("preferences.debug.probeLogs.parseDump", default: "Load parse dump"), systemImage: "doc.text.magnifyingglass") } .disabled(self.isLoadingLog) } @@ -139,7 +139,7 @@ struct DebugPane: View { self.settings.rerunProviderDetection() self.loadLog(self.currentLogProvider) } label: { - Label("Re-run provider autodetect", systemImage: "dot.radiowaves.left.and.right") + Label(L10n.tr("preferences.debug.probeLogs.redetect", default: "Re-run provider autodetect"), systemImage: "dot.radiowaves.left.and.right") } .controlSize(.small) @@ -165,10 +165,10 @@ struct DebugPane: View { } SettingsSection( - title: "Fetch strategy attempts", - caption: "Last fetch pipeline decisions and errors for a provider.") + title: L10n.tr("preferences.debug.fetchAttempts.title", default: "Fetch strategy attempts"), + caption: L10n.tr("preferences.debug.fetchAttempts.caption", default: "Last fetch pipeline decisions and errors for a provider.")) { - Picker("Provider", selection: self.$currentFetchProvider) { + Picker(L10n.tr("preferences.debug.provider", default: "Provider"), selection: self.$currentFetchProvider) { ForEach(UsageProvider.allCases, id: \.self) { provider in Text(provider.rawValue.capitalized).tag(provider) } @@ -190,14 +190,14 @@ struct DebugPane: View { if !self.settings.debugDisableKeychainAccess { SettingsSection( - title: "OpenAI cookies", - caption: "Cookie import + WebKit scrape logs from the last OpenAI cookies attempt.") + title: L10n.tr("preferences.debug.openAICookies.title", default: "OpenAI cookies"), + caption: L10n.tr("preferences.debug.openAICookies.caption", default: "Cookie import + WebKit scrape logs from the last OpenAI cookies attempt.")) { HStack(spacing: 12) { Button { self.copyToPasteboard(self.store.openAIDashboardCookieImportDebugLog ?? "") } label: { - Label("Copy", systemImage: "doc.on.doc") + Label(L10n.tr("common.copy", default: "Copy"), systemImage: "doc.on.doc") } .disabled((self.store.openAIDashboardCookieImportDebugLog ?? "").isEmpty) } @@ -206,7 +206,7 @@ struct DebugPane: View { Text( self.store.openAIDashboardCookieImportDebugLog?.isEmpty == false ? (self.store.openAIDashboardCookieImportDebugLog ?? "") - : "No log yet. Update OpenAI cookies in Providers → Codex to run an import.") + : L10n.tr("preferences.debug.openAICookies.empty", default: "No log yet. Update OpenAI cookies in Providers → Codex to run an import.")) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -219,8 +219,8 @@ struct DebugPane: View { } SettingsSection( - title: "Caches", - caption: "Clear cached cost scan results.") + title: L10n.tr("preferences.debug.caches.title", default: "Caches"), + caption: L10n.tr("preferences.debug.caches.caption", default: "Clear cached cost scan results.")) { let isTokenRefreshActive = self.store.isTokenRefreshInFlight(for: .codex) || self.store.isTokenRefreshInFlight(for: .claude) @@ -229,7 +229,7 @@ struct DebugPane: View { Button { Task { await self.clearCostCache() } } label: { - Label("Clear cost cache", systemImage: "trash") + Label(L10n.tr("preferences.debug.caches.clear", default: "Clear cost cache"), systemImage: "trash") } .disabled(self.isClearingCostCache || isTokenRefreshActive) @@ -242,10 +242,10 @@ struct DebugPane: View { } SettingsSection( - title: "Notifications", - caption: "Trigger test notifications for the 5-hour session window (depleted/restored).") + title: L10n.tr("preferences.debug.notifications.title", default: "Notifications"), + caption: L10n.tr("preferences.debug.notifications.caption", default: "Trigger test notifications for the 5-hour session window (depleted/restored).")) { - Picker("Provider", selection: self.$currentLogProvider) { + Picker(L10n.tr("preferences.debug.provider", default: "Provider"), selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) } @@ -256,26 +256,26 @@ struct DebugPane: View { Button { self.postSessionNotification(.depleted, provider: self.currentLogProvider) } label: { - Label("Post depleted", systemImage: "bell.badge") + Label(L10n.tr("preferences.debug.notifications.depleted", default: "Post depleted"), systemImage: "bell.badge") } .controlSize(.small) Button { self.postSessionNotification(.restored, provider: self.currentLogProvider) } label: { - Label("Post restored", systemImage: "bell") + Label(L10n.tr("preferences.debug.notifications.restored", default: "Post restored"), systemImage: "bell") } .controlSize(.small) } } SettingsSection( - title: "CLI sessions", - caption: "Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured.") + title: L10n.tr("preferences.debug.cliSessions.title", default: "CLI sessions"), + caption: L10n.tr("preferences.debug.cliSessions.caption", default: "Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured.")) { PreferenceToggleRow( - title: "Keep CLI sessions alive", - subtitle: "Skip teardown between probes (debug-only).", + title: L10n.tr("preferences.debug.cliSessions.keepAlive.title", default: "Keep CLI sessions alive"), + subtitle: L10n.tr("preferences.debug.cliSessions.keepAlive.subtitle", default: "Skip teardown between probes (debug-only)."), binding: self.$settings.debugKeepCLISessionsAlive) Button { @@ -283,17 +283,17 @@ struct DebugPane: View { await CLIProbeSessionResetter.resetAll() } } label: { - Label("Reset CLI sessions", systemImage: "arrow.counterclockwise") + Label(L10n.tr("preferences.debug.cliSessions.reset", default: "Reset CLI sessions"), systemImage: "arrow.counterclockwise") } .controlSize(.small) } #if DEBUG SettingsSection( - title: "Error simulation", - caption: "Inject a fake error message into the menu card for layout testing.") + title: L10n.tr("preferences.debug.errorSimulation.title", default: "Error simulation"), + caption: L10n.tr("preferences.debug.errorSimulation.caption", default: "Inject a fake error message into the menu card for layout testing.")) { - Picker("Provider", selection: self.$currentErrorProvider) { + Picker(L10n.tr("preferences.debug.provider", default: "Provider"), selection: self.$currentErrorProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) Text("Gemini").tag(UsageProvider.gemini) @@ -305,7 +305,7 @@ struct DebugPane: View { .pickerStyle(.segmented) .frame(width: 360) - TextField("Simulated error text", text: self.$simulatedErrorText, axis: .vertical) + TextField(L10n.tr("preferences.debug.errorSimulation.text", default: "Simulated error text"), text: self.$simulatedErrorText, axis: .vertical) .lineLimit(4) HStack(spacing: 12) { @@ -314,14 +314,14 @@ struct DebugPane: View { self.simulatedErrorText, provider: self.currentErrorProvider) } label: { - Label("Set menu error", systemImage: "exclamationmark.triangle") + Label(L10n.tr("preferences.debug.errorSimulation.setMenu", default: "Set menu error"), systemImage: "exclamationmark.triangle") } .controlSize(.small) Button { self.store._setErrorForTesting(nil, provider: self.currentErrorProvider) } label: { - Label("Clear menu error", systemImage: "xmark.circle") + Label(L10n.tr("preferences.debug.errorSimulation.clearMenu", default: "Clear menu error"), systemImage: "xmark.circle") } .controlSize(.small) } @@ -333,7 +333,7 @@ struct DebugPane: View { self.simulatedErrorText, provider: self.currentErrorProvider) } label: { - Label("Set cost error", systemImage: "banknote") + Label(L10n.tr("preferences.debug.errorSimulation.setCost", default: "Set cost error"), systemImage: "banknote") } .controlSize(.small) .disabled(!supportsTokenError) @@ -341,7 +341,7 @@ struct DebugPane: View { Button { self.store._setTokenErrorForTesting(nil, provider: self.currentErrorProvider) } label: { - Label("Clear cost error", systemImage: "xmark.circle") + Label(L10n.tr("preferences.debug.errorSimulation.clearCost", default: "Clear cost error"), systemImage: "xmark.circle") } .controlSize(.small) .disabled(!supportsTokenError) @@ -350,19 +350,19 @@ struct DebugPane: View { #endif SettingsSection( - title: "CLI paths", - caption: "Resolved Codex binary and PATH layers; startup login PATH capture (short timeout).") + title: L10n.tr("preferences.debug.cliPaths.title", default: "CLI paths"), + caption: L10n.tr("preferences.debug.cliPaths.caption", default: "Resolved Codex binary and PATH layers; startup login PATH capture (short timeout).")) { - self.binaryRow(title: "Codex binary", value: self.store.pathDebugInfo.codexBinary) - self.binaryRow(title: "Claude binary", value: self.store.pathDebugInfo.claudeBinary) + self.binaryRow(title: L10n.tr("preferences.debug.cliPaths.codexBinary", default: "Codex binary"), value: self.store.pathDebugInfo.codexBinary) + self.binaryRow(title: L10n.tr("preferences.debug.cliPaths.claudeBinary", default: "Claude binary"), value: self.store.pathDebugInfo.claudeBinary) VStack(alignment: .leading, spacing: 6) { - Text("Effective PATH") + Text(L10n.tr("preferences.debug.cliPaths.effectivePath", default: "Effective PATH")) .font(.callout.weight(.semibold)) ScrollView { Text( self.store.pathDebugInfo.effectivePATH.isEmpty - ? "Unavailable" + ? L10n.tr("preferences.debug.cliPaths.unavailable", default: "Unavailable") : self.store.pathDebugInfo.effectivePATH) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) @@ -376,7 +376,7 @@ struct DebugPane: View { if let loginPATH = self.store.pathDebugInfo.loginShellPATH { VStack(alignment: .leading, spacing: 6) { - Text("Login shell PATH (startup capture)") + Text(L10n.tr("preferences.debug.cliPaths.loginPath", default: "Login shell PATH (startup capture)")) .font(.callout.weight(.semibold)) ScrollView { Text(loginPATH) @@ -422,7 +422,9 @@ struct DebugPane: View { private var displayedLog: String { if self.logText.isEmpty { - return self.isLoadingLog ? "Loading…" : "No log yet. Fetch to load." + return self.isLoadingLog + ? L10n.tr("preferences.debug.loading", default: "Loading…") + : L10n.tr("preferences.debug.emptyLog", default: "No log yet. Fetch to load.") } return self.logText } @@ -472,7 +474,7 @@ struct DebugPane: View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.callout.weight(.semibold)) - Text(value ?? "Not found") + Text(value ?? L10n.tr("preferences.debug.cliPaths.notFound", default: "Not found")) .font(.system(.footnote, design: .monospaced)) .foregroundStyle(value == nil ? .secondary : .primary) } @@ -500,22 +502,24 @@ struct DebugPane: View { defer { self.isClearingCostCache = false } if let error = await self.store.clearCostUsageCache() { - self.costCacheStatus = "Failed: \(error)" + self.costCacheStatus = L10n.tr("preferences.debug.caches.failed", default: "Failed: %@", error) return } - self.costCacheStatus = "Cleared." + self.costCacheStatus = L10n.tr("preferences.debug.caches.cleared", default: "Cleared.") } private func fetchAttemptsText(for provider: UsageProvider) -> String { let attempts = self.store.fetchAttempts(for: provider) - guard !attempts.isEmpty else { return "No fetch attempts yet." } + guard !attempts.isEmpty else { return L10n.tr("preferences.debug.fetchAttempts.empty", default: "No fetch attempts yet.") } return attempts.map { attempt in let kind = Self.fetchKindLabel(attempt.kind) var line = "\(attempt.strategyID) (\(kind))" - line += attempt.wasAvailable ? " available" : " unavailable" + line += attempt.wasAvailable + ? L10n.tr("preferences.debug.fetchAttempts.available", default: " available") + : L10n.tr("preferences.debug.fetchAttempts.unavailable", default: " unavailable") if let error = attempt.errorDescription, !error.isEmpty { - line += " error=\(error)" + line += L10n.tr("preferences.debug.fetchAttempts.error", default: " error=%@", error) } return line }.joined(separator: "\n") @@ -523,12 +527,12 @@ struct DebugPane: View { private static func fetchKindLabel(_ kind: ProviderFetchKind) -> String { switch kind { - case .cli: "cli" - case .web: "web" - case .oauth: "oauth" - case .apiToken: "api" - case .localProbe: "local" - case .webDashboard: "web" + case .cli: L10n.tr("preferences.debug.fetchKind.cli", default: "cli") + case .web: L10n.tr("preferences.debug.fetchKind.web", default: "web") + case .oauth: L10n.tr("preferences.debug.fetchKind.oauth", default: "oauth") + case .apiToken: L10n.tr("preferences.debug.fetchKind.api", default: "api") + case .localProbe: L10n.tr("preferences.debug.fetchKind.local", default: "local") + case .webDashboard: L10n.tr("preferences.debug.fetchKind.web", default: "web") } } } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 39a95a55f..29c573fd2 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -11,20 +11,20 @@ struct GeneralPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("System") + Text(L10n.tr("preferences.general.section.system", default: "System")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Start at Login", - subtitle: "Automatically opens CodexBar when you start your Mac.", + title: L10n.tr("preferences.general.startAtLogin.title", default: "Start at Login"), + subtitle: L10n.tr("preferences.general.startAtLogin.subtitle", default: "Automatically opens CodexBar when you start your Mac."), binding: self.$settings.launchAtLogin) } Divider() SettingsSection(contentSpacing: 12) { - Text("Usage") + Text(L10n.tr("preferences.general.section.usage", default: "Usage")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -32,18 +32,18 @@ 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("preferences.general.costSummary.title", default: "Show cost summary")) .font(.body) } .toggleStyle(.checkbox) - Text("Reads local usage logs. Shows today + last 30 days cost in the menu.") + Text(L10n.tr("preferences.general.costSummary.subtitle", default: "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("preferences.general.costSummary.status", default: "Auto-refresh: hourly · Timeout: 10m")) .font(.footnote) .foregroundStyle(.tertiary) @@ -57,21 +57,21 @@ struct GeneralPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Automation") + Text(L10n.tr("preferences.general.section.automation", default: "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("preferences.general.refreshCadence.title", default: "Refresh cadence")) .font(.body) - Text("How often CodexBar polls providers in the background.") + Text(L10n.tr("preferences.general.refreshCadence.subtitle", default: "How often CodexBar polls providers in the background.")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Refresh cadence", selection: self.$settings.refreshFrequency) { + Picker(L10n.tr("preferences.general.refreshCadence.picker", default: "Refresh cadence"), selection: self.$settings.refreshFrequency) { ForEach(RefreshFrequency.allCases) { option in Text(option.label).tag(option) } @@ -81,20 +81,22 @@ 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("preferences.general.refreshCadence.manualHint", default: "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("preferences.general.statusChecks.title", default: "Check provider status"), + subtitle: L10n.tr( + "preferences.general.statusChecks.subtitle", + default: "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("preferences.general.notifications.title", default: "Session quota notifications"), + subtitle: L10n.tr( + "preferences.general.notifications.subtitle", + default: "Notifies when the 5-hour session quota hits 0% and when it becomes available again."), binding: self.$settings.sessionQuotaNotificationsEnabled) } @@ -103,7 +105,7 @@ struct GeneralPane: View { SettingsSection(contentSpacing: 12) { HStack { Spacer() - Button("Quit CodexBar") { NSApp.terminate(nil) } + Button(L10n.tr("preferences.general.quit", default: "Quit CodexBar")) { NSApp.terminate(nil) } .buttonStyle(.borderedProminent) .controlSize(.large) } @@ -119,7 +121,7 @@ struct GeneralPane: View { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName guard provider == .claude || provider == .codex else { - return Text("\(name): unsupported") + return Text(L10n.tr("preferences.general.costStatus.unsupported", default: "%@: unsupported", name)) .font(.footnote) .foregroundStyle(.tertiary) } @@ -133,20 +135,20 @@ struct GeneralPane: View { formatter.unitsStyle = .abbreviated return formatter.string(from: seconds).map { " (\($0))" } ?? "" }() - return Text("\(name): fetching…\(elapsed)") + return Text(L10n.tr("preferences.general.costStatus.fetching", default: "%@: fetching…%@", 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)") + return Text(L10n.tr("preferences.general.costStatus.snapshot", default: "%@: %@ · 30d %@", name, updated, cost)) .font(.footnote) .foregroundStyle(.tertiary) } if let error = self.store.tokenError(for: provider), !error.isEmpty { let truncated = UsageFormatter.truncatedSingleLine(error, max: 120) - return Text("\(name): \(truncated)") + return Text(L10n.tr("preferences.general.costStatus.error", default: "%@: %@", name, truncated)) .font(.footnote) .foregroundStyle(.tertiary) } @@ -154,11 +156,11 @@ struct GeneralPane: View { let rel = RelativeDateTimeFormatter() rel.unitsStyle = .abbreviated let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) - return Text("\(name): last attempt \(when)") + return Text(L10n.tr("preferences.general.costStatus.lastAttempt", default: "%@: last attempt %@", name, when)) .font(.footnote) .foregroundStyle(.tertiary) } - return Text("\(name): no data yet") + return Text(L10n.tr("preferences.general.costStatus.noData", default: "%@: no data yet", name)) .font(.footnote) .foregroundStyle(.tertiary) } diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index a6f893950..0cfdb5fbb 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,28 @@ 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("preferences.tab.general", default: "General"), systemImage: "gearshape") } .tag(PreferencesTab.general) ProvidersPane(settings: self.settings, store: self.store) - .tabItem { Label("Providers", systemImage: "square.grid.2x2") } + .tabItem { Label(L10n.tr("preferences.tab.providers", default: "Providers"), systemImage: "square.grid.2x2") } .tag(PreferencesTab.providers) DisplayPane(settings: self.settings, store: self.store) - .tabItem { Label("Display", systemImage: "eye") } + .tabItem { Label(L10n.tr("preferences.tab.display", default: "Display"), systemImage: "eye") } .tag(PreferencesTab.display) AdvancedPane(settings: self.settings) - .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } + .tabItem { Label(L10n.tr("preferences.tab.advanced", default: "Advanced"), systemImage: "slider.horizontal.3") } .tag(PreferencesTab.advanced) AboutPane(updater: self.updater) - .tabItem { Label("About", systemImage: "info.circle") } + .tabItem { Label(L10n.tr("preferences.tab.about", default: "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("preferences.tab.debug", default: "Debug"), systemImage: "ladybug") } .tag(PreferencesTab.debug) } } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 86314de23..7746964f1 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -224,7 +224,7 @@ extension StatusItemController { title: AppStrings.string(LocalizationCatalog.Alert.Login.codexTimedOutTitle), message: self.trimmedLoginOutput(result.output)) case let .failed(status): - let statusLine = "codex login exited with status \(status)." + let statusLine = L10n.tr("login.codex.failed.status", default: "codex login exited with status %@.", String(status)) let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) self.presentLoginAlert(title: AppStrings.string(LocalizationCatalog.Alert.Login.codexFailedTitle), message: message) } @@ -236,18 +236,18 @@ extension StatusItemController { return case .missingBinary: self.presentLoginAlert( - title: "Claude CLI not found", - message: "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.") + title: L10n.tr("login.claude.missingBinary.title", default: "Claude CLI not found"), + message: L10n.tr("login.claude.missingBinary.message", default: "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.")) case let .launchFailed(message): - self.presentLoginAlert(title: "Could not start claude /login", message: message) + self.presentLoginAlert(title: L10n.tr("login.claude.launchFailed.title", default: "Could not start claude /login"), message: message) case .timedOut: self.presentLoginAlert( - title: "Claude login timed out", + title: L10n.tr("login.claude.timedOut.title", default: "Claude login timed out"), message: self.trimmedLoginOutput(result.output)) case let .failed(status): - let statusLine = "claude /login exited with status \(status)." + let statusLine = L10n.tr("login.claude.failed.status", default: "claude /login exited with status %@.", String(status)) let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) - self.presentLoginAlert(title: "Claude login failed", message: message) + self.presentLoginAlert(title: L10n.tr("login.claude.failed.title", default: "Claude login failed"), message: message) } } @@ -295,10 +295,10 @@ extension StatusItemController { nil case .missingBinary: LoginAlertInfo( - title: "Gemini CLI not found", - message: "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.") + title: L10n.tr("login.gemini.missingBinary.title", default: "Gemini CLI not found"), + message: L10n.tr("login.gemini.missingBinary.message", default: "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.")) case let .launchFailed(message): - LoginAlertInfo(title: "Could not open Terminal for Gemini", message: message) + LoginAlertInfo(title: L10n.tr("login.gemini.launchFailed.title", default: "Could not open Terminal for Gemini"), message: message) } } @@ -313,7 +313,7 @@ extension StatusItemController { private func trimmedLoginOutput(_ text: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let limit = 600 - if trimmed.isEmpty { return "No output captured." } + if trimmed.isEmpty { return L10n.tr("login.noOutput", default: "No output captured.") } if trimmed.count <= limit { return trimmed } let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) return "\(trimmed[.. String { + let format = Bundle.module.localizedString(forKey: key, value: defaultValue, table: nil) + guard !arguments.isEmpty else { return format } + return String(format: format, locale: Locale.current, arguments: arguments) + } +} diff --git a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..9a8180bcb --- /dev/null +++ b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings @@ -0,0 +1,174 @@ +"about.built" = "Built %@"; +"about.credits" = "Peter Steinberger — MIT License\\n"; +"common.copy" = "Copy"; +"common.ok" = "OK"; +"keychain.ampCookie.message" = "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue."; +"keychain.augmentCookie.message" = "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue."; +"keychain.browserCookie.message" = "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue."; +"keychain.claudeCookie.message" = "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue."; +"keychain.claudeOAuth.message" = "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue."; +"keychain.codexCookie.message" = "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue."; +"keychain.copilotToken.message" = "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue."; +"keychain.cursorCookie.message" = "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue."; +"keychain.factoryCookie.message" = "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue."; +"keychain.kimiToken.message" = "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue."; +"keychain.kimik2Token.message" = "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue."; +"keychain.minimaxCookie.message" = "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue."; +"keychain.minimaxToken.message" = "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue."; +"keychain.opencodeCookie.message" = "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue."; +"keychain.syntheticToken.message" = "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue."; +"keychain.title" = "Keychain Access Required"; +"keychain.zaiToken.message" = "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue."; +"login.claude.failed.status" = "claude /login exited with status %@."; +"login.claude.failed.title" = "Claude login failed"; +"login.claude.launchFailed.title" = "Could not start claude /login"; +"login.claude.missingBinary.message" = "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again."; +"login.claude.missingBinary.title" = "Claude CLI not found"; +"login.claude.timedOut.title" = "Claude login timed out"; +"login.codex.failed.status" = "codex login exited with status %@."; +"login.cursor.failed.title" = "Cursor login failed"; +"login.gemini.launchFailed.title" = "Could not open Terminal for Gemini"; +"login.gemini.missingBinary.message" = "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again."; +"login.gemini.missingBinary.title" = "Gemini CLI not found"; +"login.noOutput" = "No output captured."; +"login.success.body" = "You can return to the app; authentication finished."; +"login.success.title" = "%@ login successful"; +"menu.about" = "About CodexBar"; +"menu.account" = "Account: %@"; +"menu.activity" = "Activity: %@"; +"menu.addAccount" = "Add Account..."; +"menu.dashboard" = "Usage Dashboard"; +"menu.noUsageConfigured" = "No usage configured."; +"menu.noUsageYet" = "No usage yet"; +"menu.plan" = "Plan: %@"; +"menu.quit" = "Quit"; +"menu.quota" = "Quota: %@ / %@"; +"menu.rateWindow" = "%@: %@"; +"menu.settings" = "Settings..."; +"menu.statusPage" = "Status Page"; +"menu.switchAccount" = "Switch Account..."; +"menu.updateReady" = "Update ready, restart now?"; +"preferences.about.autoUpdates" = "Check for updates automatically"; +"preferences.about.built" = "Built %@"; +"preferences.about.checkForUpdates" = "Check for Updates…"; +"preferences.about.tagline" = "May your tokens never run out—keep agent limits in view."; +"preferences.about.updateChannel" = "Update Channel"; +"preferences.about.updatesUnavailable" = "Updates unavailable in this build."; +"preferences.about.version" = "Version %@"; +"preferences.advanced.disableKeychain.subtitle" = "Prevents any Keychain access while enabled."; +"preferences.advanced.disableKeychain.title" = "Disable Keychain access"; +"preferences.advanced.hidePII.subtitle" = "Obscure email addresses in the menu bar and menu UI."; +"preferences.advanced.hidePII.title" = "Hide personal information"; +"preferences.advanced.installCLI.button" = "Install CLI"; +"preferences.advanced.installCLI.exists" = "Exists: %@"; +"preferences.advanced.installCLI.failed" = "Failed: %@"; +"preferences.advanced.installCLI.installed" = "Installed: %@"; +"preferences.advanced.installCLI.missing" = "CodexBarCLI not found in app bundle."; +"preferences.advanced.installCLI.noDirs" = "No writable bin dirs found."; +"preferences.advanced.installCLI.noWriteAccess" = "No write access: %@"; +"preferences.advanced.installCLI.subtitle" = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; +"preferences.advanced.keychain.caption" = "Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers."; +"preferences.advanced.keychain.title" = "Keychain access"; +"preferences.advanced.openMenu.subtitle" = "Trigger the menu bar menu from anywhere."; +"preferences.advanced.openMenu.title" = "Open menu"; +"preferences.advanced.section.shortcut" = "Keyboard shortcut"; +"preferences.advanced.showDebug.subtitle" = "Expose troubleshooting tools in the Debug tab."; +"preferences.advanced.showDebug.title" = "Show Debug Settings"; +"preferences.advanced.surprise.subtitle" = "Check if you like your agents having some fun up there."; +"preferences.advanced.surprise.title" = "Surprise me"; +"preferences.debug.animations.blink" = "Blink now"; +"preferences.debug.animations.caption" = "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior."; +"preferences.debug.animations.pattern" = "Animation pattern"; +"preferences.debug.animations.random" = "Random (default)"; +"preferences.debug.animations.replay" = "Replay selected animation"; +"preferences.debug.animations.title" = "Loading animations"; +"preferences.debug.caches.caption" = "Clear cached cost scan results."; +"preferences.debug.caches.clear" = "Clear cost cache"; +"preferences.debug.caches.cleared" = "Cleared."; +"preferences.debug.caches.failed" = "Failed: %@"; +"preferences.debug.caches.title" = "Caches"; +"preferences.debug.cliPaths.caption" = "Resolved Codex binary and PATH layers; startup login PATH capture (short timeout)."; +"preferences.debug.cliPaths.claudeBinary" = "Claude binary"; +"preferences.debug.cliPaths.codexBinary" = "Codex binary"; +"preferences.debug.cliPaths.effectivePath" = "Effective PATH"; +"preferences.debug.cliPaths.loginPath" = "Login shell PATH (startup capture)"; +"preferences.debug.cliPaths.notFound" = "Not found"; +"preferences.debug.cliPaths.title" = "CLI paths"; +"preferences.debug.cliPaths.unavailable" = "Unavailable"; +"preferences.debug.cliSessions.caption" = "Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured."; +"preferences.debug.cliSessions.keepAlive.subtitle" = "Skip teardown between probes (debug-only)."; +"preferences.debug.cliSessions.keepAlive.title" = "Keep CLI sessions alive"; +"preferences.debug.cliSessions.reset" = "Reset CLI sessions"; +"preferences.debug.cliSessions.title" = "CLI sessions"; +"preferences.debug.emptyLog" = "No log yet. Fetch to load."; +"preferences.debug.errorSimulation.caption" = "Inject a fake error message into the menu card for layout testing."; +"preferences.debug.errorSimulation.clearCost" = "Clear cost error"; +"preferences.debug.errorSimulation.clearMenu" = "Clear menu error"; +"preferences.debug.errorSimulation.setCost" = "Set cost error"; +"preferences.debug.errorSimulation.setMenu" = "Set menu error"; +"preferences.debug.errorSimulation.text" = "Simulated error text"; +"preferences.debug.errorSimulation.title" = "Error simulation"; +"preferences.debug.fetchAttempts.available" = " available"; +"preferences.debug.fetchAttempts.caption" = "Last fetch pipeline decisions and errors for a provider."; +"preferences.debug.fetchAttempts.empty" = "No fetch attempts yet."; +"preferences.debug.fetchAttempts.error" = " error=%@"; +"preferences.debug.fetchAttempts.title" = "Fetch strategy attempts"; +"preferences.debug.fetchAttempts.unavailable" = " unavailable"; +"preferences.debug.fetchKind.api" = "api"; +"preferences.debug.fetchKind.cli" = "cli"; +"preferences.debug.fetchKind.local" = "local"; +"preferences.debug.fetchKind.oauth" = "oauth"; +"preferences.debug.fetchKind.web" = "web"; +"preferences.debug.forceAnimation.subtitle" = "Temporarily shows the loading animation after the next refresh."; +"preferences.debug.forceAnimation.title" = "Force animation on next refresh"; +"preferences.debug.loading" = "Loading…"; +"preferences.debug.logging.enable.subtitle" = "Write logs to %@ for debugging."; +"preferences.debug.logging.enable.title" = "Enable file logging"; +"preferences.debug.logging.openFile" = "Open log file"; +"preferences.debug.logging.title" = "Logging"; +"preferences.debug.logging.verbosity" = "Verbosity"; +"preferences.debug.logging.verbosity.subtitle" = "Controls how much detail is logged."; +"preferences.debug.notifications.caption" = "Trigger test notifications for the 5-hour session window (depleted/restored)."; +"preferences.debug.notifications.depleted" = "Post depleted"; +"preferences.debug.notifications.restored" = "Post restored"; +"preferences.debug.notifications.title" = "Notifications"; +"preferences.debug.openAICookies.caption" = "Cookie import + WebKit scrape logs from the last OpenAI cookies attempt."; +"preferences.debug.openAICookies.empty" = "No log yet. Update OpenAI cookies in Providers → Codex to run an import."; +"preferences.debug.openAICookies.title" = "OpenAI cookies"; +"preferences.debug.probeLogs.caption" = "Fetch the latest probe output for debugging; Copy keeps the full text."; +"preferences.debug.probeLogs.fetch" = "Fetch log"; +"preferences.debug.probeLogs.parseDump" = "Load parse dump"; +"preferences.debug.probeLogs.redetect" = "Re-run provider autodetect"; +"preferences.debug.probeLogs.save" = "Save to file"; +"preferences.debug.probeLogs.title" = "Probe logs"; +"preferences.debug.provider" = "Provider"; +"preferences.general.costStatus.error" = "%@: %@"; +"preferences.general.costStatus.fetching" = "%@: fetching…%@"; +"preferences.general.costStatus.lastAttempt" = "%@: last attempt %@"; +"preferences.general.costStatus.noData" = "%@: no data yet"; +"preferences.general.costStatus.snapshot" = "%@: %@ · 30d %@"; +"preferences.general.costStatus.unsupported" = "%@: unsupported"; +"preferences.general.costSummary.status" = "Auto-refresh: hourly · Timeout: 10m"; +"preferences.general.costSummary.subtitle" = "Reads local usage logs. Shows today + last 30 days cost in the menu."; +"preferences.general.costSummary.title" = "Show cost summary"; +"preferences.general.notifications.subtitle" = "Notifies when the 5-hour session quota hits 0% and when it becomes available again."; +"preferences.general.notifications.title" = "Session quota notifications"; +"preferences.general.quit" = "Quit CodexBar"; +"preferences.general.refreshCadence.manualHint" = "Auto-refresh is off; use the menu's Refresh command."; +"preferences.general.refreshCadence.picker" = "Refresh cadence"; +"preferences.general.refreshCadence.subtitle" = "How often CodexBar polls providers in the background."; +"preferences.general.refreshCadence.title" = "Refresh cadence"; +"preferences.general.section.automation" = "Automation"; +"preferences.general.section.system" = "System"; +"preferences.general.section.usage" = "Usage"; +"preferences.general.startAtLogin.subtitle" = "Automatically opens CodexBar when you start your Mac."; +"preferences.general.startAtLogin.title" = "Start at Login"; +"preferences.general.statusChecks.subtitle" = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."; +"preferences.general.statusChecks.title" = "Check provider status"; +"preferences.tab.about" = "About"; +"preferences.tab.advanced" = "Advanced"; +"preferences.tab.debug" = "Debug"; +"preferences.tab.display" = "Display"; +"preferences.tab.general" = "General"; +"preferences.tab.providers" = "Providers"; +"provider.label.sonnet" = "Sonnet"; From ecbc54d0e9ec6c445c8b06e8a4a7f8e1cd5851b3 Mon Sep 17 00:00:00 2001 From: SNode Date: Sun, 22 Mar 2026 14:07:50 +0800 Subject: [PATCH 2/2] Add Simplified Chinese localization groundwork --- Sources/CodexBar/About.swift | 5 +- .../CodexBar/CostHistoryChartMenuView.swift | 4 +- .../CreditsHistoryChartMenuView.swift | 7 +- Sources/CodexBar/CursorLoginRunner.swift | 7 +- .../CodexBar/Date+RelativeDescription.swift | 3 +- Sources/CodexBar/MenuCardView.swift | 93 ++-- Sources/CodexBar/MenuDescriptor.swift | 6 +- ...penAICreditsPurchaseWindowController.swift | 2 +- Sources/CodexBar/PersonalInfoRedactor.swift | 5 +- Sources/CodexBar/PreferencesAboutPane.swift | 10 +- .../PreferencesProviderDetailView.swift | 81 ++- .../PreferencesProviderErrorView.swift | 10 +- .../PreferencesProviderSettingsRows.swift | 13 +- .../PreferencesProviderSidebarView.swift | 9 +- .../CodexBar/PreferencesProvidersPane.swift | 42 +- ...babaCodingPlanProviderImplementation.swift | 42 +- .../Amp/AmpProviderImplementation.swift | 16 +- .../Antigravity/AntigravityLoginFlow.swift | 4 +- .../AugmentProviderImplementation.swift | 26 +- .../Claude/ClaudeProviderImplementation.swift | 78 ++- .../Codex/CodexProviderImplementation.swift | 65 ++- .../Providers/Copilot/CopilotLoginFlow.swift | 33 +- .../CopilotProviderImplementation.swift | 14 +- .../Cursor/CursorProviderImplementation.swift | 32 +- .../FactoryProviderImplementation.swift | 22 +- .../JetBrains/JetBrainsLoginFlow.swift | 22 +- .../JetBrainsProviderImplementation.swift | 14 +- .../Kilo/KiloProviderImplementation.swift | 15 +- .../Kimi/KimiProviderImplementation.swift | 20 +- .../KimiK2/KimiK2ProviderImplementation.swift | 10 +- .../MiniMaxProviderImplementation.swift | 44 +- .../Ollama/OllamaProviderImplementation.swift | 16 +- .../OpenCodeProviderImplementation.swift | 30 +- .../OpenRouterProviderImplementation.swift | 10 +- .../Shared/ProviderCookieSourceUI.swift | 4 +- .../Shared/ProviderPresentation.swift | 3 +- .../SyntheticProviderImplementation.swift | 10 +- .../VertexAI/VertexAILoginFlow.swift | 19 +- .../Warp/WarpProviderImplementation.swift | 9 +- .../Zai/ZaiProviderImplementation.swift | 8 +- .../CodexBar/SessionQuotaNotifications.swift | 10 +- .../CodexBar/StatusItemController+Menu.swift | 35 +- .../StatusItemController+SwitcherViews.swift | 2 +- Sources/CodexBar/StatusItemController.swift | 4 +- Sources/CodexBar/UpdateChannel.swift | 9 +- .../UsageBreakdownChartMenuView.swift | 2 +- Sources/CodexBar/UsagePaceText.swift | 46 +- Sources/CodexBar/UsageStore+TokenCost.swift | 2 +- Sources/CodexBarCore/CostUsageFetcher.swift | 6 +- .../Localization/LocalizedProviderText.swift | 199 +++++++ .../Alibaba/AlibabaCodingPlanAPIRegion.swift | 10 +- .../AlibabaCodingPlanProviderDescriptor.swift | 10 +- .../Providers/Amp/AmpProviderDescriptor.swift | 8 +- .../AntigravityProviderDescriptor.swift | 10 +- .../Augment/AugmentProviderDescriptor.swift | 12 +- .../Claude/ClaudeProviderDescriptor.swift | 10 +- .../Claude/ClaudeUsageDataSource.swift | 8 +- .../Codex/CodexProviderDescriptor.swift | 12 +- .../Codex/CodexUsageDataSource.swift | 6 +- .../Copilot/CopilotProviderDescriptor.swift | 8 +- .../Cursor/CursorProviderDescriptor.swift | 12 +- .../Factory/FactoryProviderDescriptor.swift | 8 +- .../Gemini/GeminiProviderDescriptor.swift | 10 +- .../JetBrainsProviderDescriptor.swift | 8 +- .../Kilo/KiloProviderDescriptor.swift | 8 +- .../Providers/Kilo/KiloUsageDataSource.swift | 6 +- .../Kimi/KimiProviderDescriptor.swift | 8 +- .../KimiK2/KimiK2ProviderDescriptor.swift | 8 +- .../Kiro/KiroProviderDescriptor.swift | 8 +- .../Providers/MiniMax/MiniMaxAPIRegion.swift | 7 +- .../MiniMax/MiniMaxProviderDescriptor.swift | 8 +- .../Ollama/OllamaProviderDescriptor.swift | 8 +- .../OpenCode/OpenCodeProviderDescriptor.swift | 8 +- .../OpenRouterProviderDescriptor.swift | 10 +- .../Providers/ProviderCookieSource.swift | 6 +- .../SyntheticProviderDescriptor.swift | 8 +- .../VertexAI/VertexAIProviderDescriptor.swift | 9 +- .../Warp/WarpProviderDescriptor.swift | 8 +- .../Providers/Zai/ZaiAPIRegion.swift | 4 +- .../Providers/Zai/ZaiProviderDescriptor.swift | 8 +- .../Resources/en.lproj/Localizable.strings | 13 + .../zh-Hans.lproj/Localizable.strings | 518 ++++++++++++++++++ .../TokenAccountSupportCatalog+Data.swift | 48 +- Sources/CodexBarCore/UsageFormatter.swift | 63 ++- .../CodexBarWidget/CodexBarWidgetBundle.swift | 33 +- .../CodexBarWidgetProvider.swift | 6 +- .../CodexBarWidget/CodexBarWidgetViews.swift | 70 ++- Sources/CodexBarWidget/WidgetStrings.swift | 1 - 88 files changed, 1641 insertions(+), 543 deletions(-) create mode 100644 Sources/CodexBarCore/Localization/LocalizedProviderText.swift create mode 100644 Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings diff --git a/Sources/CodexBar/About.swift b/Sources/CodexBar/About.swift index e40d4ad2d..cbf34e209 100644 --- a/Sources/CodexBar/About.swift +++ b/Sources/CodexBar/About.swift @@ -1,4 +1,5 @@ import AppKit +import CodexBarCore @MainActor func showAbout() { @@ -24,11 +25,11 @@ func showAbout() { let credits = NSMutableAttributedString(string: L10n.tr("about.credits", default: "Peter Steinberger — MIT License\n")) credits.append(makeLink("GitHub", urlString: "https://github.com/steipete/CodexBar")) credits.append(separator) - credits.append(makeLink("Website", urlString: "https://codexbar.app")) + credits.append(makeLink(L10n.tr("common.website", default: "Website"), urlString: "https://codexbar.app")) credits.append(separator) credits.append(makeLink("Twitter", urlString: "https://twitter.com/steipete")) credits.append(separator) - credits.append(makeLink("Email", urlString: "mailto:peter@steipete.me")) + credits.append(makeLink(L10n.tr("common.email", default: "Email"), urlString: "mailto:peter@steipete.me")) if let buildTimestamp, let formatted = formattedBuildTimestamp(buildTimestamp) { var builtLine = L10n.tr("about.built", default: "Built %@", formatted) if let gitCommit, !gitCommit.isEmpty, gitCommit != "unknown" { diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index fec1135dc..e72ff4726 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -37,7 +37,7 @@ struct CostHistoryChartMenuView: View { let model = Self.makeModel(provider: self.provider, daily: self.daily) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No cost history data.") + Text(L10n.tr("chart.costHistory.empty", default: "No cost history data.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -107,7 +107,7 @@ struct CostHistoryChartMenuView: View { } if let total = self.totalCostUSD { - Text("Total (30d): \(UsageFormatter.usdString(total))") + Text(L10n.tr("chart.costHistory.total30Days", default: "Total (30d): %@", UsageFormatter.usdString(total))) .font(.caption) .foregroundStyle(.secondary) } diff --git a/Sources/CodexBar/CreditsHistoryChartMenuView.swift b/Sources/CodexBar/CreditsHistoryChartMenuView.swift index 9c5ca0b50..90d0b8502 100644 --- a/Sources/CodexBar/CreditsHistoryChartMenuView.swift +++ b/Sources/CodexBar/CreditsHistoryChartMenuView.swift @@ -29,7 +29,7 @@ struct CreditsHistoryChartMenuView: View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No credits history data.") + Text(L10n.tr("chart.creditsHistory.empty", default: "No credits history data.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -98,7 +98,10 @@ struct CreditsHistoryChartMenuView: View { } if let total = model.totalCreditsUsed { - Text("Total (30d): \(total.formatted(.number.precision(.fractionLength(0...2)))) credits") + Text(L10n.tr( + "chart.creditsHistory.total30Days", + default: "Total (30d): %@ credits", + total.formatted(.number.precision(.fractionLength(0...2))))) .font(.caption) .foregroundStyle(.secondary) } diff --git a/Sources/CodexBar/CursorLoginRunner.swift b/Sources/CodexBar/CursorLoginRunner.swift index f2b48f215..bf8677cd8 100644 --- a/Sources/CodexBar/CursorLoginRunner.swift +++ b/Sources/CodexBar/CursorLoginRunner.swift @@ -72,7 +72,7 @@ final class CursorLoginRunner: NSObject { backing: .buffered, defer: false) window.isReleasedWhenClosed = false - window.title = "Cursor Login" + window.title = L10n.tr("login.cursor.windowTitle", default: "Cursor Login") window.contentView = webView window.center() window.delegate = self @@ -110,9 +110,10 @@ final class CursorLoginRunner: NSObject { } guard !cursorCookies.isEmpty else { - self.phaseCallback?(.failed("No session cookies found")) + let message = L10n.tr("login.cursor.noSessionCookies", default: "No session cookies found") + self.phaseCallback?(.failed(message)) self.logger.warning("Cursor login failed: no session cookies found") - self.complete(with: Result(outcome: .failed("No session cookies found"), email: nil)) + self.complete(with: Result(outcome: .failed(message), email: nil)) return } diff --git a/Sources/CodexBar/Date+RelativeDescription.swift b/Sources/CodexBar/Date+RelativeDescription.swift index 7356f9671..311d86a8e 100644 --- a/Sources/CodexBar/Date+RelativeDescription.swift +++ b/Sources/CodexBar/Date+RelativeDescription.swift @@ -1,3 +1,4 @@ +import CodexBarCore import Foundation enum RelativeTimeFormatters { @@ -14,7 +15,7 @@ extension Date { func relativeDescription(now: Date = .now) -> String { let seconds = abs(now.timeIntervalSince(self)) if seconds < 15 { - return "just now" + return L10n.tr("relative.justNow", default: "just now") } return RelativeTimeFormatters.full.localizedString(for: self, relativeTo: now) } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1a5d25389..a70a12657 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -11,15 +11,15 @@ struct UsageMenuCardView: View { var labelSuffix: String { switch self { - case .left: "left" - case .used: "used" + case .left: L10n.tr("usage.percent.left", default: "left") + case .used: L10n.tr("usage.percent.used", default: "used") } } var accessibilityLabel: String { switch self { - case .left: "Usage remaining" - case .used: "Usage used" + case .left: L10n.tr("usage.accessibility.remaining", default: "Usage remaining") + case .used: L10n.tr("usage.accessibility.used", default: "Usage used") } } } @@ -85,7 +85,7 @@ struct UsageMenuCardView: View { static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { - return "API key limit" + return L10n.tr("menu.metric.apiKeyLimit", default: "API key limit") } return metric.title } @@ -150,7 +150,7 @@ struct UsageMenuCardView: View { } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(L10n.tr("menu.card.cost", default: "Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -283,7 +283,10 @@ private struct CopyIconButton: View { .frame(width: 18, height: 18) } .buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted)) - .accessibilityLabel(self.didCopy ? "Copied" : "Copy error") + .accessibilityLabel( + self.didCopy + ? L10n.tr("common.copied", default: "Copied") + : L10n.tr("common.copyError", default: "Copy error")) } private func copyToPasteboard() { @@ -306,12 +309,15 @@ private struct ProviderCostContent: View { UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, - accessibilityLabel: "Extra usage spent") + accessibilityLabel: L10n.tr("menu.card.extraUsageSpent", default: "Extra usage spent")) HStack(alignment: .firstTextBaseline) { Text(self.section.spendLine) .font(.footnote) Spacer() - Text(String(format: "%.0f%% used", min(100, max(0, self.section.percentUsed)))) + Text(L10n.tr( + "menu.card.percentUsed", + default: "%.0f%% used", + min(100, max(0, self.section.percentUsed)))) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } @@ -502,19 +508,19 @@ private struct CreditsBarContent: View { private var scaleText: String { let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) - return "\(scale) tokens" + return L10n.tr("menu.card.scaleTokens", default: "%@ tokens", scale) } var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("Credits") + Text(L10n.tr("menu.card.credits", default: "Credits")) .font(.body) .fontWeight(.medium) if let percentLeft { UsageProgressBar( percent: percentLeft, tint: self.progressColor, - accessibilityLabel: "Credits remaining") + accessibilityLabel: L10n.tr("menu.card.creditsRemaining", default: "Credits remaining")) HStack(alignment: .firstTextBaseline) { Text(self.creditsText) .font(.caption) @@ -555,7 +561,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", default: "Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -715,7 +721,9 @@ extension UsageMenuCardView.Model { isRefreshing: input.isRefreshing, lastError: input.lastError) let redacted = Self.redactedText(input: input, subtitle: subtitle) - let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil + let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil + ? L10n.tr("menu.card.noUsageYet", default: "No usage yet") + : nil return UsageMenuCardView.Model( provider: input.provider, @@ -744,9 +752,11 @@ extension UsageMenuCardView.Model { .lowercased() if input.kiloAutoMode, resolvedSource == "cli", - !notes.contains(where: { $0.caseInsensitiveCompare("Using CLI fallback") == .orderedSame }) + !notes.contains(where: { + $0.caseInsensitiveCompare(L10n.tr("menu.card.cliFallback", default: "Using CLI fallback")) == .orderedSame + }) { - notes.append("Using CLI fallback") + notes.append(L10n.tr("menu.card.cliFallback", default: "Using CLI fallback")) } return notes } @@ -759,8 +769,10 @@ extension UsageMenuCardView.Model { return switch openRouter.keyQuotaStatus { case .available: [] - case .noLimitConfigured: ["No limit set for the API key"] - case .unavailable: ["API key limit unavailable right now"] + case .noLimitConfigured: + [L10n.tr("menu.card.noApiKeyLimit", default: "No limit set for the API key")] + case .unavailable: + [L10n.tr("menu.card.apiKeyLimitUnavailable", default: "API key limit unavailable right now")] } } @@ -848,14 +860,14 @@ extension UsageMenuCardView.Model { } if isRefreshing, snapshot == nil { - return ("Refreshing...", .loading) + return (L10n.tr("menu.card.refreshing", default: "Refreshing..."), .loading) } if let updated = snapshot?.updatedAt { return (UsageFormatter.updatedString(from: updated), .info) } - return ("Not fetched yet", .info) + return (L10n.tr("menu.card.notFetched", default: "Not fetched yet"), .info) } private struct RedactedText { @@ -929,7 +941,7 @@ extension UsageMenuCardView.Model { } metrics.append(Metric( id: "primary", - title: input.metadata.sessionLabel, + title: LocalizedProviderText.label(input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -972,7 +984,7 @@ extension UsageMenuCardView.Model { } metrics.append(Metric( id: "secondary", - title: input.metadata.weeklyLabel, + title: LocalizedProviderText.label(input.metadata.weeklyLabel), percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, @@ -1004,7 +1016,7 @@ extension UsageMenuCardView.Model { } metrics.append(Metric( id: "tertiary", - title: input.metadata.opusLabel ?? "Sonnet", + title: LocalizedProviderText.label(input.metadata.opusLabel ?? "Sonnet"), percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now), @@ -1019,7 +1031,7 @@ extension UsageMenuCardView.Model { let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining metrics.append(Metric( id: "code-review", - title: "Code review", + title: L10n.tr("menu.card.codeReview", default: "Code review"), percent: Self.clamped(percent), percentStyle: percentStyle, resetText: nil, @@ -1042,7 +1054,12 @@ extension UsageMenuCardView.Model { let currentStr = UsageFormatter.tokenCountString(currentValue) let usageStr = UsageFormatter.tokenCountString(usage) let remainingStr = UsageFormatter.tokenCountString(remaining) - return "\(currentStr) / \(usageStr) (\(remainingStr) remaining)" + return L10n.tr( + "menu.card.limit.detail", + default: "%@ / %@ (%@ remaining)", + currentStr, + usageStr, + remainingStr) } return nil @@ -1060,7 +1077,7 @@ extension UsageMenuCardView.Model { let remaining = UsageFormatter.usdString(keyRemaining) let limit = UsageFormatter.usdString(keyLimit) - return "\(remaining)/\(limit) left" + return L10n.tr("menu.card.limit.left", default: "%@/%@ left", remaining, limit) } private struct PaceDetail { @@ -1104,7 +1121,7 @@ extension UsageMenuCardView.Model { if let error, !error.isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) } - return metadata.creditsHint + return LocalizedProviderText.message(metadata.creditsHint) } private static func dashboardHint(provider: UsageProvider, error: String?) -> String? { @@ -1127,9 +1144,13 @@ extension UsageMenuCardView.Model { let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLine: String = { if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" + return L10n.tr( + "menu.tokenUsage.todayWithTokens", + default: "Today: %@ · %@ tokens", + sessionCost, + sessionTokens) } - return "Today: \(sessionCost)" + return L10n.tr("menu.tokenUsage.today", default: "Today: %@", sessionCost) }() let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" @@ -1138,9 +1159,13 @@ extension UsageMenuCardView.Model { let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } let monthLine: String = { if let monthTokens { - return "Last 30 days: \(monthCost) · \(monthTokens) tokens" + return L10n.tr( + "menu.tokenUsage.last30DaysWithTokens", + default: "Last 30 days: %@ · %@ tokens", + monthCost, + monthTokens) } - return "Last 30 days: \(monthCost)" + return L10n.tr("menu.tokenUsage.last30Days", default: "Last 30 days: %@", monthCost) }() let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( @@ -1163,17 +1188,17 @@ extension UsageMenuCardView.Model { let title: String if cost.currencyCode == "Quota" { - title = "Quota usage" + title = L10n.tr("providerCost.quotaUsage", default: "Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { - title = "Extra usage" + title = L10n.tr("providerCost.extraUsage", default: "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("providerCost.thisMonth", default: "This month") return ProviderCostSection( title: title, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 9cd0cb059..529871850 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -131,7 +131,7 @@ struct MenuDescriptor { } Self.appendRateWindow( entries: &entries, - title: meta.sessionLabel, + title: LocalizedProviderText.label(meta.sessionLabel), window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -154,7 +154,7 @@ struct MenuDescriptor { }() Self.appendRateWindow( entries: &entries, - title: meta.weeklyLabel, + title: LocalizedProviderText.label(meta.weeklyLabel), window: weekly, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, @@ -174,7 +174,7 @@ struct MenuDescriptor { if meta.supportsOpus, let opus = snap.tertiary { Self.appendRateWindow( entries: &entries, - title: meta.opusLabel ?? L10n.tr("provider.label.sonnet", default: "Sonnet"), + title: LocalizedProviderText.label(meta.opusLabel ?? L10n.tr("provider.label.sonnet", default: "Sonnet")), window: opus, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) diff --git a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift index 99ec8eef6..ff6614923 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("credits.window.title", default: "Buy Credits") window.isReleasedWhenClosed = false window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] window.contentView = container diff --git a/Sources/CodexBar/PersonalInfoRedactor.swift b/Sources/CodexBar/PersonalInfoRedactor.swift index 306e981fe..6d8fe36bd 100644 --- a/Sources/CodexBar/PersonalInfoRedactor.swift +++ b/Sources/CodexBar/PersonalInfoRedactor.swift @@ -1,7 +1,10 @@ +import CodexBarCore import Foundation enum PersonalInfoRedactor { - static let emailPlaceholder = "Hidden" + static var emailPlaceholder: String { + L10n.tr("common.hidden", default: "Hidden") + } private static let emailRegex: NSRegularExpression? = { let pattern = #"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"# diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index c8e135fb4..3841e6f24 100644 --- a/Sources/CodexBar/PreferencesAboutPane.swift +++ b/Sources/CodexBar/PreferencesAboutPane.swift @@ -69,9 +69,15 @@ struct AboutPane: View { icon: "chevron.left.slash.chevron.right", title: "GitHub", url: "https://github.com/steipete/CodexBar") - AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me") + AboutLinkRow( + icon: "globe", + title: L10n.tr("common.website", default: "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: "envelope", + title: L10n.tr("common.email", default: "Email"), + url: "mailto:peter@steipete.me") } .padding(.top, 8) .frame(maxWidth: .infinity) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 58a55deb5..4eedf075b 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -28,7 +28,7 @@ struct ProviderDetailView: View { return nil } guard provider == .openrouter else { - return (label: "Plan", value: rawPlan) + return (label: L10n.tr("preferences.providerDetail.label.plan", default: "Plan"), value: rawPlan) } let prefix = "Balance:" @@ -36,10 +36,10 @@ struct ProviderDetailView: View { let valueStart = rawPlan.index(rawPlan.startIndex, offsetBy: prefix.count) let trimmedValue = rawPlan[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedValue.isEmpty { - return (label: "Balance", value: trimmedValue) + return (label: L10n.tr("preferences.providerDetail.label.balance", default: "Balance"), value: trimmedValue) } } - return (label: "Balance", value: rawPlan) + return (label: L10n.tr("preferences.providerDetail.label.balance", default: "Balance"), value: rawPlan) } var body: some View { @@ -63,14 +63,17 @@ struct ProviderDetailView: View { if let errorDisplay { ProviderErrorView( - title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:", + title: L10n.tr( + "preferences.providerDetail.fetchFailed", + default: "Last %@ fetch failed:", + 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("preferences.providerDetail.section.settings", default: "Settings")) { ForEach(self.settingsPickers) { picker in ProviderSettingsPickerRowView(picker: picker) } @@ -86,7 +89,7 @@ struct ProviderDetailView: View { } if !self.settingsToggles.isEmpty { - ProviderSettingsSection(title: "Options") { + ProviderSettingsSection(title: L10n.tr("preferences.providerDetail.section.options", default: "Options")) { ForEach(self.settingsToggles) { toggle in ProviderSettingsToggleRowView(toggle: toggle) } @@ -107,12 +110,17 @@ struct ProviderDetailView: View { } private var detailLabelWidth: CGFloat { - var infoLabels = ["State", "Source", "Version", "Updated"] + var infoLabels = [ + L10n.tr("preferences.providerDetail.label.state", default: "State"), + L10n.tr("preferences.providerDetail.label.source", default: "Source"), + L10n.tr("preferences.providerDetail.label.version", default: "Version"), + L10n.tr("preferences.providerDetail.label.updated", default: "Updated"), + ] if self.store.status(for: self.provider) != nil { - infoLabels.append("Status") + infoLabels.append(L10n.tr("preferences.providerDetail.label.status", default: "Status")) } if !self.model.email.isEmpty { - infoLabels.append("Account") + infoLabels.append(L10n.tr("preferences.providerDetail.label.account", default: "Account")) } if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { infoLabels.append(planRow.label) @@ -122,13 +130,13 @@ struct ProviderDetailView: View { Self.metricTitle(provider: self.provider, metric: metric) } if self.model.creditsText != nil { - metricLabels.append("Credits") + metricLabels.append(L10n.tr("menu.card.credits", default: "Credits")) } if let providerCost = self.model.providerCost { metricLabels.append(providerCost.title) } if self.model.tokenUsage != nil { - metricLabels.append("Cost") + metricLabels.append(L10n.tr("menu.card.cost", default: "Cost")) } let infoWidth = ProviderSettingsMetrics.labelWidth( @@ -174,7 +182,7 @@ private struct ProviderDetailHeaderView: View { } .buttonStyle(.bordered) .controlSize(.small) - .help("Refresh") + .help(L10n.tr("common.refresh", default: "Refresh")) Toggle("", isOn: self.$isEnabled) .labelsHidden() @@ -233,27 +241,44 @@ 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 source = LocalizedProviderText.source(self.store.sourceLabel(for: self.provider)) + let version = self.store.version(for: self.provider) ?? L10n.tr("common.notDetected", default: "not detected") let updated = self.updatedText let email = self.model.email - let enabledText = self.isEnabled ? "Enabled" : "Disabled" + let enabledText = self.isEnabled + ? L10n.tr("common.enabled", default: "Enabled") + : L10n.tr("common.disabled", default: "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("preferences.providerDetail.label.state", default: "State"), + value: enabledText, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("preferences.providerDetail.label.source", default: "Source"), + value: source, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("preferences.providerDetail.label.version", default: "Version"), + value: version, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("preferences.providerDetail.label.updated", default: "Updated"), + value: updated, + labelWidth: self.labelWidth) if let status { ProviderDetailInfoRow( - label: "Status", + label: L10n.tr("preferences.providerDetail.label.status", default: "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("preferences.providerDetail.label.account", default: "Account"), + value: email, + labelWidth: self.labelWidth) } if let planRow = ProviderDetailView.planRow(provider: self.provider, planText: self.model.planText) { @@ -269,9 +294,9 @@ private struct ProviderDetailInfoGrid: View { return UsageFormatter.updatedString(from: updated) } if self.store.refreshingProviders.contains(self.provider) { - return "Refreshing" + return L10n.tr("preferences.providerDetail.updated.refreshing", default: "Refreshing") } - return "Not fetched yet" + return L10n.tr("preferences.providerDetail.updated.notFetched", default: "Not fetched yet") } } @@ -304,7 +329,7 @@ struct ProviderMetricsInlineView: View { let hasProviderCost = self.model.providerCost != nil let hasTokenUsage = self.model.tokenUsage != nil ProviderSettingsSection( - title: "Usage", + title: L10n.tr("preferences.providerDetail.section.usage", default: "Usage"), spacing: 8, verticalPadding: 6, horizontalPadding: 0) @@ -331,7 +356,7 @@ struct ProviderMetricsInlineView: View { if let credits = self.model.creditsText { ProviderMetricInlineTextRow( - title: "Credits", + title: L10n.tr("menu.card.credits", default: "Credits"), value: credits, labelWidth: self.labelWidth) } @@ -345,7 +370,7 @@ struct ProviderMetricsInlineView: View { if let tokenUsage = self.model.tokenUsage { ProviderMetricInlineTextRow( - title: "Cost", + title: L10n.tr("menu.card.cost", default: "Cost"), value: tokenUsage.sessionLine, labelWidth: self.labelWidth) ProviderMetricInlineTextRow( @@ -359,9 +384,9 @@ struct ProviderMetricsInlineView: View { private var placeholderText: String { if !self.isEnabled { - return "Disabled — no recent data" + return L10n.tr("preferences.providerDetail.placeholder.disabledNoRecentData", default: "Disabled — no recent data") } - return self.model.placeholder ?? "No usage yet" + return self.model.placeholder ?? L10n.tr("menu.noUsageYet", default: "No usage yet") } } diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift index 0fa246d88..f2a1fd8a1 100644 --- a/Sources/CodexBar/PreferencesProviderErrorView.swift +++ b/Sources/CodexBar/PreferencesProviderErrorView.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI struct ProviderErrorDisplay { @@ -26,7 +27,7 @@ struct ProviderErrorView: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help("Copy error") + .help(L10n.tr("providerError.copy", default: "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("providerError.hideDetails", default: "Hide details") + : L10n.tr("providerError.showDetails", default: "Show details")) + { + self.isExpanded.toggle() + } .buttonStyle(.link) .font(.footnote) } diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 414f41c55..7221c4ee8 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI struct ProviderSettingsSection: View { @@ -220,7 +221,7 @@ struct ProviderSettingsTokenAccountsRowView: View { let accounts = self.descriptor.accounts() if accounts.isEmpty { - Text("No token accounts yet.") + Text(L10n.tr("provider.tokenAccounts.empty", default: "No token accounts yet.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -237,7 +238,7 @@ struct ProviderSettingsTokenAccountsRowView: View { .pickerStyle(.menu) .controlSize(.small) - Button("Remove selected account") { + Button(L10n.tr("provider.tokenAccounts.removeSelected", default: "Remove selected account")) { let account = accounts[selectedIndex] self.descriptor.removeAccount(account.id) } @@ -246,13 +247,13 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) + TextField(L10n.tr("provider.tokenAccounts.label", default: "Label"), text: self.$newLabel) .textFieldStyle(.roundedBorder) .font(.footnote) SecureField(self.descriptor.placeholder, text: self.$newToken) .textFieldStyle(.roundedBorder) .font(.footnote) - Button("Add") { + Button(L10n.tr("common.add", default: "Add")) { let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty, !token.isEmpty else { return } @@ -267,12 +268,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 10) { - Button("Open token file") { + Button(L10n.tr("provider.tokenAccounts.openFile", default: "Open token file")) { self.descriptor.openConfigFile() } .buttonStyle(.link) .controlSize(.small) - Button("Reload") { + Button(L10n.tr("common.reload", default: "Reload")) { self.descriptor.reloadFromDisk() } .buttonStyle(.link) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index ee34cb3e7..5c2cdc0ce 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("preferences.providers.sidebar.dragToReorder", default: "Drag to reorder")) .onDrag { self.draggingProvider = self.provider return NSItemProvider(object: self.provider.rawValue as NSString) @@ -109,9 +109,10 @@ private struct ProviderSidebarRowView: View { if lines.count >= 2 { let first = lines[0] let rest = lines.dropFirst().joined(separator: "\n") - return "Disabled — \(first)\n\(rest)" + let disabled = L10n.tr("preferences.providers.sidebar.disabled", default: "Disabled — %@", String(first)) + return "\(disabled)\n\(rest)" } - return "Disabled — \(self.subtitle)" + return L10n.tr("preferences.providers.sidebar.disabled", default: "Disabled — %@", self.subtitle) } } @@ -135,7 +136,7 @@ private struct ProviderSidebarReorderHandle: View { width: ProviderSettingsMetrics.reorderHandleSize, height: ProviderSettingsMetrics.reorderHandleSize) .foregroundStyle(.tertiary) - .accessibilityLabel("Reorder") + .accessibilityLabel(L10n.tr("preferences.providers.sidebar.reorder", default: "Reorder")) } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7a040dafd..3fff2cfaa 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -50,7 +50,7 @@ struct ProvidersPane: View { } }) } else { - Text("Select a provider") + Text(L10n.tr("preferences.providers.selectProvider", default: "Select a provider")) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } @@ -78,7 +78,7 @@ struct ProvidersPane: View { active.onConfirm() self.activeConfirmation = nil } - Button("Cancel", role: .cancel) { self.activeConfirmation = nil } + Button(L10n.tr("common.cancel", default: "Cancel"), role: .cancel) { self.activeConfirmation = nil } } }, message: { @@ -115,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("preferences.providers.subtitle.lastFetchFailed", default: "last fetch failed") } else { - usageText = "usage not fetched yet" + usageText = L10n.tr("preferences.providers.subtitle.notFetched", default: "usage not fetched yet") } let presentationContext = ProviderPresentationContext( @@ -267,34 +267,52 @@ struct ProvidersPane: View { let options: [ProviderSettingsPickerOption] if provider == .openrouter { options = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.automatic.rawValue, + title: L10n.tr("preferences.providers.menuBarMetric.automatic", default: "Automatic")), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (API key limit)"), + title: L10n.tr( + "preferences.providers.menuBarMetric.primaryApiKeyLimit", + default: "Primary (API key limit)")), ] } else { let metadata = self.store.metadata(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) var metricOptions: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.automatic.rawValue, + title: L10n.tr("preferences.providers.menuBarMetric.automatic", default: "Automatic")), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (\(metadata.sessionLabel))"), + title: L10n.tr( + "preferences.providers.menuBarMetric.primary", + default: "Primary (%@)", + LocalizedProviderText.label(metadata.sessionLabel))), ProviderSettingsPickerOption( id: MenuBarMetricPreference.secondary.rawValue, - title: "Secondary (\(metadata.weeklyLabel))"), + title: L10n.tr( + "preferences.providers.menuBarMetric.secondary", + default: "Secondary (%@)", + LocalizedProviderText.label(metadata.weeklyLabel))), ] if supportsAverage { metricOptions.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, - title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) + title: L10n.tr( + "preferences.providers.menuBarMetric.average", + default: "Average (%@ + %@)", + LocalizedProviderText.label(metadata.sessionLabel), + LocalizedProviderText.label(metadata.weeklyLabel)))) } options = metricOptions } return ProviderSettingsPickerDescriptor( id: "menuBarMetric", - title: "Menu bar metric", - subtitle: "Choose which window drives the menu bar percent.", + title: L10n.tr("preferences.providers.menuBarMetric.title", default: "Menu bar metric"), + subtitle: L10n.tr( + "preferences.providers.menuBarMetric.subtitle", + default: "Choose which window drives the menu bar percent."), binding: Binding( get: { self.settings.menuBarMetricPreference(for: provider).rawValue }, set: { rawValue in diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift index eb198478e..231594c0d 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift @@ -53,16 +53,22 @@ struct AlibabaCodingPlanProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.alibabaCodingPlanCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies from Model Studio/Bailian.", - manual: "Paste a Cookie header from modelstudio.console.alibabacloud.com.", - off: "Alibaba cookies are disabled.") + auto: L10n.tr( + "provider.alibaba.cookieSource.auto", + default: "Automatic imports browser cookies from Model Studio/Bailian."), + manual: L10n.tr( + "provider.alibaba.cookieSource.manual", + default: "Paste a Cookie header from modelstudio.console.alibabacloud.com."), + off: L10n.tr("provider.alibaba.cookieSource.off", default: "Alibaba cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "alibaba-coding-plan-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies from Model Studio/Bailian.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr( + "provider.alibaba.cookieSource.subtitle", + default: "Automatic imports browser cookies from Model Studio/Bailian."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -71,12 +77,18 @@ struct AlibabaCodingPlanProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .alibaba) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return L10n.tr( + "common.cachedFormat", + default: "Cached: %@ • %@", + LocalizedProviderText.source(entry.sourceLabel), + when) }), ProviderSettingsPickerDescriptor( id: "alibaba-coding-plan-region", - title: "Gateway region", - subtitle: "Use international or China mainland console gateways for quota fetches.", + title: L10n.tr("provider.settings.gatewayRegion.title", default: "Gateway region"), + subtitle: L10n.tr( + "provider.alibaba.gatewayRegion.subtitle", + default: "Use international or China mainland console gateways for quota fetches."), binding: binding, options: options, isVisible: nil, @@ -89,15 +101,17 @@ struct AlibabaCodingPlanProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "alibaba-coding-plan-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio.", + title: L10n.tr("provider.settings.apiKey.title", default: "API key"), + subtitle: L10n.tr( + "provider.alibaba.apiKey.subtitle", + default: "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio."), kind: .secure, placeholder: "cpk-...", binding: context.stringBinding(\.alibabaCodingPlanAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "alibaba-coding-plan-open-dashboard", - title: "Open Coding Plan", + title: L10n.tr("provider.settings.openCodingPlan", default: "Open Coding Plan"), style: .link, isVisible: nil, perform: { @@ -108,15 +122,15 @@ struct AlibabaCodingPlanProviderImplementation: ProviderImplementation { onActivate: { context.settings.ensureAlibabaCodingPlanAPITokenLoaded() }), ProviderSettingsFieldDescriptor( id: "alibaba-coding-plan-cookie", - title: "Cookie header", + title: L10n.tr("provider.settings.cookieHeader.title", default: "Cookie header"), subtitle: "", kind: .secure, - placeholder: "Cookie: ...", + placeholder: L10n.tr("provider.placeholder.cookieHeader.ascii", default: "Cookie: ..."), binding: context.stringBinding(\.alibabaCodingPlanCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "alibaba-coding-plan-open-dashboard-cookie", - title: "Open Coding Plan", + title: L10n.tr("provider.settings.openCodingPlan", default: "Open Coding Plan"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift b/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift index 25ca6c932..42f9e202e 100644 --- a/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift @@ -34,16 +34,18 @@ struct AmpProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.ampCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a Cookie header or cURL capture from Amp settings.", - off: "Amp cookies are disabled.") + auto: L10n.tr("provider.cookieSource.auto.generic", default: "Automatic imports browser cookies."), + manual: L10n.tr( + "provider.amp.cookieSource.manual", + default: "Paste a Cookie header or cURL capture from Amp settings."), + off: L10n.tr("provider.amp.cookieSource.off", default: "Amp cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "amp-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr("provider.cookieSource.auto.generic", default: "Automatic imports browser cookies."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -60,12 +62,12 @@ struct AmpProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: L10n.tr("provider.placeholder.cookieHeader", default: "Cookie: …"), binding: context.stringBinding(\.ampCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "amp-open-settings", - title: "Open Amp Settings", + title: L10n.tr("provider.amp.openSettings", default: "Open Amp Settings"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift index e41aa8fe6..e63762151 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift @@ -5,7 +5,7 @@ extension StatusItemController { func runAntigravityLoginFlow() async { self.loginPhase = .idle self.presentLoginAlert( - title: "Antigravity login is managed in the app", - message: "Open Antigravity to sign in, then refresh CodexBar.") + title: L10n.tr("login.antigravity.managed.title", default: "Antigravity login is managed in the app"), + message: L10n.tr("login.antigravity.managed.message", default: "Open Antigravity to sign in, then refresh CodexBar.")) } } diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift index c1529bd58..4f07b9539 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift @@ -52,16 +52,18 @@ struct AugmentProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.augmentCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a Cookie header or cURL capture from the Augment dashboard.", - off: "Augment cookies are disabled.") + auto: L10n.tr("provider.cookieSource.auto.generic", default: "Automatic imports browser cookies."), + manual: L10n.tr( + "provider.augment.cookieSource.manual", + default: "Paste a Cookie header or cURL capture from the Augment dashboard."), + off: L10n.tr("provider.augment.cookieSource.off", default: "Augment cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "augment-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr("provider.cookieSource.auto.generic", default: "Automatic imports browser cookies."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -70,7 +72,11 @@ struct AugmentProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .augment) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return L10n.tr( + "common.cachedFormat", + default: "Cached: %@ • %@", + LocalizedProviderText.source(entry.sourceLabel), + when) }), ] } @@ -83,14 +89,18 @@ struct AugmentProviderImplementation: ProviderImplementation { @MainActor func appendActionMenuEntries(context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) { - entries.append(.action("Refresh Session", .refreshAugmentSession)) + entries.append(.action( + L10n.tr("menu.action.refreshSession", default: "Refresh Session"), + .refreshAugmentSession)) if let error = context.store.error(for: .augment) { if error.contains("session has expired") || error.contains("No Augment session cookie found") { entries.append(.action( - "Open Augment (Log Out & Back In)", + L10n.tr( + "provider.augment.openAndRelogin", + default: "Open Augment (Log Out & Back In)"), .loginToProvider(url: "https://app.augmentcode.com"))) } } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index de1bdffc7..ab7f26ff5 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -10,7 +10,8 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { context in - var versionText = context.store.version(for: context.provider) ?? "not detected" + var versionText = context.store.version(for: context.provider) + ?? L10n.tr("common.notDetected", default: "not detected") if let parenRange = versionText.range(of: "(") { versionText = versionText[.. [ProviderSettingsToggleDescriptor] { let subtitle = if context.settings.debugDisableKeychainAccess { - "Inactive while \"Disable Keychain access\" is enabled in Advanced." + L10n.tr( + "provider.claude.promptFree.inactive", + default: "Inactive while \"Disable Keychain access\" is enabled in Advanced.") } else { - "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." + L10n.tr( + "provider.claude.promptFree.subtitle", + default: "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts.") } let promptFreeBinding = Binding( @@ -80,7 +85,9 @@ struct ClaudeProviderImplementation: ProviderImplementation { return [ ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", - title: "Avoid Keychain prompts (experimental)", + title: L10n.tr( + "provider.claude.promptFree.title", + default: "Avoid Keychain prompts (experimental)"), subtitle: subtitle, binding: promptFreeBinding, statusText: nil, @@ -120,35 +127,46 @@ struct ClaudeProviderImplementation: ProviderImplementation { let keychainPromptPolicyOptions: [ProviderSettingsPickerOption] = [ ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.never.rawValue, - title: "Never prompt"), + title: L10n.tr("provider.claude.keychainPrompt.never", default: "Never prompt")), ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.onlyOnUserAction.rawValue, - title: "Only on user action"), + title: L10n.tr( + "provider.claude.keychainPrompt.onlyOnUserAction", + default: "Only on user action")), ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.always.rawValue, - title: "Always allow prompts"), + title: L10n.tr("provider.claude.keychainPrompt.always", default: "Always allow prompts")), ] let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.claudeCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies for the web API.", - manual: "Paste a Cookie header from a claude.ai request.", - off: "Claude cookies are disabled.") + auto: L10n.tr( + "provider.claude.cookieSource.auto", + default: "Automatic imports browser cookies for the web API."), + manual: L10n.tr( + "provider.claude.cookieSource.manual", + default: "Paste a Cookie header from a claude.ai request."), + off: L10n.tr("provider.claude.cookieSource.off", default: "Claude cookies are disabled.")) } let keychainPromptPolicySubtitle: () -> String? = { if context.settings.debugDisableKeychainAccess { - return "Global Keychain access is disabled in Advanced, so this setting is currently inactive." + return L10n.tr( + "provider.claude.keychainPrompt.disabled", + default: "Global Keychain access is disabled in Advanced, so this setting is currently inactive.") } - return "Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing " + - "\"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." + return L10n.tr( + "provider.claude.keychainPrompt.subtitle", + default: "Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed.") } return [ ProviderSettingsPickerDescriptor( id: "claude-usage-source", - title: "Usage source", - subtitle: "Auto falls back to the next source if the preferred one fails.", + title: L10n.tr("provider.settings.usageSource.title", default: "Usage source"), + subtitle: L10n.tr( + "provider.settings.usageSource.subtitle", + default: "Auto falls back to the next source if the preferred one fails."), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -156,12 +174,14 @@ struct ClaudeProviderImplementation: ProviderImplementation { trailingText: { guard context.settings.claudeUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .claude) - return label == "auto" ? nil : label + return label == "auto" ? nil : LocalizedProviderText.source(label) }), ProviderSettingsPickerDescriptor( id: "claude-keychain-prompt-policy", - title: "Keychain prompt policy", - subtitle: "Applies only to the Security.framework OAuth keychain reader.", + title: L10n.tr("provider.claude.keychainPrompt.title", default: "Keychain prompt policy"), + subtitle: L10n.tr( + "provider.claude.keychainPrompt.securityFrameworkOnly", + default: "Applies only to the Security.framework OAuth keychain reader."), dynamicSubtitle: keychainPromptPolicySubtitle, binding: keychainPromptPolicyBinding, options: keychainPromptPolicyOptions, @@ -170,8 +190,10 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil), ProviderSettingsPickerDescriptor( id: "claude-cookie-source", - title: "Claude cookies", - subtitle: "Automatic imports browser cookies for the web API.", + title: L10n.tr("provider.claude.cookieSource.title", default: "Claude cookies"), + subtitle: L10n.tr( + "provider.claude.cookieSource.subtitle", + default: "Automatic imports browser cookies for the web API."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -180,7 +202,11 @@ struct ClaudeProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .claude) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return L10n.tr( + "common.cachedFormat", + default: "Cached: %@ • %@", + LocalizedProviderText.source(entry.sourceLabel), + when) }), ] } @@ -200,7 +226,11 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { if context.snapshot?.secondary == nil { - entries.append(.text("Weekly usage unavailable for this account.", .secondary)) + entries.append(.text( + L10n.tr( + "provider.claude.weeklyUnavailable", + default: "Weekly usage unavailable for this account."), + .secondary)) } if let cost = context.snapshot?.providerCost, @@ -209,7 +239,9 @@ struct ClaudeProviderImplementation: ProviderImplementation { { let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - entries.append(.text("Extra usage: \(used) / \(limit)", .primary)) + entries.append(.text( + L10n.tr("provider.claude.extraUsage", default: "Extra usage: %@ / %@", used, limit), + .primary)) } } diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 61aa3a501..fd4b68e20 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("common.notDetected", default: "not detected") } } @@ -73,8 +74,10 @@ struct CodexProviderImplementation: ProviderImplementation { return [ ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", - title: "Historical tracking", - subtitle: "Stores local Codex usage history (8 weeks) to personalize Pace predictions.", + title: L10n.tr("provider.codex.historicalTracking.title", default: "Historical tracking"), + subtitle: L10n.tr( + "provider.codex.historicalTracking.subtitle", + default: "Stores local Codex usage history (8 weeks) to personalize Pace predictions."), binding: context.boolBinding(\.historicalTrackingEnabled), statusText: nil, actions: [], @@ -84,8 +87,10 @@ struct CodexProviderImplementation: ProviderImplementation { onAppearWhenEnabled: nil), 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.openAIWebExtras.title", default: "OpenAI web extras"), + subtitle: L10n.tr( + "provider.codex.openAIWebExtras.subtitle", + default: "Show usage breakdown, credits history, and code review via chatgpt.com."), binding: extrasBinding, statusText: nil, actions: [], @@ -120,16 +125,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.cookieSource.auto", + default: "Automatic imports browser cookies for dashboard extras."), + manual: L10n.tr( + "provider.codex.cookieSource.manual", + default: "Paste a Cookie header from a chatgpt.com request."), + off: L10n.tr( + "provider.codex.cookieSource.off", + default: "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.settings.usageSource.title", default: "Usage source"), + subtitle: L10n.tr( + "provider.settings.usageSource.subtitle", + default: "Auto falls back to the next source if the preferred one fails."), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -137,12 +150,14 @@ struct CodexProviderImplementation: ProviderImplementation { trailingText: { guard context.settings.codexUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .codex) - return label == "auto" ? nil : label + return label == "auto" ? nil : LocalizedProviderText.source(label) }), ProviderSettingsPickerDescriptor( id: "codex-cookie-source", - title: "OpenAI cookies", - subtitle: "Automatic imports browser cookies for dashboard extras.", + title: L10n.tr("provider.codex.cookieSource.title", default: "OpenAI cookies"), + subtitle: L10n.tr( + "provider.codex.cookieSource.subtitle", + default: "Automatic imports browser cookies for dashboard extras."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -151,7 +166,11 @@ struct CodexProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return L10n.tr( + "common.cachedFormat", + default: "Cached: %@ • %@", + LocalizedProviderText.source(entry.sourceLabel), + when) }), ] } @@ -164,7 +183,7 @@ struct CodexProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: L10n.tr("provider.placeholder.cookieHeader", default: "Cookie: …"), binding: context.stringBinding(\.codexCookieHeader), actions: [], isVisible: { @@ -181,12 +200,22 @@ struct CodexProviderImplementation: ProviderImplementation { else { return } if let credits = context.store.credits { - entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary)) + entries.append(.text( + L10n.tr( + "menu.creditsLine", + default: "Credits: %@", + UsageFormatter.creditsString(from: credits.remaining)), + .primary)) if let latest = credits.events.first { - entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary)) + entries.append(.text( + L10n.tr( + "menu.lastSpend", + default: "Last spend: %@", + UsageFormatter.creditEventSummary(latest)), + .secondary)) } } else { - let hint = context.store.lastCreditsError ?? context.metadata.creditsHint + let hint = context.store.lastCreditsError ?? LocalizedProviderText.message(context.metadata.creditsHint) entries.append(.text(hint, .secondary)) } } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 55275ae61..881956e89 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -16,14 +16,14 @@ struct CopilotLoginFlow { pb.setString(code.userCode, forType: .string) let alert = NSAlert() - alert.messageText = "GitHub Copilot Login" - alert.informativeText = """ - A device code has been copied to your clipboard: \(code.userCode) - - Please verify it at: \(code.verificationUri) - """ - alert.addButton(withTitle: "Open Browser") - alert.addButton(withTitle: "Cancel") + alert.messageText = L10n.tr("login.copilot.title", default: "GitHub Copilot Login") + alert.informativeText = L10n.tr( + "login.copilot.deviceCode.message", + default: "A device code has been copied to your clipboard: %@\n\nPlease verify it at: %@", + code.userCode, + code.verificationUri) + alert.addButton(withTitle: L10n.tr("login.copilot.openBrowser", default: "Open Browser")) + alert.addButton(withTitle: L10n.tr("common.cancel", default: "Cancel")) let response = alert.runModal() if response == .alertSecondButtonReturn { @@ -43,12 +43,11 @@ struct CopilotLoginFlow { // Let's show a "Waiting" alert that can be cancelled. let waitingAlert = NSAlert() - waitingAlert.messageText = "Waiting for Authentication..." - waitingAlert.informativeText = """ - Please complete the login in your browser. - This window will close automatically when finished. - """ - waitingAlert.addButton(withTitle: "Cancel") + waitingAlert.messageText = L10n.tr("login.copilot.waiting.title", default: "Waiting for Authentication...") + waitingAlert.informativeText = L10n.tr( + "login.copilot.waiting.message", + default: "Please complete the login in your browser.\nThis window will close automatically when finished.") + waitingAlert.addButton(withTitle: L10n.tr("common.cancel", default: "Cancel")) let parentWindow = Self.resolveWaitingParentWindow() let hostWindow = parentWindow ?? Self.makeWaitingHostWindow() let shouldCloseHostWindow = parentWindow == nil @@ -87,19 +86,19 @@ struct CopilotLoginFlow { enabled: true) let success = NSAlert() - success.messageText = "Login Successful" + success.messageText = L10n.tr("login.copilot.success.title", default: "Login Successful") success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } let err = NSAlert() - err.messageText = "Login Failed" + err.messageText = L10n.tr("login.copilot.failed.title", default: "Login Failed") err.informativeText = error.localizedDescription err.runModal() } } catch { let err = NSAlert() - err.messageText = "Login Failed" + err.messageText = L10n.tr("login.copilot.failed.title", default: "Login Failed") err.informativeText = error.localizedDescription err.runModal() } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 986d81f2f..65c44c733 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -10,7 +10,7 @@ struct CopilotProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "github api" } + ProviderPresentation { _ in "GitHub API" } } @MainActor @@ -29,15 +29,17 @@ struct CopilotProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "copilot-api-token", - title: "GitHub Login", - subtitle: "Requires authentication via GitHub Device Flow.", + title: L10n.tr("provider.copilot.login.title", default: "GitHub Login"), + subtitle: L10n.tr( + "provider.copilot.login.subtitle", + default: "Requires authentication via GitHub Device Flow."), kind: .secure, - placeholder: "Sign in via button below", + placeholder: L10n.tr("provider.copilot.login.placeholder", default: "Sign in via button below"), binding: context.stringBinding(\.copilotAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "copilot-login", - title: "Sign in with GitHub", + title: L10n.tr("provider.copilot.login.signIn", default: "Sign in with GitHub"), style: .bordered, isVisible: { context.settings.copilotAPIToken.isEmpty }, perform: { @@ -45,7 +47,7 @@ struct CopilotProviderImplementation: ProviderImplementation { }), ProviderSettingsActionDescriptor( id: "copilot-relogin", - title: "Sign in again", + title: L10n.tr("provider.copilot.login.signInAgain", default: "Sign in again"), style: .link, isVisible: { !context.settings.copilotAPIToken.isEmpty }, perform: { diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index 48db614f9..25e936d81 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift @@ -10,7 +10,7 @@ struct CursorProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } + ProviderPresentation { _ in LocalizedProviderText.source("web") } } @MainActor @@ -53,16 +53,22 @@ struct CursorProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.cursorCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies or stored sessions.", - manual: "Paste a Cookie header from a cursor.com request.", - off: "Cursor cookies are disabled.") + auto: L10n.tr( + "provider.cursor.cookieSource.auto", + default: "Automatic imports browser cookies or stored sessions."), + manual: L10n.tr( + "provider.cursor.cookieSource.manual", + default: "Paste a Cookie header from a cursor.com request."), + off: L10n.tr("provider.cursor.cookieSource.off", default: "Cursor cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "cursor-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies or stored sessions.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr( + "provider.cursor.cookieSource.subtitle", + default: "Automatic imports browser cookies or stored sessions."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -71,7 +77,11 @@ struct CursorProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .cursor) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return L10n.tr( + "common.cachedFormat", + default: "Cached: %@ • %@", + LocalizedProviderText.source(entry.sourceLabel), + when) }), ] } @@ -94,9 +104,13 @@ struct CursorProviderImplementation: ProviderImplementation { let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) if cost.limit > 0 { let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - entries.append(.text("On-Demand: \(used) / \(limitStr)", .primary)) + entries.append(.text( + L10n.tr("provider.cursor.onDemandWithLimit", default: "On-Demand: %@ / %@", used, limitStr), + .primary)) } else { - entries.append(.text("On-Demand: \(used)", .primary)) + entries.append(.text( + L10n.tr("provider.cursor.onDemand", default: "On-Demand: %@", used), + .primary)) } } } diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index d8d2d2024..5ce205997 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -48,16 +48,22 @@ struct FactoryProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.factoryCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies and WorkOS tokens.", - manual: "Paste a Cookie header from app.factory.ai.", - off: "Factory cookies are disabled.") + auto: L10n.tr( + "provider.factory.cookieSource.auto", + default: "Automatic imports browser cookies and WorkOS tokens."), + manual: L10n.tr( + "provider.factory.cookieSource.manual", + default: "Paste a Cookie header from app.factory.ai."), + off: L10n.tr("provider.factory.cookieSource.off", default: "Factory cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "factory-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies and WorkOS tokens.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr( + "provider.factory.cookieSource.subtitle", + default: "Automatic imports browser cookies and WorkOS tokens."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -66,7 +72,11 @@ struct FactoryProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .factory) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return L10n.tr( + "common.cachedFormat", + default: "Cached: %@ • %@", + LocalizedProviderText.source(entry.sourceLabel), + when) }), ] } diff --git a/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift b/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift index bfb92438e..e1cf0fe5f 100644 --- a/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift +++ b/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift @@ -7,20 +7,30 @@ extension StatusItemController { let detectedIDEs = JetBrainsIDEDetector.detectInstalledIDEs(includeMissingQuota: true) if detectedIDEs.isEmpty { let message = [ - "Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar.", - "Alternatively, set a custom path in Settings.", + L10n.tr( + "provider.jetbrains.login.installIDE", + default: "Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar."), + L10n.tr( + "provider.jetbrains.login.customPath", + default: "Alternatively, set a custom path in Settings."), ].joined(separator: " ") self.presentLoginAlert( - title: "No JetBrains IDE detected", + title: L10n.tr("provider.jetbrains.login.noneDetectedTitle", default: "No JetBrains IDE detected"), message: message) } else { let ideNames = detectedIDEs.prefix(3).map(\.displayName).joined(separator: ", ") let hasQuotaFile = !JetBrainsIDEDetector.detectInstalledIDEs().isEmpty let message = hasQuotaFile - ? "Detected: \(ideNames). Select your preferred IDE in Settings, then refresh CodexBar." - : "Detected: \(ideNames). Use AI Assistant once to generate quota data, then refresh CodexBar." + ? L10n.tr( + "provider.jetbrains.login.detectedWithQuota", + default: "Detected: %@. Select your preferred IDE in Settings, then refresh CodexBar.", + ideNames) + : L10n.tr( + "provider.jetbrains.login.detectedWithoutQuota", + default: "Detected: %@. Use AI Assistant once to generate quota data, then refresh CodexBar.", + ideNames) self.presentLoginAlert( - title: "JetBrains AI is ready", + title: L10n.tr("provider.jetbrains.login.readyTitle", default: "JetBrains AI is ready"), message: message) } } diff --git a/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift b/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift index 7e7096f50..095e244fa 100644 --- a/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift +++ b/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift @@ -19,7 +19,9 @@ struct JetBrainsProviderImplementation: ProviderImplementation { guard !detectedIDEs.isEmpty else { return [] } var options: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: "", title: "Auto-detect"), + ProviderSettingsPickerOption( + id: "", + title: L10n.tr("provider.jetbrains.autoDetect", default: "Auto-detect")), ] for ide in detectedIDEs { options.append(ProviderSettingsPickerOption(id: ide.basePath, title: ide.displayName)) @@ -28,8 +30,8 @@ struct JetBrainsProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "jetbrains.ide", - title: "JetBrains IDE", - subtitle: "Select the IDE to monitor", + title: L10n.tr("provider.jetbrains.ide.title", default: "JetBrains IDE"), + subtitle: L10n.tr("provider.jetbrains.ide.subtitle", default: "Select the IDE to monitor"), binding: context.stringBinding(\.jetbrainsIDEBasePath), options: options, isVisible: nil, @@ -50,8 +52,10 @@ struct JetBrainsProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "jetbrains.customPath", - title: "Custom Path", - subtitle: "Override auto-detection with a custom IDE base path", + title: L10n.tr("provider.settings.customPath.title", default: "Custom Path"), + subtitle: L10n.tr( + "provider.jetbrains.customPath.subtitle", + default: "Override auto-detection with a custom IDE base path"), kind: .plain, placeholder: "~/Library/Application Support/JetBrains/IntelliJIdea2024.3", binding: context.stringBinding(\.jetbrainsIDEBasePath), diff --git a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift index e2bdb3cfa..fd2b30b54 100644 --- a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift @@ -54,8 +54,10 @@ struct KiloProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "kilo-usage-source", - title: "Usage source", - subtitle: "Auto uses API first, then falls back to CLI on auth failures.", + title: L10n.tr("provider.settings.usageSource.title", default: "Usage source"), + subtitle: L10n.tr( + "provider.kilo.usageSource.subtitle", + default: "Auto uses API first, then falls back to CLI on auth failures."), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -63,7 +65,7 @@ struct KiloProviderImplementation: ProviderImplementation { trailingText: { guard context.settings.kiloUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .kilo) - return label == "auto" ? nil : label + return label == "auto" ? nil : LocalizedProviderText.source(label) }), ] } @@ -73,9 +75,10 @@ struct KiloProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "kilo-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " - + "~/.local/share/kilo/auth.json (kilo.access).", + title: L10n.tr("provider.settings.apiKey.title", default: "API key"), + subtitle: L10n.tr( + "provider.kilo.apiKey.subtitle", + default: "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)."), kind: .secure, placeholder: "kilo_...", binding: context.stringBinding(\.kiloAPIToken), diff --git a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift index d48511963..845f312cb 100644 --- a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift @@ -10,7 +10,7 @@ struct KimiProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } + ProviderPresentation { _ in LocalizedProviderText.source("web") } } @MainActor @@ -39,16 +39,18 @@ struct KimiProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.kimiCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a cookie header or the kimi-auth token value.", - off: "Kimi cookies are disabled.") + auto: L10n.tr("provider.cookieSource.auto.generic", default: "Automatic imports browser cookies."), + manual: L10n.tr( + "provider.kimi.cookieSource.manual", + default: "Paste a cookie header or the kimi-auth token value."), + off: L10n.tr("provider.kimi.cookieSource.off", default: "Kimi cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "kimi-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr("provider.cookieSource.auto.generic", default: "Automatic imports browser cookies."), dynamicSubtitle: subtitle, binding: cookieBinding, options: options, @@ -65,12 +67,14 @@ struct KimiProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: \u{2026}\n\nor paste the kimi-auth token value", + placeholder: L10n.tr( + "provider.kimi.cookiePlaceholder", + default: "Cookie: …\n\nor paste the kimi-auth token value"), binding: context.stringBinding(\.kimiManualCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "kimi-open-console", - title: "Open Console", + title: L10n.tr("provider.kimi.openConsole", default: "Open Console"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift b/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift index 9209bba71..51b461c3e 100644 --- a/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift @@ -17,15 +17,17 @@ struct KimiK2ProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "kimi-k2-api-token", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai.", + title: L10n.tr("provider.settings.apiKey.title", default: "API key"), + subtitle: L10n.tr( + "provider.kimik2.apiKey.subtitle", + default: "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai."), kind: .secure, - placeholder: "Paste API key…", + placeholder: L10n.tr("provider.placeholder.apiKey", default: "Paste API key…"), binding: context.stringBinding(\.kimiK2APIToken), actions: [ ProviderSettingsActionDescriptor( id: "kimi-k2-open-api-keys", - title: "Open API Keys", + title: L10n.tr("provider.settings.openApiKeys", default: "Open API Keys"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift index 6b7432edc..6ca5c6f89 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift @@ -64,9 +64,13 @@ struct MiniMaxProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.minimaxCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies and local storage tokens.", - manual: "Paste a Cookie header or cURL capture from the Coding Plan page.", - off: "MiniMax cookies are disabled.") + auto: L10n.tr( + "provider.minimax.cookieSource.auto", + default: "Automatic imports browser cookies and local storage tokens."), + manual: L10n.tr( + "provider.minimax.cookieSource.manual", + default: "Paste a Cookie header or cURL capture from the Coding Plan page."), + off: L10n.tr("provider.minimax.cookieSource.off", default: "MiniMax cookies are disabled.")) } let regionBinding = Binding( @@ -81,8 +85,10 @@ struct MiniMaxProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "minimax-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies and local storage tokens.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr( + "provider.minimax.cookieSource.subtitle", + default: "Automatic imports browser cookies and local storage tokens."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -91,12 +97,18 @@ struct MiniMaxProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .minimax) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return L10n.tr( + "common.cachedFormat", + default: "Cached: %@ • %@", + LocalizedProviderText.source(entry.sourceLabel), + when) }), ProviderSettingsPickerDescriptor( id: "minimax-region", - title: "API region", - subtitle: "Choose the MiniMax host (global .io or China mainland .com).", + title: L10n.tr("provider.settings.apiRegion.title", default: "API region"), + subtitle: L10n.tr( + "provider.minimax.apiRegion.subtitle", + default: "Choose the MiniMax host (global .io or China mainland .com)."), binding: regionBinding, options: regionOptions, isVisible: nil, @@ -114,15 +126,17 @@ struct MiniMaxProviderImplementation: ProviderImplementation { return [ ProviderSettingsFieldDescriptor( id: "minimax-api-token", - title: "API token", - subtitle: "Stored in ~/.codexbar/config.json. Paste your MiniMax API key.", + title: L10n.tr("provider.settings.apiToken.title", default: "API token"), + subtitle: L10n.tr( + "provider.minimax.apiToken.subtitle", + default: "Stored in ~/.codexbar/config.json. Paste your MiniMax API key."), kind: .secure, - placeholder: "Paste API token…", + placeholder: L10n.tr("provider.placeholder.apiToken", default: "Paste API token…"), binding: context.stringBinding(\.minimaxAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard", - title: "Open Coding Plan", + title: L10n.tr("provider.settings.openCodingPlan", default: "Open Coding Plan"), style: .link, isVisible: nil, perform: { @@ -133,15 +147,15 @@ struct MiniMaxProviderImplementation: ProviderImplementation { onActivate: { context.settings.ensureMiniMaxAPITokenLoaded() }), ProviderSettingsFieldDescriptor( id: "minimax-cookie", - title: "Cookie header", + title: L10n.tr("provider.settings.cookieHeader.title", default: "Cookie header"), subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: L10n.tr("provider.placeholder.cookieHeader", default: "Cookie: …"), binding: context.stringBinding(\.minimaxCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard-cookie", - title: "Open Coding Plan", + title: L10n.tr("provider.settings.openCodingPlan", default: "Open Coding Plan"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift index 99d8582f1..5703546cc 100644 --- a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift @@ -48,16 +48,18 @@ struct OllamaProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.ollamaCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies.", - manual: "Paste a Cookie header or cURL capture from Ollama settings.", - off: "Ollama cookies are disabled.") + auto: L10n.tr("provider.cookieSource.auto.generic", default: "Automatic imports browser cookies."), + manual: L10n.tr( + "provider.ollama.cookieSource.manual", + default: "Paste a Cookie header or cURL capture from Ollama settings."), + off: L10n.tr("provider.ollama.cookieSource.off", default: "Ollama cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "ollama-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr("provider.cookieSource.auto.generic", default: "Automatic imports browser cookies."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -74,12 +76,12 @@ struct OllamaProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: L10n.tr("provider.placeholder.cookieHeader", default: "Cookie: …"), binding: context.stringBinding(\.ollamaCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "ollama-open-settings", - title: "Open Ollama Settings", + title: L10n.tr("provider.settings.openOllamaSettings", default: "Open Ollama Settings"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift index 5e069f0a1..0f94176f8 100644 --- a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift @@ -10,7 +10,7 @@ struct OpenCodeProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } + ProviderPresentation { _ in LocalizedProviderText.source("web") } } @MainActor @@ -54,16 +54,22 @@ struct OpenCodeProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.opencodeCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies from opencode.ai.", - manual: "Paste a Cookie header captured from the billing page.", - off: "OpenCode cookies are disabled.") + auto: L10n.tr( + "provider.opencode.cookieSource.auto", + default: "Automatic imports browser cookies from opencode.ai."), + manual: L10n.tr( + "provider.opencode.cookieSource.manual", + default: "Paste a Cookie header captured from the billing page."), + off: L10n.tr("provider.opencode.cookieSource.off", default: "OpenCode cookies are disabled.")) } return [ ProviderSettingsPickerDescriptor( id: "opencode-cookie-source", - title: "Cookie source", - subtitle: "Automatic imports browser cookies from opencode.ai.", + title: L10n.tr("provider.settings.cookieSource.title", default: "Cookie source"), + subtitle: L10n.tr( + "provider.opencode.cookieSource.subtitle", + default: "Automatic imports browser cookies from opencode.ai."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -72,7 +78,11 @@ struct OpenCodeProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .opencode) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return L10n.tr( + "common.cachedFormat", + default: "Cached: %@ • %@", + LocalizedProviderText.source(entry.sourceLabel), + when) }), ] } @@ -82,8 +92,10 @@ struct OpenCodeProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "opencode-workspace-id", - title: "Workspace ID", - subtitle: "Optional override if workspace lookup fails.", + title: L10n.tr("provider.opencode.workspaceID.title", default: "Workspace ID"), + subtitle: L10n.tr( + "provider.opencode.workspaceID.subtitle", + default: "Optional override if workspace lookup fails."), kind: .plain, placeholder: "wrk_…", binding: context.stringBinding(\.opencodeWorkspaceID), diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift index d584a2430..62726127a 100644 --- a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift @@ -10,7 +10,7 @@ struct OpenRouterProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { _ in LocalizedProviderText.source("api") } } @MainActor @@ -42,10 +42,10 @@ struct OpenRouterProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "openrouter-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. " - + "Get your key from openrouter.ai/settings/keys and set a key spending limit " - + "there to enable API key quota tracking.", + title: L10n.tr("provider.settings.apiKey.title", default: "API key"), + subtitle: L10n.tr( + "provider.openrouter.apiKey.subtitle", + default: "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking."), kind: .secure, placeholder: "sk-or-v1-...", binding: context.stringBinding(\.openRouterAPIToken), diff --git a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift index 4964f4df4..3e384836b 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift @@ -2,7 +2,9 @@ import CodexBarCore enum ProviderCookieSourceUI { static let keychainDisabledPrefix = - "Keychain access is disabled in Advanced, so browser cookie import is unavailable." + L10n.tr( + "provider.cookieSource.keychainDisabledPrefix", + default: "Keychain access is disabled in Advanced, so browser cookie import is unavailable.") static func options(allowsOff: Bool, keychainDisabled: Bool) -> [ProviderSettingsPickerOption] { var options: [ProviderSettingsPickerOption] = [] diff --git a/Sources/CodexBar/Providers/Shared/ProviderPresentation.swift b/Sources/CodexBar/Providers/Shared/ProviderPresentation.swift index 70fe3504f..908a5a676 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderPresentation.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderPresentation.swift @@ -6,7 +6,8 @@ struct ProviderPresentation { @MainActor static func standardDetailLine(context: ProviderPresentationContext) -> String { - let versionText = context.store.version(for: context.provider) ?? "not detected" + let versionText = context.store.version(for: context.provider) + ?? L10n.tr("common.notDetected", default: "not detected") return "\(context.metadata.cliName) \(versionText)" } } diff --git a/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift b/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift index dcef3eb67..fe33bdb9b 100644 --- a/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift @@ -9,7 +9,7 @@ struct SyntheticProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { _ in LocalizedProviderText.source("api") } } @MainActor @@ -31,10 +31,12 @@ struct SyntheticProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "synthetic-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard.", + title: L10n.tr("provider.settings.apiKey.title", default: "API key"), + subtitle: L10n.tr( + "provider.synthetic.apiKey.subtitle", + default: "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard."), kind: .secure, - placeholder: "Paste key…", + placeholder: L10n.tr("provider.placeholder.key", default: "Paste key…"), binding: context.stringBinding(\.syntheticAPIToken), actions: [], isVisible: nil, diff --git a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift index 1f8fe5418..4ac4a938f 100644 --- a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift +++ b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift @@ -7,20 +7,13 @@ extension StatusItemController { func runVertexAILoginFlow() async { // Show alert with instructions let alert = NSAlert() - alert.messageText = "Vertex AI Login" - alert.informativeText = """ - To use Vertex AI tracking, you need to authenticate with Google Cloud. - - 1. Open Terminal - 2. Run: gcloud auth application-default login - 3. Follow the browser prompts to sign in - 4. Set your project: gcloud config set project PROJECT_ID - - Would you like to open Terminal now? - """ + alert.messageText = L10n.tr("login.vertex.title", default: "Vertex AI Login") + alert.informativeText = L10n.tr( + "login.vertex.message", + default: "To use Vertex AI tracking, you need to authenticate with Google Cloud.\n\n1. Open Terminal\n2. Run: gcloud auth application-default login\n3. Follow the browser prompts to sign in\n4. Set your project: gcloud config set project PROJECT_ID\n\nWould you like to open Terminal now?") alert.alertStyle = .informational - alert.addButton(withTitle: "Open Terminal") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: L10n.tr("login.vertex.openTerminal", default: "Open Terminal")) + alert.addButton(withTitle: L10n.tr("common.cancel", default: "Cancel")) let response = alert.runModal() diff --git a/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift index e9cb82de9..77e338bc0 100644 --- a/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift @@ -17,16 +17,17 @@ struct WarpProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "warp-api-token", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, " - + "then create one.", + title: L10n.tr("provider.settings.apiKey.title", default: "API key"), + subtitle: L10n.tr( + "provider.warp.apiKey.subtitle", + default: "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one."), kind: .secure, placeholder: "wk-...", binding: context.stringBinding(\.warpAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "warp-open-api-keys", - title: "Open Warp API Key Guide", + title: L10n.tr("provider.warp.openGuide", default: "Open Warp API Key Guide"), style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift index d4bc64a9f..cb8f2f56a 100644 --- a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift @@ -10,7 +10,7 @@ struct ZaiProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { _ in LocalizedProviderText.source("api") } } @MainActor @@ -48,8 +48,10 @@ struct ZaiProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "zai-api-region", - title: "API region", - subtitle: "Use BigModel for the China mainland endpoints (open.bigmodel.cn).", + title: L10n.tr("provider.settings.apiRegion.title", default: "API region"), + subtitle: L10n.tr( + "provider.zai.apiRegion.subtitle", + default: "Use BigModel for the China mainland endpoints (open.bigmodel.cn)."), binding: binding, options: options, isVisible: nil, diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 962b5a61f..a8c50a252 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -49,9 +49,15 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { case .none: ("", "") case .depleted: - ("\(providerName) session depleted", "0% left. Will notify when it's available again.") + ( + L10n.tr("sessionQuota.depleted.title", default: "%@ session depleted", providerName), + L10n.tr("sessionQuota.depleted.body", default: "0% left. Will notify when it's available again.") + ) case .restored: - ("\(providerName) session restored", "Session quota is available again.") + ( + L10n.tr("sessionQuota.restored.title", default: "%@ session restored", providerName), + L10n.tr("sessionQuota.restored.body", default: "Session quota is available again.") + ) } let providerText = provider.rawValue diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1d7c6e35d..167e31566 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1174,7 +1174,10 @@ extension StatusItemController { } private func makeBuyCreditsItem() -> NSMenuItem { - let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") + let item = NSMenuItem( + title: L10n.tr("menu.buyCredits", default: "Buy Credits..."), + action: #selector(self.openCreditsPurchase), + keyEquivalent: "") item.target = self if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) { image.isTemplate = true @@ -1187,7 +1190,10 @@ 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.creditsHistory", default: "Credits history"), + action: nil, + keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1197,7 +1203,10 @@ 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.usageBreakdown", default: "Usage breakdown"), + action: nil, + keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1207,7 +1216,10 @@ extension StatusItemController { @discardableResult private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } - let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") + let item = NSMenuItem( + title: L10n.tr("menu.usageHistory30Days", default: "Usage history (30 days)"), + action: nil, + keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1234,12 +1246,18 @@ extension StatusItemController { let submenu = NSMenu() submenu.delegate = self - let titleItem = NSMenuItem(title: "MCP details", action: nil, keyEquivalent: "") + let titleItem = NSMenuItem( + title: L10n.tr("menu.zai.details", default: "MCP details"), + action: nil, + keyEquivalent: "") titleItem.isEnabled = false submenu.addItem(titleItem) if let window = timeLimit.windowLabel { - let item = NSMenuItem(title: "Window: \(window)", action: nil, keyEquivalent: "") + let item = NSMenuItem( + title: L10n.tr("menu.zai.window", default: "Window: %@", window), + action: nil, + keyEquivalent: "") item.isEnabled = false submenu.addItem(item) } @@ -1247,7 +1265,10 @@ extension StatusItemController { let reset = self.settings.resetTimeDisplayStyle == .absolute ? UsageFormatter.resetDescription(from: resetTime) : UsageFormatter.resetCountdownDescription(from: resetTime) - let item = NSMenuItem(title: "Resets: \(reset)", action: nil, keyEquivalent: "") + let item = NSMenuItem( + title: L10n.tr("menu.zai.resets", default: "Resets: %@", reset), + action: nil, + keyEquivalent: "") item.isEnabled = false submenu.addItem(item) } diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 92c334231..c208a4c55 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -68,7 +68,7 @@ final class ProviderSwitcherView: NSView { Segment( selection: .overview, image: overviewIcon, - title: "Overview"), + title: L10n.tr("status.switcher.overview", default: "Overview")), at: 0) } self.segments = segments diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index a83420ee9..b123dcfb1 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -474,8 +474,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let base: String switch self.loginPhase { case .idle: return nil - case .requesting: base = "Requesting login…" - case .waitingBrowser: base = "Waiting in browser…" + case .requesting: base = L10n.tr("status.login.requesting", default: "Requesting login…") + case .waitingBrowser: base = L10n.tr("status.login.waitingBrowser", default: "Waiting in browser…") } let prefix = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName return "\(prefix): \(base)" diff --git a/Sources/CodexBar/UpdateChannel.swift b/Sources/CodexBar/UpdateChannel.swift index aabf87bc6..db24389c2 100644 --- a/Sources/CodexBar/UpdateChannel.swift +++ b/Sources/CodexBar/UpdateChannel.swift @@ -1,3 +1,4 @@ +import CodexBarCore import Foundation enum UpdateChannel: String, CaseIterable, Codable { @@ -10,18 +11,18 @@ enum UpdateChannel: String, CaseIterable, Codable { var displayName: String { switch self { case .stable: - "Stable" + L10n.tr("updateChannel.stable.title", default: "Stable") case .beta: - "Beta" + L10n.tr("updateChannel.beta.title", default: "Beta") } } var description: String { switch self { case .stable: - "Receive only stable, production-ready releases." + L10n.tr("updateChannel.stable.description", default: "Receive only stable, production-ready releases.") case .beta: - "Receive stable releases plus beta previews." + L10n.tr("updateChannel.beta.description", default: "Receive stable releases plus beta previews.") } } diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index ee0ccaa65..ccb14d160 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -31,7 +31,7 @@ struct UsageBreakdownChartMenuView: View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No usage breakdown data.") + Text(L10n.tr("chart.usageBreakdown.empty", default: "No usage breakdown data.")) .font(.footnote) .foregroundStyle(.secondary) } else { diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 94d1ed565..61dc86fbf 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -12,9 +12,9 @@ enum UsagePaceText { static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String { let detail = self.weeklyDetail(pace: pace, now: now) if let rightLabel = detail.rightLabel { - return "Pace: \(detail.leftLabel) · \(rightLabel)" + return L10n.tr("usagePace.summary.withRight", default: "Pace: %@ · %@", detail.leftLabel, rightLabel) } - return "Pace: \(detail.leftLabel)" + return L10n.tr("usagePace.summary", default: "Pace: %@", detail.leftLabel) } static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail { @@ -29,40 +29,56 @@ enum UsagePaceText { let deltaValue = Int(abs(pace.deltaPercent).rounded()) switch pace.stage { case .onTrack: - return "On pace" + return L10n.tr("usagePace.onPace", default: "On pace") case .slightlyAhead, .ahead, .farAhead: - return "\(deltaValue)% in deficit" + return L10n.tr("usagePace.deficit", default: "%d%% in deficit", deltaValue) case .slightlyBehind, .behind, .farBehind: - return "\(deltaValue)% in reserve" + return L10n.tr("usagePace.reserve", default: "%d%% in reserve", deltaValue) } } private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? { let etaLabel: String? if pace.willLastToReset { - etaLabel = "Lasts until reset" + etaLabel = L10n.tr("usagePace.lastsUntilReset", default: "Lasts until reset") } else if let etaSeconds = pace.etaSeconds { - let etaText = Self.durationText(seconds: etaSeconds, now: now) - etaLabel = etaText == "now" ? "Runs out now" : "Runs out in \(etaText)" + let etaText = Self.durationText(seconds: etaSeconds) + etaLabel = etaText == L10n.tr("usagePace.now", default: "now") + ? L10n.tr("usagePace.runsOutNow", default: "Runs out now") + : L10n.tr("usagePace.runsOutIn", default: "Runs out in %@", etaText) } else { etaLabel = nil } guard let runOutProbability = pace.runOutProbability else { return etaLabel } let roundedRisk = self.roundedRiskPercent(runOutProbability) - let riskLabel = "≈ \(roundedRisk)% run-out risk" + let riskLabel = L10n.tr("usagePace.runOutRisk", default: "≈ %d%% run-out risk", roundedRisk) if let etaLabel { return "\(etaLabel) · \(riskLabel)" } return riskLabel } - private static func durationText(seconds: TimeInterval, now: Date) -> String { - let date = now.addingTimeInterval(seconds) - let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now) - if countdown == "now" { return "now" } - if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) } - return countdown + private static func durationText(seconds: TimeInterval) -> String { + let remaining = max(0, Int(ceil(seconds))) + if remaining < 60 { + return L10n.tr("usagePace.now", default: "now") + } + + let totalMinutes = max(1, Int(ceil(Double(remaining) / 60.0))) + let days = totalMinutes / (24 * 60) + let hours = (totalMinutes / 60) % 24 + let minutes = totalMinutes % 60 + + if days > 0 { + if hours > 0 { return "\(days)d \(hours)h" } + return "\(days)d" + } + if hours > 0 { + if minutes > 0 { return "\(hours)h \(minutes)m" } + return "\(hours)h" + } + return "\(totalMinutes)m" } private static func roundedRiskPercent(_ probability: Double) -> Int { diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index f00f14504..20b0660e6 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -28,6 +28,6 @@ extension UsageStore { } nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { - ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage() + LocalizedProviderText.message(ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage()) } } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2243d5218..b61db1f00 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -7,12 +7,12 @@ public enum CostUsageError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case let .unsupportedProvider(provider): - return "Cost summary is not supported for \(provider.rawValue)." + return L10n.tr("costUsage.unsupportedProvider", default: "Cost summary is not supported for %@.", provider.rawValue) case let .timedOut(seconds): if seconds >= 60, seconds % 60 == 0 { - return "Cost refresh timed out after \(seconds / 60)m." + return L10n.tr("costUsage.timedOutMinutes", default: "Cost refresh timed out after %dm.", seconds / 60) } - return "Cost refresh timed out after \(seconds)s." + return L10n.tr("costUsage.timedOutSeconds", default: "Cost refresh timed out after %ds.", seconds) } } } diff --git a/Sources/CodexBarCore/Localization/LocalizedProviderText.swift b/Sources/CodexBarCore/Localization/LocalizedProviderText.swift new file mode 100644 index 000000000..35ca265e7 --- /dev/null +++ b/Sources/CodexBarCore/Localization/LocalizedProviderText.swift @@ -0,0 +1,199 @@ +import Foundation + +public enum LocalizedProviderText { + public static func label(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return switch trimmed { + case "Session": + L10n.tr("provider.label.session", default: "Session") + case "Weekly": + L10n.tr("provider.label.weekly", default: "Weekly") + case "Credits": + L10n.tr("provider.label.credits", default: "Credits") + case "Usage": + L10n.tr("provider.label.usage", default: "Usage") + case "Plan": + L10n.tr("provider.label.plan", default: "Plan") + case "On-Demand": + L10n.tr("provider.label.onDemand", default: "On-Demand") + case "Current": + L10n.tr("provider.label.current", default: "Current") + case "Refill": + L10n.tr("provider.label.refill", default: "Refill") + case "Requests": + L10n.tr("provider.label.requests", default: "Requests") + case "Tokens": + L10n.tr("provider.label.tokens", default: "Tokens") + case "Premium": + L10n.tr("provider.label.premium", default: "Premium") + case "Chat": + L10n.tr("provider.label.chat", default: "Chat") + case "Pro": + L10n.tr("provider.label.pro", default: "Pro") + case "Flash": + L10n.tr("provider.label.flash", default: "Flash") + case "Flash Lite": + L10n.tr("provider.label.flashLite", default: "Flash Lite") + case "Sonnet": + L10n.tr("provider.label.sonnet", default: "Sonnet") + case "5-hour": + L10n.tr("provider.label.fiveHour", default: "5-hour") + case "Monthly": + L10n.tr("provider.label.monthly", default: "Monthly") + case "MCP": + L10n.tr("provider.label.mcp", default: "MCP") + case "Rate Limit": + L10n.tr("provider.label.rateLimit", default: "Rate Limit") + case "Standard": + L10n.tr("provider.label.standard", default: "Standard") + case "Add-on credits": + L10n.tr("provider.label.addOnCredits", default: "Add-on credits") + case "Balance": + L10n.tr("provider.label.balance", default: "Balance") + case "Quota": + L10n.tr("provider.label.quota", default: "Quota") + case "Prompts": + L10n.tr("provider.label.prompts", default: "Prompts") + case "Window": + L10n.tr("provider.label.window", default: "Window") + case "Kilo Pass": + L10n.tr("provider.label.kiloPass", default: "Kilo Pass") + case "Bonus": + L10n.tr("provider.label.bonus", default: "Bonus") + case "Amp Free": + L10n.tr("provider.label.ampFree", default: "Amp Free") + default: + text + } + } + + public static func usageToggle(_ provider: String, experimental: Bool = false) -> String { + if experimental { + return L10n.tr( + "provider.toggleTitle.experimental", + default: "Show %@ usage (experimental)", + provider) + } + return L10n.tr("provider.toggleTitle", default: "Show %@ usage", provider) + } + + public static func costSummaryUnsupported(_ provider: String) -> String { + L10n.tr("provider.message.costSummaryUnsupported", default: "%@ cost summary is not supported.", provider) + } + + public static func costSummaryUnavailable(_ provider: String) -> String { + L10n.tr("provider.message.costSummaryUnavailable", default: "%@ cost summary is not available.", provider) + } + + public static func costSummaryNotYetSupported(_ provider: String) -> String { + L10n.tr( + "provider.message.costSummaryNotYetSupported", + default: "%@ cost summary is not yet supported.", + provider) + } + + public static func codexNoSessions(sessions: String, archived: String) -> String { + L10n.tr( + "provider.message.codexNoSessions", + default: "No Codex sessions found in %@ or %@.", + sessions, + archived) + } + + public static func claudeNoLogs() -> String { + L10n.tr( + "provider.message.claudeNoLogs", + default: "No Claude usage logs found in %@ or %@.", + "~/.config/claude/projects", + "~/.claude/projects") + } + + public static func vertexAINoCostData() -> String { + L10n.tr( + "provider.message.vertexAINoCostData", + default: "No Vertex AI cost data found in Claude logs. Ensure entries include Vertex metadata.") + } + + public static func source(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return text } + + if trimmed.contains(" + ") { + return trimmed + .components(separatedBy: " + ") + .map(Self.source) + .joined(separator: " + ") + } + + return switch trimmed.lowercased() { + case "auto": + L10n.tr("provider.source.auto", default: "auto") + case "manual": + L10n.tr("provider.source.manual", default: "manual") + case "api": + L10n.tr("provider.source.api", default: "api") + case "web": + L10n.tr("provider.source.web", default: "web") + case "oauth": + L10n.tr("provider.source.oauth", default: "oauth") + case "cli": + L10n.tr("provider.source.cli", default: "cli") + case "local": + L10n.tr("provider.source.local", default: "local") + case "off": + L10n.tr("provider.source.off", default: "off") + case "openai-web": + L10n.tr("provider.source.openAIWeb", default: "openai-web") + case "codex-cli": + L10n.tr("provider.source.codexCLI", default: "codex-cli") + case "manual cookie header": + L10n.tr("provider.source.manualCookieHeader", default: "manual cookie header") + default: + text + } + } + + public static func message(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return text } + + switch trimmed { + case "Credits unavailable; keep Codex running to refresh.": + return L10n.tr( + "provider.message.codexCreditsUnavailable", + default: "Credits unavailable; keep Codex running to refresh.") + case "Credit balance from OpenRouter API": + return L10n.tr("provider.message.openRouterCreditBalance", default: "Credit balance from OpenRouter API") + case "On-demand usage beyond included plan limits.": + return L10n.tr( + "provider.message.cursorOnDemand", + default: "On-demand usage beyond included plan limits.") + case "Augment Code credits for AI-powered coding assistance.": + return L10n.tr( + "provider.message.augmentCredits", + default: "Augment Code credits for AI-powered coding assistance.") + default: + break + } + + if let provider = Self.prefix(before: " cost summary is not supported.", in: trimmed) { + return Self.costSummaryUnsupported(provider) + } + if let provider = Self.prefix(before: " cost summary is not available.", in: trimmed) { + return Self.costSummaryUnavailable(provider) + } + if let provider = Self.prefix(before: " cost summary is not yet supported.", in: trimmed) { + return Self.costSummaryNotYetSupported(provider) + } + if trimmed == "No Vertex AI cost data found in Claude logs. Ensure entries include Vertex metadata." { + return Self.vertexAINoCostData() + } + + return text + } + + private static func prefix(before suffix: String, in text: String) -> String? { + guard text.hasSuffix(suffix) else { return nil } + return String(text.dropLast(suffix.count)) + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift index 1e7e5b772..e9a2df067 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift @@ -9,9 +9,15 @@ public enum AlibabaCodingPlanAPIRegion: String, CaseIterable, Sendable { public var displayName: String { switch self { case .international: - "International (modelstudio.console.alibabacloud.com)" + L10n.tr( + "provider.region.alibaba.international", + default: "International (%@)", + "modelstudio.console.alibabacloud.com") case .chinaMainland: - "China mainland (bailian.console.aliyun.com)" + L10n.tr( + "provider.region.alibaba.chinaMainland", + default: "China mainland (%@)", + "bailian.console.aliyun.com") } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift index b15a40d54..16e5ae8a6 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift @@ -28,13 +28,13 @@ public enum AlibabaCodingPlanProviderDescriptor { metadata: ProviderMetadata( id: .alibaba, displayName: "Alibaba", - sessionLabel: "5-hour", - weeklyLabel: "Weekly", - opusLabel: "Monthly", + sessionLabel: LocalizedProviderText.label("5-hour"), + weeklyLabel: LocalizedProviderText.label("Weekly"), + opusLabel: LocalizedProviderText.label("Monthly"), supportsOpus: true, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Alibaba usage", + toggleTitle: LocalizedProviderText.usageToggle("Alibaba"), cliName: "alibaba-coding-plan", defaultEnabled: false, isPrimaryProvider: false, @@ -49,7 +49,7 @@ public enum AlibabaCodingPlanProviderDescriptor { color: ProviderColor(red: 1.0, green: 106 / 255, blue: 0)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Alibaba Coding Plan cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Alibaba Coding Plan") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web, .api], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), diff --git a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift index bfe1bee0e..6ad8038c7 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum AmpProviderDescriptor { metadata: ProviderMetadata( id: .amp, displayName: "Amp", - sessionLabel: "Amp Free", - weeklyLabel: "Balance", + sessionLabel: LocalizedProviderText.label("Amp Free"), + weeklyLabel: LocalizedProviderText.label("Balance"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Amp usage", + toggleTitle: LocalizedProviderText.usageToggle("Amp"), cliName: "amp", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum AmpProviderDescriptor { color: ProviderColor(red: 220 / 255, green: 38 / 255, blue: 38 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Amp cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Amp") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [AmpStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 1e59964b0..1c0ed863e 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum AntigravityProviderDescriptor { metadata: ProviderMetadata( id: .antigravity, displayName: "Antigravity", - sessionLabel: "Claude", - weeklyLabel: "Gemini Pro", - opusLabel: "Gemini Flash", + sessionLabel: LocalizedProviderText.label("Claude"), + weeklyLabel: LocalizedProviderText.label("Gemini Pro"), + opusLabel: LocalizedProviderText.label("Gemini Flash"), supportsOpus: true, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Antigravity usage (experimental)", + toggleTitle: LocalizedProviderText.usageToggle("Antigravity", experimental: true), cliName: "antigravity", defaultEnabled: false, isPrimaryProvider: false, @@ -31,7 +31,7 @@ public enum AntigravityProviderDescriptor { color: ProviderColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Antigravity cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Antigravity") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [AntigravityStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift index 4ccb0987f..dedb89bd2 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift @@ -34,13 +34,15 @@ public enum AugmentProviderDescriptor { metadata: ProviderMetadata( id: .augment, displayName: "Augment", - sessionLabel: "Credits", - weeklyLabel: "Usage", + sessionLabel: LocalizedProviderText.label("Credits"), + weeklyLabel: LocalizedProviderText.label("Usage"), opusLabel: nil, supportsOpus: false, supportsCredits: true, - creditsHint: "Augment Code credits for AI-powered coding assistance.", - toggleTitle: "Show Augment usage", + creditsHint: L10n.tr( + "provider.message.augmentCredits", + default: "Augment Code credits for AI-powered coding assistance."), + toggleTitle: LocalizedProviderText.usageToggle("Augment"), cliName: "augment", defaultEnabled: false, isPrimaryProvider: false, @@ -55,7 +57,7 @@ public enum AugmentProviderDescriptor { color: ProviderColor(red: 99 / 255, green: 102 / 255, blue: 241 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Augment cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Augment") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 3918ef04f..1b59bbf59 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum ClaudeProviderDescriptor { metadata: ProviderMetadata( id: .claude, displayName: "Claude", - sessionLabel: "Session", - weeklyLabel: "Weekly", - opusLabel: "Sonnet", + sessionLabel: LocalizedProviderText.label("Session"), + weeklyLabel: LocalizedProviderText.label("Weekly"), + opusLabel: LocalizedProviderText.label("Sonnet"), supportsOpus: true, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Claude Code usage", + toggleTitle: LocalizedProviderText.usageToggle("Claude Code"), cliName: "claude", defaultEnabled: false, isPrimaryProvider: true, @@ -90,7 +90,7 @@ public enum ClaudeProviderDescriptor { } private static func noDataMessage() -> String { - "No Claude usage logs found in ~/.config/claude/projects or ~/.claude/projects." + LocalizedProviderText.claudeNoLogs() } public static func resolveUsageStrategy( diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift index b97dce042..7a659f037 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift @@ -12,10 +12,10 @@ public enum ClaudeUsageDataSource: String, CaseIterable, Identifiable, Sendable public var displayName: String { switch self { - case .auto: "Auto" - case .oauth: "OAuth API" - case .web: "Web API (cookies)" - case .cli: "CLI (PTY)" + case .auto: L10n.tr("common.auto", default: "Auto") + case .oauth: L10n.tr("provider.sourceOption.oauthAPI", default: "OAuth API") + case .web: L10n.tr("provider.sourceOption.webAPICookies", default: "Web API (cookies)") + case .cli: L10n.tr("provider.sourceOption.cliPTY", default: "CLI (PTY)") } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 12b68cd22..bdb080328 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: LocalizedProviderText.label("Session"), + weeklyLabel: LocalizedProviderText.label("Weekly"), opusLabel: nil, supportsOpus: false, supportsCredits: true, - creditsHint: "Credits unavailable; keep Codex running to refresh.", - toggleTitle: "Show Codex usage", + creditsHint: L10n.tr( + "provider.message.codexCreditsUnavailable", + default: "Credits unavailable; keep Codex running to refresh."), + toggleTitle: LocalizedProviderText.usageToggle("Codex"), cliName: "codex", defaultEnabled: true, isPrimaryProvider: true, @@ -84,7 +86,7 @@ public enum CodexProviderDescriptor { } ?? "\(home)/.codex" let sessions = "\(base)/sessions" let archived = "\(base)/archived_sessions" - return "No Codex sessions found in \(sessions) or \(archived)." + return LocalizedProviderText.codexNoSessions(sessions: sessions, archived: archived) } public static func resolveUsageStrategy( diff --git a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift index b3bb16cd0..15ee91115 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift @@ -11,9 +11,9 @@ 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("common.auto", default: "Auto") + case .oauth: L10n.tr("provider.sourceOption.oauthAPI", default: "OAuth API") + case .cli: L10n.tr("provider.sourceOption.cliRPCPTY", default: "CLI (RPC/PTY)") } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 9e2b063cc..342691b7a 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum CopilotProviderDescriptor { metadata: ProviderMetadata( id: .copilot, displayName: "Copilot", - sessionLabel: "Premium", - weeklyLabel: "Chat", + sessionLabel: LocalizedProviderText.label("Premium"), + weeklyLabel: LocalizedProviderText.label("Chat"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Copilot usage", + toggleTitle: LocalizedProviderText.usageToggle("Copilot"), cliName: "copilot", defaultEnabled: false, isPrimaryProvider: false, @@ -29,7 +29,7 @@ public enum CopilotProviderDescriptor { color: ProviderColor(red: 168 / 255, green: 85 / 255, blue: 247 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Copilot cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Copilot") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CopilotAPIFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index e694e2e77..9fc361a99 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -10,13 +10,15 @@ public enum CursorProviderDescriptor { metadata: ProviderMetadata( id: .cursor, displayName: "Cursor", - sessionLabel: "Plan", - weeklyLabel: "On-Demand", + sessionLabel: LocalizedProviderText.label("Plan"), + weeklyLabel: LocalizedProviderText.label("On-Demand"), opusLabel: nil, supportsOpus: false, supportsCredits: true, - creditsHint: "On-demand usage beyond included plan limits.", - toggleTitle: "Show Cursor usage", + creditsHint: L10n.tr( + "provider.message.cursorOnDemand", + default: "On-demand usage beyond included plan limits."), + toggleTitle: LocalizedProviderText.usageToggle("Cursor"), cliName: "cursor", defaultEnabled: false, isPrimaryProvider: false, @@ -31,7 +33,7 @@ public enum CursorProviderDescriptor { color: ProviderColor(red: 0 / 255, green: 191 / 255, blue: 165 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Cursor cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Cursor") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CursorStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift index dbbe1509d..ba6461e58 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum FactoryProviderDescriptor { metadata: ProviderMetadata( id: .factory, displayName: "Droid", - sessionLabel: "Standard", - weeklyLabel: "Premium", + sessionLabel: LocalizedProviderText.label("Standard"), + weeklyLabel: LocalizedProviderText.label("Premium"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Droid usage", + toggleTitle: LocalizedProviderText.usageToggle("Droid"), cliName: "factory", defaultEnabled: false, isPrimaryProvider: false, @@ -31,7 +31,7 @@ public enum FactoryProviderDescriptor { color: ProviderColor(red: 255 / 255, green: 107 / 255, blue: 53 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Droid cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Droid") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [FactoryStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index c71f5ca04..ceb25e3d0 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum GeminiProviderDescriptor { metadata: ProviderMetadata( id: .gemini, displayName: "Gemini", - sessionLabel: "Pro", - weeklyLabel: "Flash", - opusLabel: "Flash Lite", + sessionLabel: LocalizedProviderText.label("Pro"), + weeklyLabel: LocalizedProviderText.label("Flash"), + opusLabel: LocalizedProviderText.label("Flash Lite"), supportsOpus: true, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Gemini usage", + toggleTitle: LocalizedProviderText.usageToggle("Gemini"), cliName: "gemini", defaultEnabled: false, isPrimaryProvider: false, @@ -31,7 +31,7 @@ public enum GeminiProviderDescriptor { color: ProviderColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Gemini cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Gemini") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [GeminiStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift index 7b079e883..e6ed9b10b 100644 --- a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum JetBrainsProviderDescriptor { metadata: ProviderMetadata( id: .jetbrains, displayName: "JetBrains AI", - sessionLabel: "Current", - weeklyLabel: "Refill", + sessionLabel: LocalizedProviderText.label("Current"), + weeklyLabel: LocalizedProviderText.label("Refill"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show JetBrains AI usage", + toggleTitle: LocalizedProviderText.usageToggle("JetBrains AI"), cliName: "jetbrains", defaultEnabled: false, isPrimaryProvider: false, @@ -29,7 +29,7 @@ public enum JetBrainsProviderDescriptor { color: ProviderColor(red: 255 / 255, green: 51 / 255, blue: 153 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "JetBrains AI cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("JetBrains AI") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [JetBrainsStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift index 8e3d8fad3..331a21a05 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum KiloProviderDescriptor { metadata: ProviderMetadata( id: .kilo, displayName: "Kilo", - sessionLabel: "Credits", - weeklyLabel: "Kilo Pass", + sessionLabel: LocalizedProviderText.label("Credits"), + weeklyLabel: LocalizedProviderText.label("Kilo Pass"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Kilo usage", + toggleTitle: LocalizedProviderText.usageToggle("Kilo"), cliName: "kilo", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum KiloProviderDescriptor { color: ProviderColor(red: 242 / 255, green: 112 / 255, blue: 39 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Kilo cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Kilo") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloUsageDataSource.swift b/Sources/CodexBarCore/Providers/Kilo/KiloUsageDataSource.swift index 7c2487447..a8f85ffe1 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloUsageDataSource.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloUsageDataSource.swift @@ -11,9 +11,9 @@ public enum KiloUsageDataSource: String, CaseIterable, Identifiable, Sendable { public var displayName: String { switch self { - case .auto: "Auto" - case .api: "API" - case .cli: "CLI" + case .auto: L10n.tr("common.auto", default: "Auto") + case .api: L10n.tr("provider.sourceOption.api", default: "API") + case .cli: L10n.tr("provider.sourceOption.cli", default: "CLI") } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 711c20bc8..efbf9ac9f 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum KimiProviderDescriptor { metadata: ProviderMetadata( id: .kimi, displayName: "Kimi", - sessionLabel: "Weekly", - weeklyLabel: "Rate Limit", + sessionLabel: LocalizedProviderText.label("Weekly"), + weeklyLabel: LocalizedProviderText.label("Rate Limit"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Kimi usage", + toggleTitle: LocalizedProviderText.usageToggle("Kimi"), cliName: "kimi", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum KimiProviderDescriptor { color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Kimi cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Kimi") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [KimiWebFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift index cc986b6f0..06cda86a8 100644 --- a/Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum KimiK2ProviderDescriptor { metadata: ProviderMetadata( id: .kimik2, displayName: "Kimi K2", - sessionLabel: "Credits", - weeklyLabel: "Credits", + sessionLabel: LocalizedProviderText.label("Credits"), + weeklyLabel: LocalizedProviderText.label("Credits"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Kimi K2 usage", + toggleTitle: LocalizedProviderText.usageToggle("Kimi K2"), cliName: "kimik2", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum KimiK2ProviderDescriptor { color: ProviderColor(red: 76 / 255, green: 0 / 255, blue: 255 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Kimi K2 cost summary is not available." }), + noDataMessage: { LocalizedProviderText.costSummaryUnavailable("Kimi K2") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [KimiK2APIFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift index ad006df0d..d5f23a385 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum KiroProviderDescriptor { metadata: ProviderMetadata( id: .kiro, displayName: "Kiro", - sessionLabel: "Credits", - weeklyLabel: "Bonus", + sessionLabel: LocalizedProviderText.label("Credits"), + weeklyLabel: LocalizedProviderText.label("Bonus"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Kiro usage", + toggleTitle: LocalizedProviderText.usageToggle("Kiro"), cliName: "kiro", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum KiroProviderDescriptor { color: ProviderColor(red: 255 / 255, green: 153 / 255, blue: 0 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Kiro cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Kiro") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [KiroCLIFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index 8428f81f7..a89417303 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -11,9 +11,12 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { public var displayName: String { switch self { case .global: - "Global (platform.minimax.io)" + L10n.tr("provider.region.minimax.global", default: "Global (%@)", "platform.minimax.io") case .chinaMainland: - "China mainland (platform.minimaxi.com)" + L10n.tr( + "provider.region.minimax.chinaMainland", + default: "China mainland (%@)", + "platform.minimaxi.com") } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 762177efe..76ff4b956 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum MiniMaxProviderDescriptor { metadata: ProviderMetadata( id: .minimax, displayName: "MiniMax", - sessionLabel: "Prompts", - weeklyLabel: "Window", + sessionLabel: LocalizedProviderText.label("Prompts"), + weeklyLabel: LocalizedProviderText.label("Window"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show MiniMax usage", + toggleTitle: LocalizedProviderText.usageToggle("MiniMax"), cliName: "minimax", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum MiniMaxProviderDescriptor { color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "MiniMax cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("MiniMax") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web, .api], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift index 4702609fa..404063e87 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum OllamaProviderDescriptor { metadata: ProviderMetadata( id: .ollama, displayName: "Ollama", - sessionLabel: "Session", - weeklyLabel: "Weekly", + sessionLabel: LocalizedProviderText.label("Session"), + weeklyLabel: LocalizedProviderText.label("Weekly"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Ollama usage", + toggleTitle: LocalizedProviderText.usageToggle("Ollama"), cliName: "ollama", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum OllamaProviderDescriptor { color: ProviderColor(red: 136 / 255, green: 136 / 255, blue: 136 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Ollama cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Ollama") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OllamaStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 18803a46c..06f129cd7 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum OpenCodeProviderDescriptor { metadata: ProviderMetadata( id: .opencode, displayName: "OpenCode", - sessionLabel: "5-hour", - weeklyLabel: "Weekly", + sessionLabel: LocalizedProviderText.label("5-hour"), + weeklyLabel: LocalizedProviderText.label("Weekly"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show OpenCode usage", + toggleTitle: LocalizedProviderText.usageToggle("OpenCode"), cliName: "opencode", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum OpenCodeProviderDescriptor { color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "OpenCode cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("OpenCode") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenCodeUsageFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift index 711954198..becfa10be 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum OpenRouterProviderDescriptor { metadata: ProviderMetadata( id: .openrouter, displayName: "OpenRouter", - sessionLabel: "Credits", - weeklyLabel: "Usage", + sessionLabel: LocalizedProviderText.label("Credits"), + weeklyLabel: LocalizedProviderText.label("Usage"), opusLabel: nil, supportsOpus: false, supportsCredits: true, - creditsHint: "Credit balance from OpenRouter API", - toggleTitle: "Show OpenRouter usage", + creditsHint: L10n.tr("provider.message.openRouterCreditBalance", default: "Credit balance from OpenRouter API"), + toggleTitle: LocalizedProviderText.usageToggle("OpenRouter"), cliName: "openrouter", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum OpenRouterProviderDescriptor { color: ProviderColor(red: 100 / 255, green: 103 / 255, blue: 242 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "OpenRouter cost summary is not yet supported." }), + noDataMessage: { LocalizedProviderText.costSummaryNotYetSupported("OpenRouter") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenRouterAPIFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/ProviderCookieSource.swift b/Sources/CodexBarCore/Providers/ProviderCookieSource.swift index 29e90e9f4..70b080b6f 100644 --- a/Sources/CodexBarCore/Providers/ProviderCookieSource.swift +++ b/Sources/CodexBarCore/Providers/ProviderCookieSource.swift @@ -11,9 +11,9 @@ public enum ProviderCookieSource: String, CaseIterable, Identifiable, Sendable, public var displayName: String { switch self { - case .auto: "Auto" - case .manual: "Manual" - case .off: "Off" + case .auto: L10n.tr("common.auto", default: "Auto") + case .manual: L10n.tr("common.manual", default: "Manual") + case .off: L10n.tr("common.off", default: "Off") } } diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift index 550ab9190..d48ccdf0c 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum SyntheticProviderDescriptor { metadata: ProviderMetadata( id: .synthetic, displayName: "Synthetic", - sessionLabel: "Quota", - weeklyLabel: "Usage", + sessionLabel: LocalizedProviderText.label("Quota"), + weeklyLabel: LocalizedProviderText.label("Usage"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Synthetic usage", + toggleTitle: LocalizedProviderText.usageToggle("Synthetic"), cliName: "synthetic", defaultEnabled: false, isPrimaryProvider: false, @@ -29,7 +29,7 @@ public enum SyntheticProviderDescriptor { color: ProviderColor(red: 20 / 255, green: 20 / 255, blue: 20 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Synthetic cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("Synthetic") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [SyntheticAPIFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift index f81e0d1f2..fe80170eb 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum VertexAIProviderDescriptor { metadata: ProviderMetadata( id: .vertexai, displayName: "Vertex AI", - sessionLabel: "Requests", - weeklyLabel: "Tokens", + sessionLabel: LocalizedProviderText.label("Requests"), + weeklyLabel: LocalizedProviderText.label("Tokens"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Vertex AI usage", + toggleTitle: LocalizedProviderText.usageToggle("Vertex AI"), cliName: "vertexai", defaultEnabled: false, isPrimaryProvider: false, @@ -30,8 +30,7 @@ public enum VertexAIProviderDescriptor { color: ProviderColor(red: 66 / 255, green: 133 / 255, blue: 244 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, - noDataMessage: { "No Vertex AI cost data found in Claude logs. Ensure entries include Vertex metadata." - }), + noDataMessage: { LocalizedProviderText.vertexAINoCostData() }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .oauth], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [VertexAIOAuthFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift index 29506321c..738380a9f 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum WarpProviderDescriptor { metadata: ProviderMetadata( id: .warp, displayName: "Warp", - sessionLabel: "Credits", - weeklyLabel: "Add-on credits", + sessionLabel: LocalizedProviderText.label("Credits"), + weeklyLabel: LocalizedProviderText.label("Add-on credits"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Warp usage", + toggleTitle: LocalizedProviderText.usageToggle("Warp"), cliName: "warp", defaultEnabled: false, isPrimaryProvider: false, @@ -30,7 +30,7 @@ public enum WarpProviderDescriptor { color: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Warp cost summary is not available." }), + noDataMessage: { LocalizedProviderText.costSummaryUnavailable("Warp") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WarpAPIFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift index 18d5a15e9..881a3d619 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift @@ -9,9 +9,9 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { public var displayName: String { switch self { case .global: - "Global (api.z.ai)" + L10n.tr("provider.region.zai.global", default: "Global (%@)", "api.z.ai") case .bigmodelCN: - "BigModel CN (open.bigmodel.cn)" + L10n.tr("provider.region.zai.bigmodelCN", default: "BigModel CN (%@)", "open.bigmodel.cn") } } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index 430066a10..ab8979d64 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -10,13 +10,13 @@ public enum ZaiProviderDescriptor { metadata: ProviderMetadata( id: .zai, displayName: "z.ai", - sessionLabel: "Tokens", - weeklyLabel: "MCP", + sessionLabel: LocalizedProviderText.label("Tokens"), + weeklyLabel: LocalizedProviderText.label("MCP"), opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show z.ai usage", + toggleTitle: LocalizedProviderText.usageToggle("z.ai"), cliName: "zai", defaultEnabled: false, isPrimaryProvider: false, @@ -29,7 +29,7 @@ public enum ZaiProviderDescriptor { color: ProviderColor(red: 232 / 255, green: 90 / 255, blue: 106 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "z.ai cost summary is not supported." }), + noDataMessage: { LocalizedProviderText.costSummaryUnsupported("z.ai") }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZaiAPIFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings index 9a8180bcb..5bb3f4268 100644 --- a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings @@ -171,4 +171,17 @@ "preferences.tab.display" = "Display"; "preferences.tab.general" = "General"; "preferences.tab.providers" = "Providers"; +"provider.label.monthly" = "Monthly"; "provider.label.sonnet" = "Sonnet"; +"provider.message.augmentCredits" = "Augment Code credits for AI-powered coding assistance."; +"provider.message.claudeNoLogs" = "No Claude usage logs found in %@ or %@."; +"provider.message.codexCreditsUnavailable" = "Credits unavailable; keep Codex running to refresh."; +"provider.message.codexNoSessions" = "No Codex sessions found in %@ or %@."; +"provider.message.costSummaryNotYetSupported" = "%@ cost summary is not yet supported."; +"provider.message.costSummaryUnavailable" = "%@ cost summary is not available."; +"provider.message.costSummaryUnsupported" = "%@ cost summary is not supported."; +"provider.message.cursorOnDemand" = "On-demand usage beyond included plan limits."; +"provider.message.openRouterCreditBalance" = "Credit balance from OpenRouter API"; +"provider.message.vertexAINoCostData" = "No Vertex AI cost data found in Claude logs. Ensure entries include Vertex metadata."; +"provider.toggleTitle" = "Show %@ usage"; +"provider.toggleTitle.experimental" = "Show %@ usage (experimental)"; 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..bac1c1f9d --- /dev/null +++ b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,518 @@ +"about.built" = "构建于 %@"; +"about.credits" = "Peter Steinberger — MIT License\\n"; +"common.add" = "添加"; +"common.cancel" = "取消"; +"common.copy" = "复制"; +"common.copyError" = "复制错误"; +"common.copied" = "已复制"; +"common.disabled" = "已禁用"; +"common.email" = "邮箱"; +"common.enabled" = "已启用"; +"common.hidden" = "已隐藏"; +"common.notDetected" = "未检测到"; +"common.ok" = "确定"; +"common.refresh" = "刷新"; +"common.reload" = "重新加载"; +"common.website" = "官网"; +"costUsage.timedOutMinutes" = "成本刷新在 %d 分钟后超时。"; +"costUsage.timedOutSeconds" = "成本刷新在 %d 秒后超时。"; +"costUsage.unsupportedProvider" = "不支持 %@ 的成本摘要。"; +"credits.window.title" = "购买 Credits"; +"chart.costHistory.empty" = "暂无成本历史数据。"; +"chart.costHistory.total30Days" = "总计(30 天):%@"; +"chart.creditsHistory.empty" = "暂无 Credits 历史数据。"; +"chart.creditsHistory.total30Days" = "总计(30 天):%@ Credits"; +"chart.usageBreakdown.empty" = "暂无用量拆分数据。"; +"keychain.ampCookie.message" = "CodexBar 将请求 macOS Keychain 中的 Amp Cookie Header,以便获取使用情况。点击“确定”继续。"; +"keychain.augmentCookie.message" = "CodexBar 将请求 macOS Keychain 中的 Augment Cookie Header,以便获取使用情况。点击“确定”继续。"; +"keychain.browserCookie.message" = "CodexBar 将请求 macOS Keychain 中的“%@”,以便解密浏览器 Cookie 并验证你的账户。点击“确定”继续。"; +"keychain.claudeCookie.message" = "CodexBar 将请求 macOS Keychain 中的 Claude Cookie Header,以便获取 Claude 网页端使用情况。点击“确定”继续。"; +"keychain.claudeOAuth.message" = "CodexBar 将请求 macOS Keychain 中的 Claude Code OAuth 令牌,以便获取你的 Claude 使用情况。点击“确定”继续。"; +"keychain.codexCookie.message" = "CodexBar 将请求 macOS Keychain 中的 OpenAI Cookie Header,以便获取 Codex 控制台附加信息。点击“确定”继续。"; +"keychain.copilotToken.message" = "CodexBar 将请求 macOS Keychain 中的 GitHub Copilot 令牌,以便获取使用情况。点击“确定”继续。"; +"keychain.cursorCookie.message" = "CodexBar 将请求 macOS Keychain 中的 Cursor Cookie Header,以便获取使用情况。点击“确定”继续。"; +"keychain.factoryCookie.message" = "CodexBar 将请求 macOS Keychain 中的 Factory Cookie Header,以便获取使用情况。点击“确定”继续。"; +"keychain.kimiToken.message" = "CodexBar 将请求 macOS Keychain 中的 Kimi 认证令牌,以便获取使用情况。点击“确定”继续。"; +"keychain.kimik2Token.message" = "CodexBar 将请求 macOS Keychain 中的 Kimi K2 API Key,以便获取使用情况。点击“确定”继续。"; +"keychain.minimaxCookie.message" = "CodexBar 将请求 macOS Keychain 中的 MiniMax Cookie Header,以便获取使用情况。点击“确定”继续。"; +"keychain.minimaxToken.message" = "CodexBar 将请求 macOS Keychain 中的 MiniMax API 令牌,以便获取使用情况。点击“确定”继续。"; +"keychain.opencodeCookie.message" = "CodexBar 将请求 macOS Keychain 中的 OpenCode Cookie Header,以便获取使用情况。点击“确定”继续。"; +"keychain.syntheticToken.message" = "CodexBar 将请求 macOS Keychain 中的 Synthetic API Key,以便获取使用情况。点击“确定”继续。"; +"keychain.title" = "需要访问 Keychain"; +"keychain.zaiToken.message" = "CodexBar 将请求 macOS Keychain 中的 z.ai API 令牌,以便获取使用情况。点击“确定”继续。"; +"login.antigravity.managed.message" = "打开 Antigravity 完成登录,然后刷新 CodexBar。"; +"login.antigravity.managed.title" = "Antigravity 的登录由应用本身管理"; +"login.claude.failed.status" = "claude /login 以状态 %@ 退出。"; +"login.claude.failed.title" = "Claude 登录失败"; +"login.claude.launchFailed.title" = "无法启动 claude /login"; +"login.claude.missingBinary.message" = "请先安装 Claude CLI(npm i -g @anthropic-ai/claude-code),然后重试。"; +"login.claude.missingBinary.title" = "未找到 Claude CLI"; +"login.claude.timedOut.title" = "Claude 登录超时"; +"login.codex.failed.status" = "codex login 以状态 %@ 退出。"; +"login.copilot.deviceCode.message" = "设备码已复制到剪贴板:%@\\n\\n请前往 %@ 完成验证。"; +"login.copilot.failed.title" = "登录失败"; +"login.copilot.openBrowser" = "打开浏览器"; +"login.copilot.success.title" = "登录成功"; +"login.copilot.title" = "GitHub Copilot 登录"; +"login.copilot.waiting.message" = "请在浏览器中完成登录。\\n完成后此窗口会自动关闭。"; +"login.copilot.waiting.title" = "正在等待认证完成…"; +"login.cursor.failed.title" = "Cursor 登录失败"; +"login.gemini.launchFailed.title" = "无法为 Gemini 打开 Terminal"; +"login.gemini.missingBinary.message" = "请先安装 Gemini CLI(npm i -g @google/gemini-cli),然后重试。"; +"login.gemini.missingBinary.title" = "未找到 Gemini CLI"; +"login.noOutput" = "未捕获到输出。"; +"login.success.body" = "你现在可以回到应用;认证已完成。"; +"login.success.title" = "%@ 登录成功"; +"login.vertex.message" = "要使用 Vertex AI 追踪,你需要先通过 Google Cloud 完成认证。\\n\\n1. 打开 Terminal\\n2. 运行:gcloud auth application-default login\\n3. 按浏览器提示完成登录\\n4. 设置项目:gcloud config set project PROJECT_ID\\n\\n现在要打开 Terminal 吗?"; +"login.vertex.openTerminal" = "打开 Terminal"; +"login.vertex.title" = "Vertex AI 登录"; +"menu.about" = "关于 CodexBar"; +"menu.account" = "账户:%@"; +"menu.activity" = "活动:%@"; +"menu.addAccount" = "添加账户…"; +"menu.buyCredits" = "购买 Credits…"; +"menu.card.cost" = "成本"; +"menu.card.credits" = "Credits"; +"menu.card.creditsRemaining" = "剩余 Credits"; +"menu.card.extraUsageSpent" = "额外用量已花费"; +"menu.card.percentUsed" = "已用 %.0f%%"; +"menu.card.scaleTokens" = "%@ tokens"; +"menu.creditsHistory" = "Credits 历史"; +"menu.dashboard" = "用量控制台"; +"menu.metric.apiKeyLimit" = "API 密钥限额"; +"menu.noUsageConfigured" = "尚未配置用量。"; +"menu.noUsageYet" = "暂无用量"; +"menu.plan" = "方案:%@"; +"menu.quit" = "退出"; +"menu.quota" = "额度:%@ / %@"; +"menu.rateWindow" = "%@:%@"; +"menu.settings" = "设置…"; +"menu.statusPage" = "状态页"; +"menu.switchAccount" = "切换账户…"; +"menu.updateReady" = "更新已就绪,现在重启吗?"; +"menu.usageBreakdown" = "用量拆分"; +"menu.usageHistory30Days" = "用量历史(30 天)"; +"menu.zai.details" = "MCP 详情"; +"menu.zai.resets" = "重置:%@"; +"menu.zai.window" = "窗口:%@"; +"preferences.about.autoUpdates" = "自动检查更新"; +"preferences.about.built" = "构建于 %@"; +"preferences.about.checkForUpdates" = "检查更新…"; +"preferences.about.tagline" = "愿你的 tokens 永不耗尽,始终把 agent 限额尽收眼底。"; +"preferences.about.updateChannel" = "更新通道"; +"preferences.about.updatesUnavailable" = "此构建中更新不可用。"; +"preferences.about.version" = "版本 %@"; +"preferences.advanced.disableKeychain.subtitle" = "启用后将阻止任何 Keychain 访问。"; +"preferences.advanced.disableKeychain.title" = "禁用 Keychain 访问"; +"preferences.advanced.hidePII.subtitle" = "在菜单栏和菜单界面中隐藏邮箱地址等个人信息。"; +"preferences.advanced.hidePII.title" = "隐藏个人信息"; +"preferences.advanced.installCLI.button" = "安装 CLI"; +"preferences.advanced.installCLI.exists" = "已存在:%@"; +"preferences.advanced.installCLI.failed" = "失败:%@"; +"preferences.advanced.installCLI.installed" = "已安装:%@"; +"preferences.advanced.installCLI.missing" = "App Bundle 中未找到 CodexBarCLI。"; +"preferences.advanced.installCLI.noDirs" = "未找到可写的 bin 目录。"; +"preferences.advanced.installCLI.noWriteAccess" = "无写入权限:%@"; +"preferences.advanced.installCLI.subtitle" = "将 CodexBarCLI 以 codexbar 的名字创建符号链接到 /usr/local/bin 和 /opt/homebrew/bin。"; +"preferences.advanced.keychain.caption" = "禁用所有 Keychain 读写。浏览器 Cookie 导入将不可用;请在 Providers 中手动粘贴 Cookie Header。"; +"preferences.advanced.keychain.title" = "Keychain 访问"; +"preferences.advanced.openMenu.subtitle" = "在任何位置触发菜单栏菜单。"; +"preferences.advanced.openMenu.title" = "打开菜单"; +"preferences.advanced.section.shortcut" = "快捷键"; +"preferences.advanced.showDebug.subtitle" = "在 Debug 标签页中显示排障工具。"; +"preferences.advanced.showDebug.title" = "显示 Debug 设置"; +"preferences.advanced.surprise.subtitle" = "看看你的 agents 在上面耍花样时你是否喜欢。"; +"preferences.advanced.surprise.title" = "来点惊喜"; +"preferences.debug.animations.blink" = "立即闪烁"; +"preferences.debug.animations.caption" = "选择一个动画并在菜单栏中重放。使用“随机”可保持现有行为。"; +"preferences.debug.animations.pattern" = "动画模式"; +"preferences.debug.animations.random" = "随机(默认)"; +"preferences.debug.animations.replay" = "重放所选动画"; +"preferences.debug.animations.title" = "加载动画"; +"preferences.debug.caches.caption" = "清除缓存的成本扫描结果。"; +"preferences.debug.caches.clear" = "清除成本缓存"; +"preferences.debug.caches.cleared" = "已清除。"; +"preferences.debug.caches.failed" = "失败:%@"; +"preferences.debug.caches.title" = "缓存"; +"preferences.debug.cliPaths.caption" = "查看解析后的 Codex 二进制路径和各层 PATH;同时显示启动时捕获的登录 Shell PATH(短超时)。"; +"preferences.debug.cliPaths.claudeBinary" = "Claude 二进制"; +"preferences.debug.cliPaths.codexBinary" = "Codex 二进制"; +"preferences.debug.cliPaths.effectivePath" = "当前生效的 PATH"; +"preferences.debug.cliPaths.loginPath" = "登录 Shell PATH(启动时捕获)"; +"preferences.debug.cliPaths.notFound" = "未找到"; +"preferences.debug.cliPaths.title" = "CLI 路径"; +"preferences.debug.cliPaths.unavailable" = "不可用"; +"preferences.debug.cliSessions.caption" = "在探测后保持 Codex/Claude CLI 会话存活。默认会在捕获到数据后退出。"; +"preferences.debug.cliSessions.keepAlive.subtitle" = "在探测之间跳过清理(仅限调试)。"; +"preferences.debug.cliSessions.keepAlive.title" = "保持 CLI 会话存活"; +"preferences.debug.cliSessions.reset" = "重置 CLI 会话"; +"preferences.debug.cliSessions.title" = "CLI 会话"; +"preferences.debug.emptyLog" = "暂无日志。点击获取以加载。"; +"preferences.debug.errorSimulation.caption" = "向菜单卡片中注入一条伪造错误信息,用于布局测试。"; +"preferences.debug.errorSimulation.clearCost" = "清除成本错误"; +"preferences.debug.errorSimulation.clearMenu" = "清除菜单错误"; +"preferences.debug.errorSimulation.setCost" = "设置成本错误"; +"preferences.debug.errorSimulation.setMenu" = "设置菜单错误"; +"preferences.debug.errorSimulation.text" = "模拟错误文本"; +"preferences.debug.errorSimulation.title" = "错误模拟"; +"preferences.debug.fetchAttempts.available" = " 可用"; +"preferences.debug.fetchAttempts.caption" = "查看某个服务最近一次获取流水线中的决策与错误。"; +"preferences.debug.fetchAttempts.empty" = "暂无获取尝试。"; +"preferences.debug.fetchAttempts.error" = " 错误=%@"; +"preferences.debug.fetchAttempts.title" = "获取策略尝试"; +"preferences.debug.fetchAttempts.unavailable" = " 不可用"; +"preferences.debug.fetchKind.api" = "api"; +"preferences.debug.fetchKind.cli" = "cli"; +"preferences.debug.fetchKind.local" = "local"; +"preferences.debug.fetchKind.oauth" = "oauth"; +"preferences.debug.fetchKind.web" = "web"; +"preferences.debug.forceAnimation.subtitle" = "在下一次刷新后临时显示加载动画。"; +"preferences.debug.forceAnimation.title" = "下次刷新时强制播放动画"; +"preferences.debug.loading" = "加载中…"; +"preferences.debug.logging.enable.subtitle" = "将日志写入 %@ 以便调试。"; +"preferences.debug.logging.enable.title" = "启用文件日志"; +"preferences.debug.logging.openFile" = "打开日志文件"; +"preferences.debug.logging.title" = "日志"; +"preferences.debug.logging.verbosity" = "详细级别"; +"preferences.debug.logging.verbosity.subtitle" = "控制日志记录的详细程度。"; +"preferences.debug.notifications.caption" = "触发 5 小时会话窗口的测试通知(耗尽/恢复)。"; +"preferences.debug.notifications.depleted" = "发送耗尽通知"; +"preferences.debug.notifications.restored" = "发送恢复通知"; +"preferences.debug.notifications.title" = "通知"; +"preferences.debug.openAICookies.caption" = "最近一次 OpenAI Cookie 尝试的 Cookie 导入与 WebKit 抓取日志。"; +"preferences.debug.openAICookies.empty" = "还没有日志。在 Providers → Codex 中更新 OpenAI Cookies 以触发导入。"; +"preferences.debug.openAICookies.title" = "OpenAI Cookies"; +"preferences.debug.probeLogs.caption" = "获取最新探测输出用于调试;“复制”会保留完整文本。"; +"preferences.debug.probeLogs.fetch" = "获取日志"; +"preferences.debug.probeLogs.parseDump" = "加载解析转储"; +"preferences.debug.probeLogs.redetect" = "重新运行服务自动检测"; +"preferences.debug.probeLogs.save" = "保存到文件"; +"preferences.debug.probeLogs.title" = "探测日志"; +"preferences.debug.provider" = "服务"; +"preferences.general.costStatus.error" = "%@:%@"; +"preferences.general.costStatus.fetching" = "%@:获取中…%@"; +"preferences.general.costStatus.lastAttempt" = "%@:最近一次尝试 %@"; +"preferences.general.costStatus.noData" = "%@:暂无数据"; +"preferences.general.costStatus.snapshot" = "%@:%@ · 30 天 %@"; +"preferences.general.costStatus.unsupported" = "%@:不支持"; +"preferences.general.costSummary.status" = "自动刷新:每小时 · 超时:10 分钟"; +"preferences.general.costSummary.subtitle" = "读取本地用量日志,在菜单中显示今日和近 30 天成本。"; +"preferences.general.costSummary.title" = "显示成本摘要"; +"preferences.general.notifications.subtitle" = "当 5 小时会话额度降至 0%% 和恢复可用时发送通知。"; +"preferences.general.notifications.title" = "会话额度通知"; +"preferences.general.quit" = "退出 CodexBar"; +"preferences.general.refreshCadence.manualHint" = "自动刷新已关闭;请使用菜单中的“刷新”命令。"; +"preferences.general.refreshCadence.picker" = "刷新频率"; +"preferences.general.refreshCadence.subtitle" = "CodexBar 在后台轮询各服务的频率。"; +"preferences.general.refreshCadence.title" = "刷新频率"; +"preferences.general.section.automation" = "自动化"; +"preferences.general.section.system" = "系统"; +"preferences.general.section.usage" = "用量"; +"preferences.general.startAtLogin.subtitle" = "在你启动 Mac 时自动打开 CodexBar。"; +"preferences.general.startAtLogin.title" = "登录时启动"; +"preferences.general.statusChecks.subtitle" = "轮询 OpenAI/Claude 状态页以及 Gemini/Antigravity 的 Google Workspace 状态,并在图标和菜单中显示事件。"; +"preferences.general.statusChecks.title" = "检查服务状态"; +"preferences.providerDetail.fetchFailed" = "最近一次 %@ 获取失败:"; +"preferences.providerDetail.label.account" = "账户"; +"preferences.providerDetail.label.balance" = "余额"; +"preferences.providerDetail.label.plan" = "方案"; +"preferences.providerDetail.label.source" = "来源"; +"preferences.providerDetail.label.state" = "状态"; +"preferences.providerDetail.label.status" = "服务状态"; +"preferences.providerDetail.label.updated" = "更新时间"; +"preferences.providerDetail.label.version" = "版本"; +"preferences.providerDetail.placeholder.disabledNoRecentData" = "已禁用 — 无最近数据"; +"preferences.providerDetail.section.options" = "选项"; +"preferences.providerDetail.section.settings" = "设置"; +"preferences.providerDetail.section.usage" = "用量"; +"preferences.providerDetail.updated.notFetched" = "尚未获取"; +"preferences.providerDetail.updated.refreshing" = "刷新中"; +"preferences.providers.selectProvider" = "选择一个服务"; +"preferences.providers.sidebar.disabled" = "已禁用 — %@"; +"preferences.providers.sidebar.dragToReorder" = "拖动以重新排序"; +"preferences.providers.sidebar.reorder" = "重新排序"; +"preferences.providers.subtitle.lastFetchFailed" = "上次获取失败"; +"preferences.providers.subtitle.notFetched" = "尚未获取用量"; +"preferences.tab.about" = "关于"; +"preferences.tab.advanced" = "高级"; +"preferences.tab.debug" = "调试"; +"preferences.tab.display" = "显示"; +"preferences.tab.general" = "通用"; +"preferences.tab.providers" = "服务"; +"provider.label.sonnet" = "Sonnet"; +"provider.tokenAccounts.empty" = "暂无 Token 账户。"; +"provider.tokenAccounts.label" = "标签"; +"provider.tokenAccounts.openFile" = "打开 Token 文件"; +"provider.tokenAccounts.removeSelected" = "移除所选账户"; +"relative.justNow" = "刚刚"; +"sessionQuota.depleted.body" = "剩余 0%%。恢复可用时会再次通知你。"; +"sessionQuota.depleted.title" = "%@ 会话额度已耗尽"; +"sessionQuota.restored.body" = "会话额度已重新可用。"; +"sessionQuota.restored.title" = "%@ 会话额度已恢复"; +"status.login.requesting" = "正在请求登录…"; +"status.login.waitingBrowser" = "正在等待浏览器完成…"; +"tokenAccountSupport.apiTokens.title" = "API 令牌"; +"tokenAccountSupport.augment.subtitle" = "存储多个 Augment Cookie Header。"; +"tokenAccountSupport.claude.placeholder" = "粘贴 sessionKey 或 OAuth 令牌…"; +"tokenAccountSupport.claude.subtitle" = "存储 Claude 的 sessionKey Cookie 或 OAuth 访问令牌。"; +"tokenAccountSupport.cookieHeader.placeholder" = "Cookie:…"; +"tokenAccountSupport.cursor.subtitle" = "存储多个 Cursor Cookie Header。"; +"tokenAccountSupport.factory.subtitle" = "存储多个 Factory Cookie Header。"; +"tokenAccountSupport.minimax.subtitle" = "存储多个 MiniMax Cookie Header。"; +"tokenAccountSupport.ollama.subtitle" = "存储多个 Ollama Cookie Header。"; +"tokenAccountSupport.opencode.subtitle" = "存储多个 OpenCode Cookie Header。"; +"tokenAccountSupport.sessionTokens.title" = "会话令牌"; +"tokenAccountSupport.zai.placeholder" = "粘贴令牌…"; +"tokenAccountSupport.zai.subtitle" = "保存在 CodexBar 配置文件中。"; +"updateChannel.beta.description" = "接收稳定版发布以及 beta 预览版本。"; +"updateChannel.beta.title" = "测试版"; +"updateChannel.stable.description" = "仅接收稳定、可用于生产环境的正式版本。"; +"updateChannel.stable.title" = "稳定版"; +"usage.accessibility.remaining" = "剩余用量"; +"usage.accessibility.used" = "已用用量"; +"usage.percent.left" = "剩余"; +"usage.percent.used" = "已用"; +"usageFormatter.creditEvent.compact" = "%@ — %@:%@"; +"usageFormatter.creditEvent.summary" = "%@ · %@ · %@ Credits"; +"usageFormatter.credits.left" = "剩余 %@"; +"usageFormatter.reset.inDays" = "%d 天后"; +"usageFormatter.reset.inDaysHours" = "%d 天 %d 小时后"; +"usageFormatter.reset.inHours" = "%d 小时后"; +"usageFormatter.reset.inHoursMinutes" = "%d 小时 %d 分钟后"; +"usageFormatter.reset.inMinutes" = "%d 分钟后"; +"usageFormatter.reset.now" = "现在"; +"usageFormatter.reset.tomorrow" = "明天 %@"; +"usageFormatter.resetLine" = "重置:%@"; +"usageFormatter.updated.atTime" = "更新于 %@"; +"usageFormatter.updated.justNow" = "刚刚更新"; +"usageFormatter.updated.relative" = "更新于 %@"; +"usageFormatter.usageLine" = "%.0f%% %@"; +"usagePace.deficit" = "透支 %d%%"; +"usagePace.lastsUntilReset" = "可持续到重置"; +"usagePace.now" = "现在"; +"usagePace.onPace" = "节奏正常"; +"usagePace.reserve" = "预留 %d%%"; +"usagePace.runOutRisk" = "约 %d%% 的耗尽风险"; +"usagePace.runsOutIn" = "将在 %@ 后耗尽"; +"usagePace.runsOutNow" = "现在就会耗尽"; +"usagePace.summary" = "节奏:%@"; +"usagePace.summary.withRight" = "节奏:%@ · %@"; +"common.auto" = "自动"; +"common.cachedFormat" = "已缓存:%@ • %@"; +"common.manual" = "手动"; +"common.off" = "关闭"; +"menu.card.apiKeyLimitUnavailable" = "API Key 限额当前不可用"; +"menu.card.cliFallback" = "正在使用 CLI 回退"; +"menu.card.codeReview" = "代码审查"; +"menu.card.limit.detail" = "%@ / %@(剩余 %@)"; +"menu.card.limit.left" = "剩余 %@/%@"; +"menu.card.noApiKeyLimit" = "该 API Key 未设置限额"; +"menu.card.noUsageYet" = "暂无用量"; +"menu.card.notFetched" = "尚未获取"; +"menu.card.refreshing" = "刷新中..."; +"menu.creditsLine" = "积分:%@"; +"menu.lastSpend" = "最近消费:%@"; +"menu.tokenUsage.last30Days" = "最近 30 天:%@"; +"menu.tokenUsage.last30DaysWithTokens" = "最近 30 天:%@ · %@ tokens"; +"menu.tokenUsage.today" = "今天:%@"; +"menu.tokenUsage.todayWithTokens" = "今天:%@ · %@ tokens"; +"preferences.providers.menuBarMetric.automatic" = "自动"; +"preferences.providers.menuBarMetric.average" = "平均(%@ + %@)"; +"preferences.providers.menuBarMetric.primary" = "主指标(%@)"; +"preferences.providers.menuBarMetric.primaryApiKeyLimit" = "主指标(API Key 限额)"; +"preferences.providers.menuBarMetric.secondary" = "次指标(%@)"; +"preferences.providers.menuBarMetric.subtitle" = "选择由哪个窗口驱动菜单栏百分比。"; +"preferences.providers.menuBarMetric.title" = "菜单栏指标"; +"provider.claude.cookieSource.auto" = "自动导入浏览器 Cookie 以访问 Web API。"; +"provider.claude.cookieSource.manual" = "粘贴来自 claude.ai 请求的 Cookie Header。"; +"provider.claude.cookieSource.off" = "Claude Cookie 已禁用。"; +"provider.claude.cookieSource.subtitle" = "自动导入浏览器 Cookie 以访问 Web API。"; +"provider.claude.extraUsage" = "额外用量:%@ / %@"; +"provider.claude.keychainPrompt.always" = "始终允许提示"; +"provider.claude.keychainPrompt.disabled" = "高级设置中已禁用全局 Keychain 访问,因此此设置当前不会生效。"; +"provider.claude.keychainPrompt.never" = "从不提示"; +"provider.claude.keychainPrompt.onlyOnUserAction" = "仅用户操作时提示"; +"provider.claude.keychainPrompt.securityFrameworkOnly" = "仅适用于基于 Security.framework 的 OAuth Keychain 读取器。"; +"provider.claude.keychainPrompt.subtitle" = "当实验性读取模式关闭时,控制 Claude OAuth 的 Keychain 提示。选择“从不提示”可能导致 OAuth 不可用;需要时请改用 Web/CLI。"; +"provider.claude.keychainPrompt.title" = "Keychain 提示策略"; +"provider.claude.weeklyUnavailable" = "此账户暂不提供每周用量。"; +"provider.codex.cookieSource.auto" = "自动导入浏览器 Cookie 以启用仪表盘扩展信息。"; +"provider.codex.cookieSource.manual" = "粘贴来自 chatgpt.com 请求的 Cookie Header。"; +"provider.codex.cookieSource.off" = "禁用 OpenAI 仪表盘 Cookie。"; +"provider.codex.cookieSource.subtitle" = "自动导入浏览器 Cookie 以启用仪表盘扩展信息。"; +"provider.codex.cookieSource.title" = "OpenAI Cookie"; +"provider.codex.historicalTracking.subtitle" = "在本地存储 Codex 用量历史(8 周),用于个性化 Pace 预测。"; +"provider.codex.historicalTracking.title" = "历史追踪"; +"provider.codex.openAIWebExtras.subtitle" = "通过 chatgpt.com 显示用量分解、积分历史和代码审查。"; +"provider.codex.openAIWebExtras.title" = "OpenAI Web 扩展"; +"provider.cookieSource.auto.generic" = "自动导入浏览器 Cookie。"; +"provider.cookieSource.keychainDisabledPrefix" = "高级设置中已禁用 Keychain 访问,因此浏览器 Cookie 导入不可用。"; +"provider.cursor.cookieSource.auto" = "自动导入浏览器 Cookie 或已存储的会话。"; +"provider.cursor.cookieSource.manual" = "粘贴来自 cursor.com 请求的 Cookie Header。"; +"provider.cursor.cookieSource.off" = "Cursor Cookie 已禁用。"; +"provider.cursor.cookieSource.subtitle" = "自动导入浏览器 Cookie 或已存储的会话。"; +"provider.cursor.onDemand" = "按需:%@"; +"provider.cursor.onDemandWithLimit" = "按需:%@ / %@"; +"provider.jetbrains.autoDetect" = "自动检测"; +"provider.jetbrains.customPath.subtitle" = "使用自定义 IDE 基础路径覆盖自动检测。"; +"provider.jetbrains.ide.subtitle" = "选择要监控的 IDE"; +"provider.jetbrains.ide.title" = "JetBrains IDE"; +"provider.jetbrains.login.customPath" = "或者在设置中指定自定义路径。"; +"provider.jetbrains.login.detectedWithQuota" = "已检测到:%@。请在设置中选择首选 IDE,然后刷新 CodexBar。"; +"provider.jetbrains.login.detectedWithoutQuota" = "已检测到:%@。请先使用一次 AI Assistant 生成配额数据,然后刷新 CodexBar。"; +"provider.jetbrains.login.installIDE" = "安装并启用 AI Assistant 的 JetBrains IDE,然后刷新 CodexBar。"; +"provider.jetbrains.login.noneDetectedTitle" = "未检测到 JetBrains IDE"; +"provider.jetbrains.login.readyTitle" = "JetBrains AI 已就绪"; +"provider.kilo.apiKey.subtitle" = "保存在 ~/.codexbar/config.json。你也可以提供 KILO_API_KEY 或 ~/.local/share/kilo/auth.json(kilo.access)。"; +"provider.kilo.usageSource.subtitle" = "自动模式会优先使用 API,认证失败时再回退到 CLI。"; +"provider.label.addOnCredits" = "附加积分"; +"provider.label.ampFree" = "Amp 免费版"; +"provider.label.balance" = "余额"; +"provider.label.bonus" = "奖励"; +"provider.label.chat" = "聊天"; +"provider.label.credits" = "积分"; +"provider.label.current" = "当前"; +"provider.label.fiveHour" = "5 小时"; +"provider.label.flash" = "Flash"; +"provider.label.flashLite" = "Flash Lite"; +"provider.label.kiloPass" = "Kilo Pass"; +"provider.label.mcp" = "MCP"; +"provider.label.monthly" = "每月"; +"provider.label.onDemand" = "按需"; +"provider.label.plan" = "套餐"; +"provider.label.premium" = "高级"; +"provider.label.pro" = "Pro"; +"provider.label.prompts" = "提示"; +"provider.label.quota" = "配额"; +"provider.label.rateLimit" = "速率限制"; +"provider.label.refill" = "补充"; +"provider.label.requests" = "请求"; +"provider.label.session" = "会话"; +"provider.label.standard" = "标准版"; +"provider.label.tokens" = "Token"; +"provider.label.usage" = "用量"; +"provider.label.weekly" = "每周"; +"provider.label.window" = "窗口"; +"provider.minimax.apiRegion.subtitle" = "选择 MiniMax 主机(全球 .io 或中国大陆 .com)。"; +"provider.minimax.apiToken.subtitle" = "保存在 ~/.codexbar/config.json。粘贴你的 MiniMax API Key。"; +"provider.minimax.cookieSource.auto" = "自动导入浏览器 Cookie 和本地存储令牌。"; +"provider.minimax.cookieSource.manual" = "粘贴来自 Coding Plan 页面的 Cookie Header 或 cURL 抓包。"; +"provider.minimax.cookieSource.off" = "MiniMax Cookie 已禁用。"; +"provider.minimax.cookieSource.subtitle" = "自动导入浏览器 Cookie 和本地存储令牌。"; +"provider.ollama.cookieSource.manual" = "粘贴来自 Ollama 设置页的 Cookie Header 或 cURL 抓包。"; +"provider.ollama.cookieSource.off" = "Ollama Cookie 已禁用。"; +"provider.placeholder.apiKey" = "粘贴 API Key…"; +"provider.placeholder.apiToken" = "粘贴 API Token…"; +"provider.placeholder.cookieHeader" = "Cookie: …"; +"provider.placeholder.cookieHeader.ascii" = "Cookie: ..."; +"provider.placeholder.key" = "粘贴密钥…"; +"provider.region.alibaba.chinaMainland" = "中国大陆(%@)"; +"provider.region.alibaba.international" = "国际站(%@)"; +"provider.region.minimax.chinaMainland" = "中国大陆(%@)"; +"provider.region.minimax.global" = "全球(%@)"; +"provider.region.zai.bigmodelCN" = "BigModel 中国站(%@)"; +"provider.region.zai.global" = "全球(%@)"; +"provider.settings.apiKey.title" = "API Key"; +"provider.settings.apiRegion.title" = "API 区域"; +"provider.settings.apiToken.title" = "API Token"; +"provider.settings.cookieHeader.title" = "Cookie Header"; +"provider.settings.cookieSource.title" = "Cookie 来源"; +"provider.settings.customPath.title" = "自定义路径"; +"provider.settings.gatewayRegion.title" = "网关区域"; +"provider.settings.openApiKeys" = "打开 API Keys"; +"provider.settings.openCodingPlan" = "打开 Coding Plan"; +"provider.settings.openOllamaSettings" = "打开 Ollama 设置"; +"provider.settings.usageSource.subtitle" = "自动模式会在首选来源失败时回退到下一个来源。"; +"provider.settings.usageSource.title" = "用量来源"; +"provider.source.api" = "API"; +"provider.source.auto" = "自动"; +"provider.source.cli" = "CLI"; +"provider.source.codexCLI" = "Codex CLI"; +"provider.source.local" = "本地"; +"provider.source.manual" = "手动"; +"provider.source.manualCookieHeader" = "手动 Cookie Header"; +"provider.source.off" = "关闭"; +"provider.source.oauth" = "OAuth"; +"provider.source.openAIWeb" = "OpenAI Web"; +"provider.source.web" = "Web"; +"provider.sourceOption.api" = "API"; +"provider.sourceOption.cli" = "CLI"; +"provider.sourceOption.cliPTY" = "CLI(PTY)"; +"provider.sourceOption.cliRPCPTY" = "CLI(RPC/PTY)"; +"provider.sourceOption.oauthAPI" = "OAuth API"; +"provider.sourceOption.webAPICookies" = "Web API(Cookie)"; +"provider.synthetic.apiKey.subtitle" = "保存在 ~/.codexbar/config.json。粘贴来自 Synthetic 仪表盘的密钥。"; +"provider.warp.apiKey.subtitle" = "保存在 ~/.codexbar/config.json。在 Warp 中打开 Settings > Platform > API Keys,然后创建一个。"; +"provider.warp.openGuide" = "打开 Warp API Key 指南"; +"providerError.copy" = "复制错误"; +"providerError.hideDetails" = "隐藏详情"; +"providerError.showDetails" = "显示详情"; +"providerCost.extraUsage" = "额外用量"; +"providerCost.quotaUsage" = "配额用量"; +"providerCost.thisMonth" = "本月"; +"widget.label.codeReview" = "代码审查"; +"widget.label.credits" = "积分"; +"widget.label.last30Days" = "30 天"; +"widget.label.today" = "今天"; +"widget.metric.tokens" = "%@ tokens"; +"provider.amp.cookieSource.manual" = "粘贴来自 Amp 设置页的 Cookie Header 或 cURL 抓包。"; +"provider.amp.cookieSource.off" = "Amp Cookie 已禁用。"; +"provider.amp.openSettings" = "打开 Amp 设置"; +"provider.claude.promptFree.inactive" = "当高级设置中的“禁用 Keychain 访问”启用时,此项不会生效。"; +"provider.claude.promptFree.subtitle" = "使用 /usr/bin/security 读取 Claude 凭据,以避免 CodexBar 的 Keychain 提示。"; +"provider.claude.promptFree.title" = "避免 Keychain 提示(实验性)"; +"provider.copilot.login.placeholder" = "请使用下方按钮登录"; +"provider.copilot.login.signIn" = "使用 GitHub 登录"; +"provider.copilot.login.signInAgain" = "重新登录"; +"provider.copilot.login.subtitle" = "需要通过 GitHub Device Flow 完成认证。"; +"provider.copilot.login.title" = "GitHub 登录"; +"provider.factory.cookieSource.auto" = "自动导入浏览器 Cookie 和 WorkOS 令牌。"; +"provider.factory.cookieSource.manual" = "粘贴来自 app.factory.ai 的 Cookie Header。"; +"provider.factory.cookieSource.off" = "Factory Cookie 已禁用。"; +"provider.factory.cookieSource.subtitle" = "自动导入浏览器 Cookie 和 WorkOS 令牌。"; +"provider.kimi.cookiePlaceholder" = "Cookie: …\n\n或粘贴 kimi-auth token 值"; +"provider.kimi.cookieSource.manual" = "粘贴 Cookie Header 或 kimi-auth token 值。"; +"provider.kimi.cookieSource.off" = "Kimi Cookie 已禁用。"; +"provider.kimi.openConsole" = "打开控制台"; +"provider.message.augmentCredits" = "用于 AI 编码辅助的 Augment Code 积分。"; +"provider.message.claudeNoLogs" = "在 %@ 或 %@ 中未找到 Claude 用量日志。"; +"provider.message.codexCreditsUnavailable" = "积分暂不可用;保持 Codex 运行后可刷新。"; +"provider.message.codexNoSessions" = "在 %@ 或 %@ 中未找到 Codex 会话。"; +"provider.message.costSummaryNotYetSupported" = "%@ 的成本汇总暂未支持。"; +"provider.message.costSummaryUnavailable" = "%@ 的成本汇总暂不可用。"; +"provider.message.costSummaryUnsupported" = "暂不支持 %@ 的成本汇总。"; +"provider.message.cursorOnDemand" = "按需用量会在套餐包含额度之外单独计费。"; +"provider.message.openRouterCreditBalance" = "来自 OpenRouter API 的积分余额"; +"provider.message.vertexAINoCostData" = "在 Claude 日志中未找到 Vertex AI 成本数据。请确认条目包含 Vertex 元数据。"; +"provider.opencode.cookieSource.auto" = "自动从 opencode.ai 导入浏览器 Cookie。"; +"provider.opencode.cookieSource.manual" = "粘贴从账单页面抓取的 Cookie Header。"; +"provider.opencode.cookieSource.off" = "OpenCode Cookie 已禁用。"; +"provider.opencode.cookieSource.subtitle" = "自动从 opencode.ai 导入浏览器 Cookie。"; +"provider.opencode.workspaceID.subtitle" = "当工作区查找失败时可手动覆盖。"; +"provider.opencode.workspaceID.title" = "工作区 ID"; +"provider.openrouter.apiKey.subtitle" = "保存在 ~/.codexbar/config.json。请在 openrouter.ai/settings/keys 获取密钥,并在那里设置 Key 的消费限额,以启用 API Key 配额追踪。"; +"provider.zai.apiRegion.subtitle" = "中国大陆端点请使用 BigModel(open.bigmodel.cn)。"; +"provider.toggleTitle" = "显示 %@ 用量"; +"provider.toggleTitle.experimental" = "显示 %@ 用量(实验性)"; +"status.switcher.overview" = "概览"; +"widget.bundle.history.description" = "带近期汇总的用量历史图表。"; +"widget.bundle.history.title" = "CodexBar 历史"; +"widget.bundle.metric.description" = "显示积分或成本的紧凑小组件。"; +"widget.bundle.metric.title" = "CodexBar 指标"; +"widget.bundle.switcher.description" = "带 provider 切换器的用量小组件。"; +"widget.bundle.switcher.title" = "CodexBar 切换器"; +"widget.bundle.usage.description" = "显示会话和每周用量,以及积分和成本。"; +"widget.bundle.usage.title" = "CodexBar 用量"; +"login.cursor.noSessionCookies" = "未找到会话 Cookie"; +"login.cursor.windowTitle" = "Cursor 登录"; +"menu.action.refreshSession" = "刷新会话"; +"provider.alibaba.apiKey.subtitle" = "保存在 ~/.codexbar/config.json。粘贴你在 Model Studio 中的 Coding Plan API Key。"; +"provider.alibaba.cookieSource.auto" = "自动从 Model Studio/Bailian 导入浏览器 Cookie。"; +"provider.alibaba.cookieSource.manual" = "粘贴来自 modelstudio.console.alibabacloud.com 的 Cookie Header。"; +"provider.alibaba.cookieSource.off" = "Alibaba Cookie 已禁用。"; +"provider.alibaba.cookieSource.subtitle" = "自动从 Model Studio/Bailian 导入浏览器 Cookie。"; +"provider.alibaba.gatewayRegion.subtitle" = "为配额获取选择国际站或中国大陆控制台网关。"; +"provider.augment.cookieSource.manual" = "粘贴来自 Augment 仪表盘的 Cookie Header 或 cURL 抓包。"; +"provider.augment.cookieSource.off" = "Augment Cookie 已禁用。"; +"provider.augment.openAndRelogin" = "打开 Augment(退出后重新登录)"; +"provider.kimik2.apiKey.subtitle" = "保存在 ~/.codexbar/config.json。可在 kimi-k2.ai 生成。"; diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 2a1d0f1d4..3fc2d47d7 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -3,58 +3,58 @@ import Foundation extension TokenAccountSupportCatalog { static let supportByProvider: [UsageProvider: TokenAccountSupport] = [ .claude: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store Claude sessionKey cookies or OAuth access tokens.", - placeholder: "Paste sessionKey or OAuth token…", + title: L10n.tr("tokenAccountSupport.sessionTokens.title", default: "Session tokens"), + subtitle: L10n.tr("tokenAccountSupport.claude.subtitle", default: "Store Claude sessionKey cookies or OAuth access tokens."), + placeholder: L10n.tr("tokenAccountSupport.claude.placeholder", default: "Paste sessionKey or OAuth token…"), injection: .cookieHeader, requiresManualCookieSource: true, cookieName: "sessionKey"), .zai: TokenAccountSupport( - title: "API tokens", - subtitle: "Stored in the CodexBar config file.", - placeholder: "Paste token…", + title: L10n.tr("tokenAccountSupport.apiTokens.title", default: "API tokens"), + subtitle: L10n.tr("tokenAccountSupport.zai.subtitle", default: "Stored in the CodexBar config file."), + placeholder: L10n.tr("tokenAccountSupport.zai.placeholder", default: "Paste token…"), injection: .environment(key: ZaiSettingsReader.apiTokenKey), requiresManualCookieSource: false, cookieName: nil), .cursor: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple Cursor Cookie headers.", - placeholder: "Cookie: …", + title: L10n.tr("tokenAccountSupport.sessionTokens.title", default: "Session tokens"), + subtitle: L10n.tr("tokenAccountSupport.cursor.subtitle", default: "Store multiple Cursor Cookie headers."), + placeholder: L10n.tr("tokenAccountSupport.cookieHeader.placeholder", default: "Cookie: …"), injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), .opencode: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple OpenCode Cookie headers.", - placeholder: "Cookie: …", + title: L10n.tr("tokenAccountSupport.sessionTokens.title", default: "Session tokens"), + subtitle: L10n.tr("tokenAccountSupport.opencode.subtitle", default: "Store multiple OpenCode Cookie headers."), + placeholder: L10n.tr("tokenAccountSupport.cookieHeader.placeholder", default: "Cookie: …"), injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), .factory: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple Factory Cookie headers.", - placeholder: "Cookie: …", + title: L10n.tr("tokenAccountSupport.sessionTokens.title", default: "Session tokens"), + subtitle: L10n.tr("tokenAccountSupport.factory.subtitle", default: "Store multiple Factory Cookie headers."), + placeholder: L10n.tr("tokenAccountSupport.cookieHeader.placeholder", default: "Cookie: …"), injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), .minimax: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple MiniMax Cookie headers.", - placeholder: "Cookie: …", + title: L10n.tr("tokenAccountSupport.sessionTokens.title", default: "Session tokens"), + subtitle: L10n.tr("tokenAccountSupport.minimax.subtitle", default: "Store multiple MiniMax Cookie headers."), + placeholder: L10n.tr("tokenAccountSupport.cookieHeader.placeholder", default: "Cookie: …"), injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), .augment: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple Augment Cookie headers.", - placeholder: "Cookie: …", + title: L10n.tr("tokenAccountSupport.sessionTokens.title", default: "Session tokens"), + subtitle: L10n.tr("tokenAccountSupport.augment.subtitle", default: "Store multiple Augment Cookie headers."), + placeholder: L10n.tr("tokenAccountSupport.cookieHeader.placeholder", default: "Cookie: …"), injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), .ollama: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple Ollama Cookie headers.", - placeholder: "Cookie: …", + title: L10n.tr("tokenAccountSupport.sessionTokens.title", default: "Session tokens"), + subtitle: L10n.tr("tokenAccountSupport.ollama.subtitle", default: "Store multiple Ollama Cookie headers."), + placeholder: L10n.tr("tokenAccountSupport.cookieHeader.placeholder", default: "Cookie: …"), injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 85c011d6d..70e83d03f 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -9,13 +9,15 @@ public enum UsageFormatter { public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { let percent = showUsed ? used : remaining let clamped = min(100, max(0, percent)) - let suffix = showUsed ? "used" : "left" - return String(format: "%.0f%% %@", clamped, suffix) + let suffix = showUsed + ? L10n.tr("usage.percent.used", default: "used") + : L10n.tr("usage.percent.left", default: "left") + return L10n.tr("usageFormatter.usageLine", default: "%.0f%% %@", clamped, suffix) } public static func resetCountdownDescription(from date: Date, now: Date = .init()) -> String { let seconds = max(0, date.timeIntervalSince(now)) - if seconds < 1 { return "now" } + if seconds < 1 { return L10n.tr("usageFormatter.reset.now", default: "now") } let totalMinutes = max(1, Int(ceil(seconds / 60.0))) let days = totalMinutes / (24 * 60) @@ -23,14 +25,18 @@ public enum UsageFormatter { let minutes = totalMinutes % 60 if days > 0 { - if hours > 0 { return "in \(days)d \(hours)h" } - return "in \(days)d" + if hours > 0 { + return L10n.tr("usageFormatter.reset.inDaysHours", default: "in %dd %dh", days, hours) + } + return L10n.tr("usageFormatter.reset.inDays", default: "in %dd", days) } if hours > 0 { - if minutes > 0 { return "in \(hours)h \(minutes)m" } - return "in \(hours)h" + if minutes > 0 { + return L10n.tr("usageFormatter.reset.inHoursMinutes", default: "in %dh %dm", hours, minutes) + } + return L10n.tr("usageFormatter.reset.inHours", default: "in %dh", hours) } - return "in \(totalMinutes)m" + return L10n.tr("usageFormatter.reset.inMinutes", default: "in %dm", totalMinutes) } public static func resetDescription(from date: Date, now: Date = .init()) -> String { @@ -42,7 +48,10 @@ public enum UsageFormatter { if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(date, inSameDayAs: tomorrow) { - return "tomorrow, \(date.formatted(date: .omitted, time: .shortened))" + return L10n.tr( + "usageFormatter.reset.tomorrow", + default: "tomorrow, %@", + date.formatted(date: .omitted, time: .shortened)) } return date.formatted(date: .abbreviated, time: .shortened) } @@ -56,7 +65,7 @@ public enum UsageFormatter { let text = style == .countdown ? self.resetCountdownDescription(from: date, now: now) : self.resetDescription(from: date, now: now) - return "Resets \(text)" + return L10n.tr("usageFormatter.resetLine", default: "Resets %@", text) } if let desc = window.resetDescription { @@ -71,24 +80,30 @@ public enum UsageFormatter { public static func updatedString(from date: Date, now: Date = .init()) -> String { let delta = now.timeIntervalSince(date) if abs(delta) < 60 { - return "Updated just now" + return L10n.tr("usageFormatter.updated.justNow", default: "Updated just now") } if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 { #if os(macOS) let rel = RelativeDateTimeFormatter() rel.unitsStyle = .abbreviated - return "Updated \(rel.localizedString(for: date, relativeTo: now))" + return L10n.tr( + "usageFormatter.updated.relative", + default: "Updated %@", + rel.localizedString(for: date, relativeTo: now)) #else let seconds = max(0, Int(now.timeIntervalSince(date))) if seconds < 3600 { let minutes = max(1, seconds / 60) - return "Updated \(minutes)m ago" + return L10n.tr("usageFormatter.updated.relative", default: "Updated %@", "\(minutes)m ago") } let wholeHours = max(1, seconds / 3600) - return "Updated \(wholeHours)h ago" + return L10n.tr("usageFormatter.updated.relative", default: "Updated %@", "\(wholeHours)h ago") #endif } else { - return "Updated \(date.formatted(date: .omitted, time: .shortened))" + return L10n.tr( + "usageFormatter.updated.atTime", + default: "Updated %@", + date.formatted(date: .omitted, time: .shortened)) } } @@ -99,7 +114,7 @@ public enum UsageFormatter { // Use explicit locale for consistent formatting on all systems number.locale = Locale(identifier: "en_US_POSIX") let formatted = number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) - return "\(formatted) left" + return L10n.tr("usageFormatter.credits.left", default: "%@ left", formatted) } /// Formats a USD value with proper negative handling and thousand separators. @@ -148,21 +163,33 @@ public enum UsageFormatter { public static func creditEventSummary(_ event: CreditEvent) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium + formatter.locale = .current let number = NumberFormatter() number.numberStyle = .decimal number.maximumFractionDigits = 2 let credits = number.string(from: NSNumber(value: event.creditsUsed)) ?? "0" - return "\(formatter.string(from: event.date)) · \(event.service) · \(credits) credits" + return L10n.tr( + "usageFormatter.creditEvent.summary", + default: "%@ · %@ · %@ credits", + formatter.string(from: event.date), + event.service, + credits) } public static func creditEventCompact(_ event: CreditEvent) -> String { let formatter = DateFormatter() formatter.dateFormat = "MMM d" + formatter.locale = .current let number = NumberFormatter() number.numberStyle = .decimal number.maximumFractionDigits = 2 let credits = number.string(from: NSNumber(value: event.creditsUsed)) ?? "0" - return "\(formatter.string(from: event.date)) — \(event.service): \(credits)" + return L10n.tr( + "usageFormatter.creditEvent.compact", + default: "%@ — %@: %@", + formatter.string(from: event.date), + event.service, + credits) } public static func creditShort(_ value: Double) -> String { diff --git a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift index 4b6505217..fae200135 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI import WidgetKit @@ -21,8 +22,12 @@ struct CodexBarSwitcherWidget: Widget { { entry in CodexBarSwitcherWidgetView(entry: entry) } - .configurationDisplayName("CodexBar Switcher") - .description("Usage widget with a provider switcher.") + .configurationDisplayName(WidgetStrings.string(LocalizationEntry( + "widget.bundle.switcher.title", + fallback: "CodexBar Switcher"))) + .description(WidgetStrings.string(LocalizationEntry( + "widget.bundle.switcher.description", + fallback: "Usage widget with a provider switcher."))) .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } @@ -38,8 +43,12 @@ struct CodexBarUsageWidget: Widget { { entry in CodexBarUsageWidgetView(entry: entry) } - .configurationDisplayName("CodexBar Usage") - .description("Session and weekly usage with credits and costs.") + .configurationDisplayName(WidgetStrings.string(LocalizationEntry( + "widget.bundle.usage.title", + fallback: "CodexBar Usage"))) + .description(WidgetStrings.string(LocalizationEntry( + "widget.bundle.usage.description", + fallback: "Session and weekly usage with credits and costs."))) .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } @@ -55,8 +64,12 @@ struct CodexBarHistoryWidget: Widget { { entry in CodexBarHistoryWidgetView(entry: entry) } - .configurationDisplayName("CodexBar History") - .description("Usage history chart with recent totals.") + .configurationDisplayName(WidgetStrings.string(LocalizationEntry( + "widget.bundle.history.title", + fallback: "CodexBar History"))) + .description(WidgetStrings.string(LocalizationEntry( + "widget.bundle.history.description", + fallback: "Usage history chart with recent totals."))) .supportedFamilies([.systemMedium, .systemLarge]) } } @@ -72,8 +85,12 @@ struct CodexBarCompactWidget: Widget { { entry in CodexBarCompactWidgetView(entry: entry) } - .configurationDisplayName("CodexBar Metric") - .description("Compact widget for credits or cost.") + .configurationDisplayName(WidgetStrings.string(LocalizationEntry( + "widget.bundle.metric.title", + fallback: "CodexBar Metric"))) + .description(WidgetStrings.string(LocalizationEntry( + "widget.bundle.metric.description", + fallback: "Compact widget for credits or cost."))) .supportedFamilies([.systemSmall]) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 6d53a6284..c911042e7 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -91,7 +91,7 @@ enum CompactMetric: String, AppEnum { struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title = WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle) - static let description = IntentDescription(WidgetStrings.string(LocalizationCatalog.Widget.Intent.providerDescription)) + static let description = IntentDescription(stringLiteral: WidgetStrings.string(LocalizationCatalog.Widget.Intent.providerDescription)) @Parameter(title: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle)) var provider: ProviderChoice @@ -103,7 +103,7 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { struct SwitchWidgetProviderIntent: AppIntent { static let title = WidgetStrings.resource(LocalizationCatalog.Widget.Intent.switchProviderTitle) - static let description = IntentDescription(WidgetStrings.string(LocalizationCatalog.Widget.Intent.switchProviderDescription)) + static let description = IntentDescription(stringLiteral: WidgetStrings.string(LocalizationCatalog.Widget.Intent.switchProviderDescription)) @Parameter(title: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle)) var provider: ProviderChoice @@ -123,7 +123,7 @@ struct SwitchWidgetProviderIntent: AppIntent { struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title = WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerMetricTitle) - static let description = IntentDescription(WidgetStrings.string(LocalizationCatalog.Widget.Intent.providerMetricDescription)) + static let description = IntentDescription(stringLiteral: WidgetStrings.string(LocalizationCatalog.Widget.Intent.providerMetricDescription)) @Parameter(title: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle)) var provider: ProviderChoice diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 3a548c3a8..464e09b49 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -290,16 +290,18 @@ private struct SwitcherSmallUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( - title: "Code review", + title: L10n.tr("widget.label.codeReview", default: "Code review"), percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } @@ -313,19 +315,21 @@ private struct SwitcherMediumUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) + ValueLine(title: L10n.tr("widget.label.credits", default: "Credits"), value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( - title: "Today", + title: L10n.tr("widget.label.today", default: "Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) } } @@ -338,29 +342,31 @@ private struct SwitcherLargeUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( - title: "Code review", + title: L10n.tr("widget.label.codeReview", default: "Code review"), percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) + ValueLine(title: L10n.tr("widget.label.credits", default: "Credits"), value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", + title: L10n.tr("widget.label.today", default: "Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) ValueLine( - title: "30d", + title: L10n.tr("widget.label.last30Days", default: "30d"), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) @@ -379,16 +385,18 @@ private struct SmallUsageView: View { VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( - title: "Code review", + title: L10n.tr("widget.label.codeReview", default: "Code review"), percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } @@ -404,19 +412,21 @@ private struct MediumUsageView: View { VStack(alignment: .leading, spacing: 10) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) + ValueLine(title: L10n.tr("widget.label.credits", default: "Credits"), value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( - title: "Today", + title: L10n.tr("widget.label.today", default: "Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) } } @@ -431,29 +441,31 @@ private struct LargeUsageView: View { VStack(alignment: .leading, spacing: 12) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session"), percentLeft: self.entry.primary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", + title: LocalizedProviderText.label( + ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly"), percentLeft: self.entry.secondary?.remainingPercent, color: WidgetColors.color(for: self.entry.provider)) if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( - title: "Code review", + title: L10n.tr("widget.label.codeReview", default: "Code review"), percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) + ValueLine(title: L10n.tr("widget.label.credits", default: "Credits"), value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", + title: L10n.tr("widget.label.today", default: "Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) ValueLine( - title: "30d", + title: L10n.tr("widget.label.last30Days", default: "30d"), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) @@ -477,10 +489,10 @@ private struct HistoryView: View { .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( - title: "Today", + title: L10n.tr("widget.label.today", default: "Today"), value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) ValueLine( - title: "30d", + title: L10n.tr("widget.label.last30Days", default: "30d"), value: WidgetFormat.costAndTokens(cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) } } @@ -661,7 +673,7 @@ enum WidgetFormat { formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 let raw = formatter.string(from: NSNumber(value: value)) ?? "\(value)" - return "\(raw) tokens" + return L10n.tr("widget.metric.tokens", default: "%@ tokens", raw) } static func relativeDate(_ date: Date) -> String { diff --git a/Sources/CodexBarWidget/WidgetStrings.swift b/Sources/CodexBarWidget/WidgetStrings.swift index d25dba2fe..49f087b61 100644 --- a/Sources/CodexBarWidget/WidgetStrings.swift +++ b/Sources/CodexBarWidget/WidgetStrings.swift @@ -2,7 +2,6 @@ import CodexBarCore import Foundation import SwiftUI -@MainActor enum WidgetStrings { static func string(_ entry: LocalizationEntry) -> String { NSLocalizedString(entry.key, tableName: nil, bundle: .module, value: entry.fallback, comment: "")