diff --git a/Package.swift b/Package.swift index ed0af2639..311f08fe2 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let sweetCookieKitDependency: Package.Dependency = let package = Package( name: "CodexBar", + defaultLocalization: "en", platforms: [ .macOS(.v14), ], @@ -98,6 +99,9 @@ let package = Package( name: "CodexBarWidget", dependencies: ["CodexBarCore"], path: "Sources/CodexBarWidget", + resources: [ + .process("Resources"), + ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), diff --git a/Sources/CodexBar/AppStrings.swift b/Sources/CodexBar/AppStrings.swift new file mode 100644 index 000000000..c309608c6 --- /dev/null +++ b/Sources/CodexBar/AppStrings.swift @@ -0,0 +1,18 @@ +import CodexBarCore +import Foundation +import SwiftUI + +@MainActor +enum AppStrings { + static func string(_ entry: LocalizationEntry) -> String { + NSLocalizedString(entry.key, tableName: nil, bundle: .module, value: entry.fallback, comment: "") + } + + static func text(_ entry: LocalizationEntry) -> Text { + Text(self.string(entry)) + } + + static func formatted(_ entry: LocalizationEntry, _ arguments: CVarArg...) -> String { + String(format: self.string(entry), locale: Locale.current, arguments: arguments) + } +} diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 04050b3bb..4fff19c2d 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -13,40 +13,40 @@ struct DisplayPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("Menu bar") + AppStrings.text(LocalizationCatalog.Preferences.Display.menuBarSection) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Merge Icons", - subtitle: "Use a single menu bar icon with a provider switcher.", + title: AppStrings.string(LocalizationCatalog.Preferences.Display.mergeIconsTitle), + subtitle: AppStrings.string(LocalizationCatalog.Preferences.Display.mergeIconsSubtitle), binding: self.$settings.mergeIcons) PreferenceToggleRow( - title: "Switcher shows icons", - subtitle: "Show provider icons in the switcher (otherwise show a weekly progress line).", + title: AppStrings.string(LocalizationCatalog.Preferences.Display.switcherShowsIconsTitle), + subtitle: AppStrings.string(LocalizationCatalog.Preferences.Display.switcherShowsIconsSubtitle), binding: self.$settings.switcherShowsIcons) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( - title: "Show most-used provider", - subtitle: "Menu bar auto-shows the provider closest to its rate limit.", + title: AppStrings.string(LocalizationCatalog.Preferences.Display.showMostUsedProviderTitle), + subtitle: AppStrings.string(LocalizationCatalog.Preferences.Display.showMostUsedProviderSubtitle), binding: self.$settings.menuBarShowsHighestUsage) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( - title: "Menu bar shows percent", - subtitle: "Replace critter bars with provider branding icons and a percentage.", + title: AppStrings.string(LocalizationCatalog.Preferences.Display.showPercentTitle), + subtitle: AppStrings.string(LocalizationCatalog.Preferences.Display.showPercentSubtitle), binding: self.$settings.menuBarShowsBrandIconWithPercent) HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Display mode") + AppStrings.text(LocalizationCatalog.Preferences.Display.modeTitle) .font(.body) - Text("Choose what to show in the menu bar (Pace shows usage vs. expected).") + AppStrings.text(LocalizationCatalog.Preferences.Display.modeSubtitle) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Display mode", selection: self.$settings.menuBarDisplayMode) { + Picker(AppStrings.string(LocalizationCatalog.Preferences.Display.modeTitle), selection: self.$settings.menuBarDisplayMode) { ForEach(MenuBarDisplayMode.allCases) { mode in Text(mode.label).tag(mode) } @@ -62,25 +62,25 @@ struct DisplayPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Menu content") + AppStrings.text(LocalizationCatalog.Preferences.Display.menuContentSection) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Show usage as used", - subtitle: "Progress bars fill as you consume quota (instead of showing remaining).", + title: AppStrings.string(LocalizationCatalog.Preferences.Display.usageAsUsedTitle), + subtitle: AppStrings.string(LocalizationCatalog.Preferences.Display.usageAsUsedSubtitle), binding: self.$settings.usageBarsShowUsed) PreferenceToggleRow( - title: "Show reset time as clock", - subtitle: "Display reset times as absolute clock values instead of countdowns.", + title: AppStrings.string(LocalizationCatalog.Preferences.Display.resetTimeClockTitle), + subtitle: AppStrings.string(LocalizationCatalog.Preferences.Display.resetTimeClockSubtitle), binding: self.$settings.resetTimesShowAbsolute) PreferenceToggleRow( - title: "Show credits + extra usage", - subtitle: "Show Codex Credits and Claude Extra usage sections in the menu.", + title: AppStrings.string(LocalizationCatalog.Preferences.Display.showCreditsExtraTitle), + subtitle: AppStrings.string(LocalizationCatalog.Preferences.Display.showCreditsExtraSubtitle), binding: self.$settings.showOptionalCreditsAndExtraUsage) PreferenceToggleRow( - title: "Show all token accounts", - subtitle: "Stack token accounts in the menu (otherwise show an account switcher bar).", + title: AppStrings.string(LocalizationCatalog.Preferences.Display.showAllTokenAccountsTitle), + subtitle: AppStrings.string(LocalizationCatalog.Preferences.Display.showAllTokenAccountsSubtitle), binding: self.$settings.showAllTokenAccountsInMenu) self.overviewProviderSelector } @@ -110,11 +110,11 @@ struct DisplayPane: View { private var overviewProviderSelector: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center, spacing: 12) { - Text("Overview tab providers") + AppStrings.text(LocalizationCatalog.Preferences.Display.overviewTitle) .font(.body) Spacer(minLength: 0) if self.showsOverviewConfigureButton { - Button("Configure…") { + Button(AppStrings.string(LocalizationCatalog.Preferences.Display.overviewConfigure)) { self.isOverviewProviderPopoverPresented = true } .offset(y: 1) @@ -125,11 +125,11 @@ struct DisplayPane: View { } if !self.settings.mergeIcons { - Text("Enable Merge Icons to configure Overview tab providers.") + AppStrings.text(LocalizationCatalog.Preferences.Display.overviewEnableMergeIcons) .font(.footnote) .foregroundStyle(.tertiary) } else if self.activeProvidersInOrder.isEmpty { - Text("No enabled providers available for Overview.") + AppStrings.text(LocalizationCatalog.Preferences.Display.overviewNoEnabledProviders) .font(.footnote) .foregroundStyle(.tertiary) } else { @@ -144,9 +144,9 @@ struct DisplayPane: View { private var overviewProviderPopover: some View { VStack(alignment: .leading, spacing: 10) { - Text("Choose up to \(Self.maxOverviewProviders) providers") + Text(AppStrings.formatted(LocalizationCatalog.Preferences.Display.overviewChooseLimitFormat, Self.maxOverviewProviders)) .font(.headline) - Text("Overview rows always follow provider order.") + AppStrings.text(LocalizationCatalog.Preferences.Display.overviewRowsFollowOrder) .font(.footnote) .foregroundStyle(.tertiary) @@ -191,7 +191,7 @@ struct DisplayPane: View { private var overviewProviderSelectionSummary: String { let selectedNames = self.overviewSelectedProviders.map(self.providerDisplayName) - guard !selectedNames.isEmpty else { return "No providers selected" } + guard !selectedNames.isEmpty else { return AppStrings.string(LocalizationCatalog.Preferences.Display.overviewNoneSelected) } return selectedNames.joined(separator: ", ") } diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..f4a47f70d --- /dev/null +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -0,0 +1,48 @@ +"menu.settings" = "Settings…"; +"preferences.general.title" = "General"; +"preferences.display.menu_bar.section" = "Menu bar"; +"preferences.display.merge_icons.title" = "Merge Icons"; +"preferences.display.merge_icons.subtitle" = "Use a single menu bar icon with a provider switcher."; +"preferences.display.switcher_shows_icons.title" = "Switcher shows icons"; +"preferences.display.switcher_shows_icons.subtitle" = "Show provider icons in the switcher (otherwise show a weekly progress line)."; +"preferences.display.show_most_used_provider.title" = "Show most-used provider"; +"preferences.display.show_most_used_provider.subtitle" = "Menu bar auto-shows the provider closest to its rate limit."; +"preferences.display.show_percent.title" = "Menu bar shows percent"; +"preferences.display.show_percent.subtitle" = "Replace critter bars with provider branding icons and a percentage."; +"preferences.display.mode.title" = "Display mode"; +"preferences.display.mode.subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; +"preferences.display.menu_content.section" = "Menu content"; +"preferences.display.usage_as_used.title" = "Show usage as used"; +"preferences.display.usage_as_used.subtitle" = "Progress bars fill as you consume quota (instead of showing remaining)."; +"preferences.display.reset_time_clock.title" = "Show reset time as clock"; +"preferences.display.reset_time_clock.subtitle" = "Display reset times as absolute clock values instead of countdowns."; +"preferences.display.show_credits_extra.title" = "Show credits + extra usage"; +"preferences.display.show_credits_extra.subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu."; +"preferences.display.show_all_token_accounts.title" = "Show all token accounts"; +"preferences.display.show_all_token_accounts.subtitle" = "Stack token accounts in the menu (otherwise show an account switcher bar)."; +"preferences.display.overview.title" = "Overview tab providers"; +"preferences.display.overview.configure" = "Configure…"; +"preferences.display.overview.enable_merge_icons" = "Enable Merge Icons to configure Overview tab providers."; +"preferences.display.overview.no_enabled_providers" = "No enabled providers available for Overview."; +"preferences.display.overview.none_selected" = "No providers selected"; +"preferences.display.overview.choose_limit" = "Choose up to %lld providers"; +"preferences.display.overview.rows_follow_order" = "Overview rows always follow provider order."; +"alert.login.codex_missing_cli.title" = "Codex CLI not found"; +"alert.login.codex_missing_cli.message" = "Install the Codex CLI (npm i -g @openai/codex) and try again."; +"alert.login.codex_launch_failed.title" = "Could not start codex login"; +"alert.login.codex_timed_out.title" = "Codex login timed out"; +"alert.login.codex_failed.title" = "Codex login failed"; +"widget.empty.open_app" = "Open CodexBar"; +"widget.empty.usage_refresh_hint" = "Usage data will appear once the app refreshes."; +"widget.empty.history_refresh_hint" = "Usage history will appear after a refresh."; +"widget.empty.switcher_refresh_hint" = "Usage data appears after a refresh."; +"widget.metric.credits_left" = "Credits left"; +"widget.metric.today_cost" = "Today cost"; +"widget.metric.last_30_days_cost" = "30d cost"; +"widget.intent.provider.title" = "Provider"; +"widget.intent.provider.description" = "Select the provider to display in the widget."; +"widget.intent.switch_provider.title" = "Switch Provider"; +"widget.intent.switch_provider.description" = "Switch the provider shown in the widget."; +"widget.intent.provider_metric.title" = "Provider + Metric"; +"widget.intent.provider_metric.description" = "Select the provider and metric to display."; +"widget.intent.metric.title" = "Metric"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..788f7ded2 --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,48 @@ +"menu.settings" = "设置…"; +"preferences.general.title" = "通用"; +"preferences.display.menu_bar.section" = "菜单栏"; +"preferences.display.merge_icons.title" = "合并图标"; +"preferences.display.merge_icons.subtitle" = "使用单个菜单栏图标,并提供服务切换器。"; +"preferences.display.switcher_shows_icons.title" = "切换器显示图标"; +"preferences.display.switcher_shows_icons.subtitle" = "在切换器中显示服务图标(否则显示每周进度线)。"; +"preferences.display.show_most_used_provider.title" = "显示使用最多的服务"; +"preferences.display.show_most_used_provider.subtitle" = "菜单栏会自动显示最接近速率限制的服务。"; +"preferences.display.show_percent.title" = "菜单栏显示百分比"; +"preferences.display.show_percent.subtitle" = "用服务品牌图标和百分比替换小动物进度条。"; +"preferences.display.mode.title" = "显示模式"; +"preferences.display.mode.subtitle" = "选择菜单栏显示的内容(Pace 表示使用量与预期的对比)。"; +"preferences.display.menu_content.section" = "菜单内容"; +"preferences.display.usage_as_used.title" = "按已用量显示使用情况"; +"preferences.display.usage_as_used.subtitle" = "进度条会随着配额消耗而填充(而不是显示剩余量)。"; +"preferences.display.reset_time_clock.title" = "将重置时间显示为时钟"; +"preferences.display.reset_time_clock.subtitle" = "将重置时间显示为绝对时刻,而不是倒计时。"; +"preferences.display.show_credits_extra.title" = "显示 Credits 与额外用量"; +"preferences.display.show_credits_extra.subtitle" = "在菜单中显示 Codex Credits 和 Claude Extra usage 区块。"; +"preferences.display.show_all_token_accounts.title" = "显示所有 Token 账户"; +"preferences.display.show_all_token_accounts.subtitle" = "在菜单中堆叠所有 Token 账户(否则显示账户切换条)。"; +"preferences.display.overview.title" = "概览标签页服务"; +"preferences.display.overview.configure" = "配置…"; +"preferences.display.overview.enable_merge_icons" = "启用 Merge Icons 后才能配置概览标签页服务。"; +"preferences.display.overview.no_enabled_providers" = "没有可用于概览的已启用服务。"; +"preferences.display.overview.none_selected" = "未选择任何服务"; +"preferences.display.overview.choose_limit" = "最多选择 %lld 个服务"; +"preferences.display.overview.rows_follow_order" = "概览行始终遵循服务顺序。"; +"alert.login.codex_missing_cli.title" = "未找到 Codex CLI"; +"alert.login.codex_missing_cli.message" = "请先安装 Codex CLI(npm i -g @openai/codex),然后重试。"; +"alert.login.codex_launch_failed.title" = "无法启动 codex login"; +"alert.login.codex_timed_out.title" = "Codex 登录超时"; +"alert.login.codex_failed.title" = "Codex 登录失败"; +"widget.empty.open_app" = "打开 CodexBar"; +"widget.empty.usage_refresh_hint" = "应用刷新后,这里会显示使用数据。"; +"widget.empty.history_refresh_hint" = "刷新后,这里会显示使用历史。"; +"widget.empty.switcher_refresh_hint" = "刷新后会显示使用数据。"; +"widget.metric.credits_left" = "剩余 Credits"; +"widget.metric.today_cost" = "今日成本"; +"widget.metric.last_30_days_cost" = "30 天成本"; +"widget.intent.provider.title" = "服务"; +"widget.intent.provider.description" = "选择要在小组件中显示的服务。"; +"widget.intent.switch_provider.title" = "切换服务"; +"widget.intent.switch_provider.description" = "切换小组件中显示的服务。"; +"widget.intent.provider_metric.title" = "服务 + 指标"; +"widget.intent.provider_metric.description" = "选择要显示的服务和指标。"; +"widget.intent.metric.title" = "指标"; diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index ad65b0879..86314de23 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -215,18 +215,18 @@ extension StatusItemController { return case .missingBinary: self.presentLoginAlert( - title: "Codex CLI not found", - message: "Install the Codex CLI (npm i -g @openai/codex) and try again.") + title: AppStrings.string(LocalizationCatalog.Alert.Login.codexMissingCLITitle), + message: AppStrings.string(LocalizationCatalog.Alert.Login.codexMissingCLIMessage)) case let .launchFailed(message): - self.presentLoginAlert(title: "Could not start codex login", message: message) + self.presentLoginAlert(title: AppStrings.string(LocalizationCatalog.Alert.Login.codexLaunchFailedTitle), message: message) case .timedOut: self.presentLoginAlert( - title: "Codex login timed out", + 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 message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) - self.presentLoginAlert(title: "Codex login failed", message: message) + self.presentLoginAlert(title: AppStrings.string(LocalizationCatalog.Alert.Login.codexFailedTitle), message: message) } } diff --git a/Sources/CodexBarCore/LocalizationCatalog.swift b/Sources/CodexBarCore/LocalizationCatalog.swift new file mode 100644 index 000000000..906c7206c --- /dev/null +++ b/Sources/CodexBarCore/LocalizationCatalog.swift @@ -0,0 +1,162 @@ +import Foundation + +public struct LocalizationEntry: Sendable, Hashable { + public let key: String + public let fallback: String + + public init(_ key: String, fallback: String) { + self.key = key + self.fallback = fallback + } +} + +public enum LocalizationCatalog { + public enum Menu { + public static let settings = LocalizationEntry("menu.settings", fallback: "Settings…") + } + + public enum Preferences { + public enum General { + public static let title = LocalizationEntry("preferences.general.title", fallback: "General") + } + + public enum Display { + public static let menuBarSection = LocalizationEntry("preferences.display.menu_bar.section", fallback: "Menu bar") + public static let mergeIconsTitle = LocalizationEntry("preferences.display.merge_icons.title", fallback: "Merge Icons") + public static let mergeIconsSubtitle = LocalizationEntry( + "preferences.display.merge_icons.subtitle", + fallback: "Use a single menu bar icon with a provider switcher.") + public static let switcherShowsIconsTitle = LocalizationEntry( + "preferences.display.switcher_shows_icons.title", + fallback: "Switcher shows icons") + public static let switcherShowsIconsSubtitle = LocalizationEntry( + "preferences.display.switcher_shows_icons.subtitle", + fallback: "Show provider icons in the switcher (otherwise show a weekly progress line).") + public static let showMostUsedProviderTitle = LocalizationEntry( + "preferences.display.show_most_used_provider.title", + fallback: "Show most-used provider") + public static let showMostUsedProviderSubtitle = LocalizationEntry( + "preferences.display.show_most_used_provider.subtitle", + fallback: "Menu bar auto-shows the provider closest to its rate limit.") + public static let showPercentTitle = LocalizationEntry( + "preferences.display.show_percent.title", + fallback: "Menu bar shows percent") + public static let showPercentSubtitle = LocalizationEntry( + "preferences.display.show_percent.subtitle", + fallback: "Replace critter bars with provider branding icons and a percentage.") + public static let modeTitle = LocalizationEntry("preferences.display.mode.title", fallback: "Display mode") + public static let modeSubtitle = LocalizationEntry( + "preferences.display.mode.subtitle", + fallback: "Choose what to show in the menu bar (Pace shows usage vs. expected).") + public static let menuContentSection = LocalizationEntry( + "preferences.display.menu_content.section", + fallback: "Menu content") + public static let usageAsUsedTitle = LocalizationEntry( + "preferences.display.usage_as_used.title", + fallback: "Show usage as used") + public static let usageAsUsedSubtitle = LocalizationEntry( + "preferences.display.usage_as_used.subtitle", + fallback: "Progress bars fill as you consume quota (instead of showing remaining).") + public static let resetTimeClockTitle = LocalizationEntry( + "preferences.display.reset_time_clock.title", + fallback: "Show reset time as clock") + public static let resetTimeClockSubtitle = LocalizationEntry( + "preferences.display.reset_time_clock.subtitle", + fallback: "Display reset times as absolute clock values instead of countdowns.") + public static let showCreditsExtraTitle = LocalizationEntry( + "preferences.display.show_credits_extra.title", + fallback: "Show credits + extra usage") + public static let showCreditsExtraSubtitle = LocalizationEntry( + "preferences.display.show_credits_extra.subtitle", + fallback: "Show Codex Credits and Claude Extra usage sections in the menu.") + public static let showAllTokenAccountsTitle = LocalizationEntry( + "preferences.display.show_all_token_accounts.title", + fallback: "Show all token accounts") + public static let showAllTokenAccountsSubtitle = LocalizationEntry( + "preferences.display.show_all_token_accounts.subtitle", + fallback: "Stack token accounts in the menu (otherwise show an account switcher bar).") + public static let overviewTitle = LocalizationEntry( + "preferences.display.overview.title", + fallback: "Overview tab providers") + public static let overviewConfigure = LocalizationEntry( + "preferences.display.overview.configure", + fallback: "Configure…") + public static let overviewEnableMergeIcons = LocalizationEntry( + "preferences.display.overview.enable_merge_icons", + fallback: "Enable Merge Icons to configure Overview tab providers.") + public static let overviewNoEnabledProviders = LocalizationEntry( + "preferences.display.overview.no_enabled_providers", + fallback: "No enabled providers available for Overview.") + public static let overviewNoneSelected = LocalizationEntry( + "preferences.display.overview.none_selected", + fallback: "No providers selected") + public static let overviewChooseLimitFormat = LocalizationEntry( + "preferences.display.overview.choose_limit", + fallback: "Choose up to %lld providers") + public static let overviewRowsFollowOrder = LocalizationEntry( + "preferences.display.overview.rows_follow_order", + fallback: "Overview rows always follow provider order.") + } + } + + public enum Alert { + public enum Login { + public static let codexMissingCLITitle = LocalizationEntry( + "alert.login.codex_missing_cli.title", + fallback: "Codex CLI not found") + public static let codexMissingCLIMessage = LocalizationEntry( + "alert.login.codex_missing_cli.message", + fallback: "Install the Codex CLI (npm i -g @openai/codex) and try again.") + public static let codexLaunchFailedTitle = LocalizationEntry( + "alert.login.codex_launch_failed.title", + fallback: "Could not start codex login") + public static let codexTimedOutTitle = LocalizationEntry( + "alert.login.codex_timed_out.title", + fallback: "Codex login timed out") + public static let codexFailedTitle = LocalizationEntry( + "alert.login.codex_failed.title", + fallback: "Codex login failed") + } + } + + public enum Widget { + public enum Empty { + public static let openApp = LocalizationEntry("widget.empty.open_app", fallback: "Open CodexBar") + public static let usageRefreshHint = LocalizationEntry( + "widget.empty.usage_refresh_hint", + fallback: "Usage data will appear once the app refreshes.") + public static let historyRefreshHint = LocalizationEntry( + "widget.empty.history_refresh_hint", + fallback: "Usage history will appear after a refresh.") + public static let switcherRefreshHint = LocalizationEntry( + "widget.empty.switcher_refresh_hint", + fallback: "Usage data appears after a refresh.") + } + + public enum Metric { + public static let creditsLeft = LocalizationEntry("widget.metric.credits_left", fallback: "Credits left") + public static let todayCost = LocalizationEntry("widget.metric.today_cost", fallback: "Today cost") + public static let last30DaysCost = LocalizationEntry("widget.metric.last_30_days_cost", fallback: "30d cost") + } + + public enum Intent { + public static let providerTitle = LocalizationEntry("widget.intent.provider.title", fallback: "Provider") + public static let providerDescription = LocalizationEntry( + "widget.intent.provider.description", + fallback: "Select the provider to display in the widget.") + public static let switchProviderTitle = LocalizationEntry( + "widget.intent.switch_provider.title", + fallback: "Switch Provider") + public static let switchProviderDescription = LocalizationEntry( + "widget.intent.switch_provider.description", + fallback: "Switch the provider shown in the widget.") + public static let providerMetricTitle = LocalizationEntry( + "widget.intent.provider_metric.title", + fallback: "Provider + Metric") + public static let providerMetricDescription = LocalizationEntry( + "widget.intent.provider_metric.description", + fallback: "Select the provider and metric to display.") + public static let metricTitle = LocalizationEntry("widget.intent.metric.title", fallback: "Metric") + } + } +} diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index c828a2695..6d53a6284 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -15,7 +15,7 @@ enum ProviderChoice: String, AppEnum { case kilo case opencode - static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle)) static let caseDisplayRepresentations: [ProviderChoice: DisplayRepresentation] = [ .codex: DisplayRepresentation(title: "Codex"), @@ -80,20 +80,20 @@ enum CompactMetric: String, AppEnum { case todayCost case last30DaysCost - static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Metric") + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.metricTitle)) static let caseDisplayRepresentations: [CompactMetric: DisplayRepresentation] = [ - .credits: DisplayRepresentation(title: "Credits left"), - .todayCost: DisplayRepresentation(title: "Today cost"), - .last30DaysCost: DisplayRepresentation(title: "30d cost"), + .credits: DisplayRepresentation(title: WidgetStrings.resource(LocalizationCatalog.Widget.Metric.creditsLeft)), + .todayCost: DisplayRepresentation(title: WidgetStrings.resource(LocalizationCatalog.Widget.Metric.todayCost)), + .last30DaysCost: DisplayRepresentation(title: WidgetStrings.resource(LocalizationCatalog.Widget.Metric.last30DaysCost)), ] } struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { - static let title: LocalizedStringResource = "Provider" - static let description = IntentDescription("Select the provider to display in the widget.") + static let title = WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle) + static let description = IntentDescription(WidgetStrings.string(LocalizationCatalog.Widget.Intent.providerDescription)) - @Parameter(title: "Provider") + @Parameter(title: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle)) var provider: ProviderChoice init() { @@ -102,10 +102,10 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { } struct SwitchWidgetProviderIntent: AppIntent { - static let title: LocalizedStringResource = "Switch Provider" - static let description = IntentDescription("Switch the provider shown in the widget.") + static let title = WidgetStrings.resource(LocalizationCatalog.Widget.Intent.switchProviderTitle) + static let description = IntentDescription(WidgetStrings.string(LocalizationCatalog.Widget.Intent.switchProviderDescription)) - @Parameter(title: "Provider") + @Parameter(title: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle)) var provider: ProviderChoice init() {} @@ -122,13 +122,13 @@ struct SwitchWidgetProviderIntent: AppIntent { } struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { - static let title: LocalizedStringResource = "Provider + Metric" - static let description = IntentDescription("Select the provider and metric to display.") + static let title = WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerMetricTitle) + static let description = IntentDescription(WidgetStrings.string(LocalizationCatalog.Widget.Intent.providerMetricDescription)) - @Parameter(title: "Provider") + @Parameter(title: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.providerTitle)) var provider: ProviderChoice - @Parameter(title: "Metric") + @Parameter(title: WidgetStrings.resource(LocalizationCatalog.Widget.Intent.metricTitle)) var metric: CompactMetric init() { diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 3b0dd2d27..3a548c3a8 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -33,10 +33,10 @@ struct CodexBarUsageWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") + WidgetStrings.text(LocalizationCatalog.Widget.Empty.openApp) .font(.body) .fontWeight(.semibold) - Text("Usage data will appear once the app refreshes.") + WidgetStrings.text(LocalizationCatalog.Widget.Empty.usageRefreshHint) .font(.caption) .foregroundStyle(.secondary) } @@ -63,10 +63,10 @@ struct CodexBarHistoryWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") + WidgetStrings.text(LocalizationCatalog.Widget.Empty.openApp) .font(.body) .fontWeight(.semibold) - Text("Usage history will appear after a refresh.") + WidgetStrings.text(LocalizationCatalog.Widget.Empty.historyRefreshHint) .font(.caption) .foregroundStyle(.secondary) } @@ -92,10 +92,10 @@ struct CodexBarCompactWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") + WidgetStrings.text(LocalizationCatalog.Widget.Empty.openApp) .font(.body) .fontWeight(.semibold) - Text("Usage data will appear once the app refreshes.") + WidgetStrings.text(LocalizationCatalog.Widget.Empty.usageRefreshHint) .font(.caption) .foregroundStyle(.secondary) } @@ -143,10 +143,10 @@ struct CodexBarSwitcherWidgetView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") + WidgetStrings.text(LocalizationCatalog.Widget.Empty.openApp) .font(.caption) .foregroundStyle(.secondary) - Text("Usage data appears after a refresh.") + WidgetStrings.text(LocalizationCatalog.Widget.Empty.switcherRefreshHint) .font(.caption2) .foregroundStyle(.secondary) } @@ -182,15 +182,15 @@ private struct CompactMetricView: View { switch self.metric { case .credits: let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" - return (value, "Credits left", nil) + return (value, WidgetStrings.string(LocalizationCatalog.Widget.Metric.creditsLeft), nil) case .todayCost: let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "—" let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) - return (value, "Today cost", detail) + return (value, WidgetStrings.string(LocalizationCatalog.Widget.Metric.todayCost), detail) case .last30DaysCost: let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "—" let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) - return (value, "30d cost", detail) + return (value, WidgetStrings.string(LocalizationCatalog.Widget.Metric.last30DaysCost), detail) } } } diff --git a/Sources/CodexBarWidget/Resources/en.lproj/Localizable.strings b/Sources/CodexBarWidget/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..f4a47f70d --- /dev/null +++ b/Sources/CodexBarWidget/Resources/en.lproj/Localizable.strings @@ -0,0 +1,48 @@ +"menu.settings" = "Settings…"; +"preferences.general.title" = "General"; +"preferences.display.menu_bar.section" = "Menu bar"; +"preferences.display.merge_icons.title" = "Merge Icons"; +"preferences.display.merge_icons.subtitle" = "Use a single menu bar icon with a provider switcher."; +"preferences.display.switcher_shows_icons.title" = "Switcher shows icons"; +"preferences.display.switcher_shows_icons.subtitle" = "Show provider icons in the switcher (otherwise show a weekly progress line)."; +"preferences.display.show_most_used_provider.title" = "Show most-used provider"; +"preferences.display.show_most_used_provider.subtitle" = "Menu bar auto-shows the provider closest to its rate limit."; +"preferences.display.show_percent.title" = "Menu bar shows percent"; +"preferences.display.show_percent.subtitle" = "Replace critter bars with provider branding icons and a percentage."; +"preferences.display.mode.title" = "Display mode"; +"preferences.display.mode.subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; +"preferences.display.menu_content.section" = "Menu content"; +"preferences.display.usage_as_used.title" = "Show usage as used"; +"preferences.display.usage_as_used.subtitle" = "Progress bars fill as you consume quota (instead of showing remaining)."; +"preferences.display.reset_time_clock.title" = "Show reset time as clock"; +"preferences.display.reset_time_clock.subtitle" = "Display reset times as absolute clock values instead of countdowns."; +"preferences.display.show_credits_extra.title" = "Show credits + extra usage"; +"preferences.display.show_credits_extra.subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu."; +"preferences.display.show_all_token_accounts.title" = "Show all token accounts"; +"preferences.display.show_all_token_accounts.subtitle" = "Stack token accounts in the menu (otherwise show an account switcher bar)."; +"preferences.display.overview.title" = "Overview tab providers"; +"preferences.display.overview.configure" = "Configure…"; +"preferences.display.overview.enable_merge_icons" = "Enable Merge Icons to configure Overview tab providers."; +"preferences.display.overview.no_enabled_providers" = "No enabled providers available for Overview."; +"preferences.display.overview.none_selected" = "No providers selected"; +"preferences.display.overview.choose_limit" = "Choose up to %lld providers"; +"preferences.display.overview.rows_follow_order" = "Overview rows always follow provider order."; +"alert.login.codex_missing_cli.title" = "Codex CLI not found"; +"alert.login.codex_missing_cli.message" = "Install the Codex CLI (npm i -g @openai/codex) and try again."; +"alert.login.codex_launch_failed.title" = "Could not start codex login"; +"alert.login.codex_timed_out.title" = "Codex login timed out"; +"alert.login.codex_failed.title" = "Codex login failed"; +"widget.empty.open_app" = "Open CodexBar"; +"widget.empty.usage_refresh_hint" = "Usage data will appear once the app refreshes."; +"widget.empty.history_refresh_hint" = "Usage history will appear after a refresh."; +"widget.empty.switcher_refresh_hint" = "Usage data appears after a refresh."; +"widget.metric.credits_left" = "Credits left"; +"widget.metric.today_cost" = "Today cost"; +"widget.metric.last_30_days_cost" = "30d cost"; +"widget.intent.provider.title" = "Provider"; +"widget.intent.provider.description" = "Select the provider to display in the widget."; +"widget.intent.switch_provider.title" = "Switch Provider"; +"widget.intent.switch_provider.description" = "Switch the provider shown in the widget."; +"widget.intent.provider_metric.title" = "Provider + Metric"; +"widget.intent.provider_metric.description" = "Select the provider and metric to display."; +"widget.intent.metric.title" = "Metric"; diff --git a/Sources/CodexBarWidget/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBarWidget/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..788f7ded2 --- /dev/null +++ b/Sources/CodexBarWidget/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,48 @@ +"menu.settings" = "设置…"; +"preferences.general.title" = "通用"; +"preferences.display.menu_bar.section" = "菜单栏"; +"preferences.display.merge_icons.title" = "合并图标"; +"preferences.display.merge_icons.subtitle" = "使用单个菜单栏图标,并提供服务切换器。"; +"preferences.display.switcher_shows_icons.title" = "切换器显示图标"; +"preferences.display.switcher_shows_icons.subtitle" = "在切换器中显示服务图标(否则显示每周进度线)。"; +"preferences.display.show_most_used_provider.title" = "显示使用最多的服务"; +"preferences.display.show_most_used_provider.subtitle" = "菜单栏会自动显示最接近速率限制的服务。"; +"preferences.display.show_percent.title" = "菜单栏显示百分比"; +"preferences.display.show_percent.subtitle" = "用服务品牌图标和百分比替换小动物进度条。"; +"preferences.display.mode.title" = "显示模式"; +"preferences.display.mode.subtitle" = "选择菜单栏显示的内容(Pace 表示使用量与预期的对比)。"; +"preferences.display.menu_content.section" = "菜单内容"; +"preferences.display.usage_as_used.title" = "按已用量显示使用情况"; +"preferences.display.usage_as_used.subtitle" = "进度条会随着配额消耗而填充(而不是显示剩余量)。"; +"preferences.display.reset_time_clock.title" = "将重置时间显示为时钟"; +"preferences.display.reset_time_clock.subtitle" = "将重置时间显示为绝对时刻,而不是倒计时。"; +"preferences.display.show_credits_extra.title" = "显示 Credits 与额外用量"; +"preferences.display.show_credits_extra.subtitle" = "在菜单中显示 Codex Credits 和 Claude Extra usage 区块。"; +"preferences.display.show_all_token_accounts.title" = "显示所有 Token 账户"; +"preferences.display.show_all_token_accounts.subtitle" = "在菜单中堆叠所有 Token 账户(否则显示账户切换条)。"; +"preferences.display.overview.title" = "概览标签页服务"; +"preferences.display.overview.configure" = "配置…"; +"preferences.display.overview.enable_merge_icons" = "启用 Merge Icons 后才能配置概览标签页服务。"; +"preferences.display.overview.no_enabled_providers" = "没有可用于概览的已启用服务。"; +"preferences.display.overview.none_selected" = "未选择任何服务"; +"preferences.display.overview.choose_limit" = "最多选择 %lld 个服务"; +"preferences.display.overview.rows_follow_order" = "概览行始终遵循服务顺序。"; +"alert.login.codex_missing_cli.title" = "未找到 Codex CLI"; +"alert.login.codex_missing_cli.message" = "请先安装 Codex CLI(npm i -g @openai/codex),然后重试。"; +"alert.login.codex_launch_failed.title" = "无法启动 codex login"; +"alert.login.codex_timed_out.title" = "Codex 登录超时"; +"alert.login.codex_failed.title" = "Codex 登录失败"; +"widget.empty.open_app" = "打开 CodexBar"; +"widget.empty.usage_refresh_hint" = "应用刷新后,这里会显示使用数据。"; +"widget.empty.history_refresh_hint" = "刷新后,这里会显示使用历史。"; +"widget.empty.switcher_refresh_hint" = "刷新后会显示使用数据。"; +"widget.metric.credits_left" = "剩余 Credits"; +"widget.metric.today_cost" = "今日成本"; +"widget.metric.last_30_days_cost" = "30 天成本"; +"widget.intent.provider.title" = "服务"; +"widget.intent.provider.description" = "选择要在小组件中显示的服务。"; +"widget.intent.switch_provider.title" = "切换服务"; +"widget.intent.switch_provider.description" = "切换小组件中显示的服务。"; +"widget.intent.provider_metric.title" = "服务 + 指标"; +"widget.intent.provider_metric.description" = "选择要显示的服务和指标。"; +"widget.intent.metric.title" = "指标"; diff --git a/Sources/CodexBarWidget/WidgetStrings.swift b/Sources/CodexBarWidget/WidgetStrings.swift new file mode 100644 index 000000000..d25dba2fe --- /dev/null +++ b/Sources/CodexBarWidget/WidgetStrings.swift @@ -0,0 +1,18 @@ +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: "") + } + + static func text(_ entry: LocalizationEntry) -> Text { + Text(self.string(entry)) + } + + static func resource(_ entry: LocalizationEntry) -> LocalizedStringResource { + LocalizedStringResource(stringLiteral: self.string(entry)) + } +} diff --git a/Tests/CodexBarTests/LocalizationCatalogTests.swift b/Tests/CodexBarTests/LocalizationCatalogTests.swift new file mode 100644 index 000000000..136fc448e --- /dev/null +++ b/Tests/CodexBarTests/LocalizationCatalogTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore +@testable import CodexBarWidget + +@MainActor +struct LocalizationCatalogTests { + @Test + func `app strings resolve english fallbacks from resources`() { + #expect(AppStrings.string(LocalizationCatalog.Menu.settings) == "Settings…") + #expect(AppStrings.string(LocalizationCatalog.Preferences.Display.mergeIconsTitle) == "Merge Icons") + #expect(AppStrings.formatted(LocalizationCatalog.Preferences.Display.overviewChooseLimitFormat, 3) == "Choose up to 3 providers") + } + + @Test + func `widget strings resolve localized resource values`() { + #expect(WidgetStrings.string(LocalizationCatalog.Widget.Empty.openApp) == "Open CodexBar") + #expect(WidgetStrings.string(LocalizationCatalog.Widget.Metric.todayCost) == "Today cost") + } +}