From e65a95e5686660e2c9c4612910a8d0a8440a02d3 Mon Sep 17 00:00:00 2001 From: Jill-Stingray-L Date: Sun, 30 Aug 2026 20:54:33 +0800 Subject: [PATCH] feat: add Simplified Chinese localization --- README.md | 9 +- docs/ARCHITECTURE.md | 4 +- src/CodexUsageWidget/App.xaml.cs | 20 +- .../Application/UsageMonitor.cs | 3 +- .../Application/UsageTextFormatter.cs | 21 +- .../Infrastructure/AppPaths.cs | 2 + .../Hooks/CodexActivityHookSetupService.cs | 6 +- .../Settings/LanguagePreference.cs | 8 + .../Settings/LanguagePreferenceResolver.cs | 30 +++ .../Settings/LanguagePreferenceStore.cs | 56 +++++ .../Infrastructure/Windows/TrayIconService.cs | 77 +++++-- .../Localization/AppLanguageController.cs | 50 +++++ src/CodexUsageWidget/Localization/Strings.cs | 56 +++++ .../Localization/UsageLabelLocalizer.cs | 36 ++++ src/CodexUsageWidget/Program.cs | 7 +- src/CodexUsageWidget/Resources/Strings.resx | 193 ++++++++++++++++++ .../Resources/Strings.zh-CN.resx | 193 ++++++++++++++++++ .../Views/ActivityHookChangeReviewWindow.xaml | 7 +- .../ActivityHookChangeReviewWindow.xaml.cs | 13 +- .../Controls/ActivityHookSetupControl.xaml | 15 +- .../Controls/ActivityHookSetupControl.xaml.cs | 111 ++++++++-- .../Views/Controls/DetailedUsageView.xaml | 13 +- .../Views/Localization/LocExtension.cs | 17 ++ src/CodexUsageWidget/Views/MainWindow.xaml | 21 +- src/CodexUsageWidget/Views/MainWindow.xaml.cs | 36 +++- .../Views/SettingsWindow.xaml | 155 +++++++++----- .../Views/SettingsWindow.xaml.cs | 30 +++ .../Views/TaskbarLabelWindow.xaml | 15 +- .../Views/TaskbarLabelWindow.xaml.cs | 31 ++- .../ViewModels/ActivityHookSetupViewModel.cs | 63 +++--- .../ViewModels/TokenActivityViewModel.cs | 30 ++- .../Views/ViewModels/UsageLimitViewModel.cs | 11 +- .../Views/ViewModels/UsageWidgetViewModel.cs | 68 +++--- .../AppLanguageControllerTests.cs | 57 ++++++ .../LanguagePreferenceResolverTests.cs | 42 ++++ .../LanguagePreferenceStoreTests.cs | 70 +++++++ .../LocalizationResourceTests.cs | 61 ++++++ .../TaskbarSettingsMenuTests.cs | 26 ++- .../UsageLabelLocalizerTests.cs | 21 ++ .../UsageTextFormatterTests.cs | 2 +- 40 files changed, 1458 insertions(+), 228 deletions(-) create mode 100644 src/CodexUsageWidget/Infrastructure/Settings/LanguagePreference.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Settings/LanguagePreferenceResolver.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Settings/LanguagePreferenceStore.cs create mode 100644 src/CodexUsageWidget/Localization/AppLanguageController.cs create mode 100644 src/CodexUsageWidget/Localization/Strings.cs create mode 100644 src/CodexUsageWidget/Localization/UsageLabelLocalizer.cs create mode 100644 src/CodexUsageWidget/Resources/Strings.resx create mode 100644 src/CodexUsageWidget/Resources/Strings.zh-CN.resx create mode 100644 src/CodexUsageWidget/Views/Localization/LocExtension.cs create mode 100644 tests/CodexUsageWidget.Tests/AppLanguageControllerTests.cs create mode 100644 tests/CodexUsageWidget.Tests/LanguagePreferenceResolverTests.cs create mode 100644 tests/CodexUsageWidget.Tests/LanguagePreferenceStoreTests.cs create mode 100644 tests/CodexUsageWidget.Tests/LocalizationResourceTests.cs create mode 100644 tests/CodexUsageWidget.Tests/UsageLabelLocalizerTests.cs diff --git a/README.md b/README.md index dc88b53..46b5ed7 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ of `codex.cmd` or `codex.exe`. including credits, spend controls, earned resets, token activity, and model-specific limits when Codex returns them - System, light, and dark themes with five preset accent colors selected from Settings +- Automatic Windows-language selection with English fallback, plus English and Simplified + Chinese overrides in Settings - A movable, always-on-top desktop widget and a compact label beside the notification area - Live task activity dots based on official local Codex lifecycle hooks - Automatic refresh every two minutes plus live rate-limit notifications @@ -66,9 +68,9 @@ subscription percentage. Select the `−` button to move the widget to the taskbar. Right-click the taskbar label or tray icon to refresh, change the display mode, open Settings, check for updates, or exit. -The Settings window opens from the gear button on the widget too. Theme, accent color, -widget layout, displayed limit, and Start with Windows changes apply as soon as they are -selected. +The Settings window opens from the gear button on the widget too. Language, theme, accent +color, widget layout, displayed limit, and Start with Windows changes apply as soon as they +are selected. ## Activity dots @@ -101,6 +103,7 @@ The application writes only under `%LOCALAPPDATA%\CodexUsageWidget`: - `displayed-limit.txt`: selected summary limit - `theme.txt`: system, light, or dark theme preference - `accent-palette.txt`: selected preset accent color +- `language.txt`: system, English, or Simplified Chinese language preference - `logs\codex-usage-widget-YYYYMMDD.log`: diagnostic logs retained for 14 days The widget displays ChatGPT and Codex subscription limits. It does not display OpenAI API diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9b32b37..b4d01bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,7 +42,9 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence 11. `AppThemeController` applies the saved system, light, or dark theme plus the selected accent palette, and observes Windows theme changes without leaking registry access into view code. -12. `MainWindow` remains a window-lifecycle shell while the Settings window coordinates +12. `AppLanguageController` resolves the saved system, English, or Simplified Chinese + preference, while standard .NET resources and a notifying WPF binding refresh existing UI. +13. `MainWindow` remains a window-lifecycle shell while the Settings window coordinates activity-hook setup plus immediate theme, accent, widget-layout, displayed-limit, and Windows startup preferences. Focused user controls render compact, detailed, and repeated limit-row content. diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index 0a15190..81678cd 100644 --- a/src/CodexUsageWidget/App.xaml.cs +++ b/src/CodexUsageWidget/App.xaml.cs @@ -7,6 +7,7 @@ using CodexUsageWidget.Infrastructure.Preview; using CodexUsageWidget.Infrastructure.Settings; using CodexUsageWidget.Infrastructure.Windows; +using CodexUsageWidget.Localization; using CodexUsageWidget.Views; namespace CodexUsageWidget; @@ -18,8 +19,20 @@ public partial class App : System.Windows.Application, IDisposable private FileLogger? _logger; private GlobalExceptionHandler? _exceptionHandler; private AppThemeController? _themeController; + private readonly AppLanguageController _languageController; private bool _disposed; + public App() + : this(new AppLanguageController(new LanguagePreferenceStore())) + { + } + + public App(AppLanguageController languageController) + { + ArgumentNullException.ThrowIfNull(languageController); + _languageController = languageController; + } + protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); @@ -84,7 +97,8 @@ protected override void OnStartup(StartupEventArgs e) new DisplayedLimitPreferenceStore(), startupRegistrationService, new TrayIconService(), - _themeController); + _themeController, + _languageController); MainWindow = window; activityMonitor.StartAsync().GetAwaiter().GetResult(); window.Show(); @@ -100,8 +114,8 @@ protected override void OnStartup(StartupEventArgs e) activityMonitor?.DisposeAsync().AsTask().GetAwaiter().GetResult(); _logger.LogError("Application startup failed.", ex); System.Windows.MessageBox.Show( - "Codex Usage Widget could not start. See the log under " + AppPaths.LogDirectory, - "Codex Usage Widget", + Strings.Format("App_StartupFailure", AppPaths.LogDirectory), + Strings.Get("App_Name"), MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(-1); diff --git a/src/CodexUsageWidget/Application/UsageMonitor.cs b/src/CodexUsageWidget/Application/UsageMonitor.cs index 013c29e..573ec96 100644 --- a/src/CodexUsageWidget/Application/UsageMonitor.cs +++ b/src/CodexUsageWidget/Application/UsageMonitor.cs @@ -1,4 +1,5 @@ using CodexUsageWidget.Domain; +using CodexUsageWidget.Localization; namespace CodexUsageWidget.Application; @@ -67,7 +68,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default) } catch (OperationCanceledException) when (!_lifetime.IsCancellationRequested) { - RefreshFailed?.Invoke("Codex did not respond in time."); + RefreshFailed?.Invoke(Strings.Get("Error_ResponseTimeout")); } catch (Exception ex) { diff --git a/src/CodexUsageWidget/Application/UsageTextFormatter.cs b/src/CodexUsageWidget/Application/UsageTextFormatter.cs index fd56518..3f05ca3 100644 --- a/src/CodexUsageWidget/Application/UsageTextFormatter.cs +++ b/src/CodexUsageWidget/Application/UsageTextFormatter.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using CodexUsageWidget.Localization; + namespace CodexUsageWidget.Application; public static class UsageTextFormatter @@ -7,16 +10,17 @@ public static string ToFriendlyError(string message) if (message.Contains("not found", StringComparison.OrdinalIgnoreCase) || message.Contains("cannot find", StringComparison.OrdinalIgnoreCase)) { - return "Codex CLI was not found on PATH."; + return Strings.Get("Error_CliNotFound"); } if (message.Contains("login", StringComparison.OrdinalIgnoreCase) || message.Contains("unauthorized", StringComparison.OrdinalIgnoreCase)) { - return "Run codex login, then refresh."; + return Strings.Get("Error_LoginRequired"); } - return message.Length > 100 ? message[..100] + "…" : message; + var detail = message.Length > 100 ? message[..100] + "…" : message; + return Strings.Format("Error_Unexpected", detail); } public static string FormatReset(DateTimeOffset reset, DateTimeOffset? now = null) @@ -24,15 +28,20 @@ public static string FormatReset(DateTimeOffset reset, DateTimeOffset? now = nul var remaining = reset - (now ?? DateTimeOffset.Now); if (remaining <= TimeSpan.Zero) { - return "now"; + return Strings.Get("Usage_ResetsNow"); } if (remaining < TimeSpan.FromHours(24)) { - return $"in {Math.Max(1, (int)Math.Ceiling(remaining.TotalHours))}h · {reset:HH:mm}"; + return Strings.Format( + "Usage_ResetsInHours", + Math.Max(1, (int)Math.Ceiling(remaining.TotalHours)), + reset.ToString("HH:mm", CultureInfo.CurrentCulture)); } - return $"{reset:ddd HH:mm}"; + return Strings.Format( + "Usage_ResetsAt", + reset.ToString("ddd HH:mm", CultureInfo.CurrentCulture)); } public static string ColorForRemaining(double remainingPercent) => remainingPercent switch diff --git a/src/CodexUsageWidget/Infrastructure/AppPaths.cs b/src/CodexUsageWidget/Infrastructure/AppPaths.cs index 7d4daf8..ff2aee1 100644 --- a/src/CodexUsageWidget/Infrastructure/AppPaths.cs +++ b/src/CodexUsageWidget/Infrastructure/AppPaths.cs @@ -18,6 +18,8 @@ public static class AppPaths public static string ThemePreferenceFile => Path.Combine(LocalDataDirectory, "theme.txt"); + public static string LanguagePreferenceFile => Path.Combine(LocalDataDirectory, "language.txt"); + public static string AccentPaletteFile => Path.Combine(LocalDataDirectory, "accent-palette.txt"); public static string LogDirectory => Path.Combine(LocalDataDirectory, "logs"); diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs index c498891..abe5161 100644 --- a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs @@ -70,7 +70,7 @@ public async Task GetStatusAsync( { return new ActivityHookSetupStatus( ActivityHookSetupState.InstalledStatusUnavailable, - $"Codex could not report hook trust status: {ex.Message}"); + ex.Message); } } @@ -123,8 +123,6 @@ private static ActivityHookSetupStatus FromTrustEvaluation( new ActivityHookSetupStatus(ActivityHookSetupState.Active), CodexHookTrustEvaluation.Modified => new ActivityHookSetupStatus(ActivityHookSetupState.Modified), - _ => new ActivityHookSetupStatus( - ActivityHookSetupState.InstalledStatusUnavailable, - "The hook definitions are installed, but Codex did not report all expected hooks.") + _ => new ActivityHookSetupStatus(ActivityHookSetupState.InstalledStatusUnavailable) }; } diff --git a/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreference.cs b/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreference.cs new file mode 100644 index 0000000..52102db --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreference.cs @@ -0,0 +1,8 @@ +namespace CodexUsageWidget.Infrastructure.Settings; + +public enum LanguagePreference +{ + System, + English, + SimplifiedChinese +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreferenceResolver.cs b/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreferenceResolver.cs new file mode 100644 index 0000000..7362a7a --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreferenceResolver.cs @@ -0,0 +1,30 @@ +using System.Globalization; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public static class LanguagePreferenceResolver +{ + public static LanguagePreference Resolve( + LanguagePreference preference, + CultureInfo uiCulture) => preference switch + { + LanguagePreference.English => LanguagePreference.English, + LanguagePreference.SimplifiedChinese => LanguagePreference.SimplifiedChinese, + _ => IsSimplifiedChinese(uiCulture) + ? LanguagePreference.SimplifiedChinese + : LanguagePreference.English + }; + + private static bool IsSimplifiedChinese(CultureInfo culture) + { + for (var current = culture; !string.IsNullOrEmpty(current.Name); current = current.Parent) + { + if (string.Equals(current.Name, "zh-Hans", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreferenceStore.cs b/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreferenceStore.cs new file mode 100644 index 0000000..9fcabc5 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/LanguagePreferenceStore.cs @@ -0,0 +1,56 @@ +using System.IO; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public sealed class LanguagePreferenceStore +{ + private readonly string _path; + + public LanguagePreferenceStore(string? path = null) + { + _path = path ?? AppPaths.LanguagePreferenceFile; + } + + public LanguagePreference Load() + { + try + { + return File.ReadAllText(_path).Trim().ToLowerInvariant() switch + { + "english" => LanguagePreference.English, + "simplified-chinese" => LanguagePreference.SimplifiedChinese, + _ => LanguagePreference.System + }; + } + catch (IOException) + { + return LanguagePreference.System; + } + catch (UnauthorizedAccessException) + { + return LanguagePreference.System; + } + } + + public void Save(LanguagePreference preference) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText( + _path, + preference switch + { + LanguagePreference.English => "english", + LanguagePreference.SimplifiedChinese => "simplified-chinese", + _ => "system" + }); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs index 4741a22..68c53e5 100644 --- a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs +++ b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs @@ -1,4 +1,5 @@ using CodexUsageWidget.Infrastructure.Settings; +using CodexUsageWidget.Localization; using Forms = System.Windows.Forms; namespace CodexUsageWidget.Infrastructure.Windows; @@ -7,9 +8,16 @@ public sealed class TrayIconService : IDisposable { private readonly Forms.NotifyIcon _notifyIcon; private readonly Forms.ContextMenuStrip _menu; + private readonly Forms.ToolStripMenuItem _openItem; + private readonly Forms.ToolStripMenuItem _refreshItem; + private readonly Forms.ToolStripMenuItem _settingsItem; + private readonly Forms.ToolStripMenuItem _displayModeItem; private readonly Forms.ToolStripMenuItem _desktopWidgetModeItem; private readonly Forms.ToolStripMenuItem _taskbarIndicatorModeItem; + private readonly Forms.ToolStripMenuItem _updateItem; + private readonly Forms.ToolStripMenuItem _exitItem; private System.Drawing.Icon _currentIcon; + private double? _remainingPercent; private bool _disposed; public TrayIconService() @@ -18,33 +26,47 @@ public TrayIconService() { ShowItemToolTips = true }; - menu.Items.Add("Open", null, (_, _) => OpenRequested?.Invoke(this, EventArgs.Empty)); - menu.Items.Add("Refresh", null, (_, _) => RefreshRequested?.Invoke(this, EventArgs.Empty)); - menu.Items.Add( - "Settings...", + _openItem = new Forms.ToolStripMenuItem( + null, + null, + (_, _) => OpenRequested?.Invoke(this, EventArgs.Empty)); + _refreshItem = new Forms.ToolStripMenuItem( + null, + null, + (_, _) => RefreshRequested?.Invoke(this, EventArgs.Empty)); + _settingsItem = new Forms.ToolStripMenuItem( + null, null, (_, _) => SettingsRequested?.Invoke(this, EventArgs.Empty)); + menu.Items.Add(_openItem); + menu.Items.Add(_refreshItem); + menu.Items.Add(_settingsItem); menu.Items.Add(new Forms.ToolStripSeparator()); - var displayModeMenu = new Forms.ToolStripMenuItem("Display mode"); + _displayModeItem = new Forms.ToolStripMenuItem(); _desktopWidgetModeItem = new Forms.ToolStripMenuItem( - "Desktop widget", + null, null, (_, _) => DesktopModeRequested?.Invoke(this, EventArgs.Empty)); _taskbarIndicatorModeItem = new Forms.ToolStripMenuItem( - "Taskbar label", + null, null, (_, _) => TaskbarModeRequested?.Invoke(this, EventArgs.Empty)); - displayModeMenu.DropDownItems.Add(_desktopWidgetModeItem); - displayModeMenu.DropDownItems.Add(_taskbarIndicatorModeItem); - menu.Items.Add(displayModeMenu); + _displayModeItem.DropDownItems.Add(_desktopWidgetModeItem); + _displayModeItem.DropDownItems.Add(_taskbarIndicatorModeItem); + menu.Items.Add(_displayModeItem); - menu.Items.Add( - "Check for updates...", + _updateItem = new Forms.ToolStripMenuItem( + null, null, (_, _) => UpdateCheckRequested?.Invoke(this, EventArgs.Empty)); + menu.Items.Add(_updateItem); menu.Items.Add(new Forms.ToolStripSeparator()); - menu.Items.Add("Exit", null, (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty)); + _exitItem = new Forms.ToolStripMenuItem( + null, + null, + (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty)); + menu.Items.Add(_exitItem); _currentIcon = UsageIconFactory.Create(null); _menu = menu; @@ -56,6 +78,8 @@ public TrayIconService() ContextMenuStrip = menu }; _notifyIcon.MouseClick += NotifyIconOnMouseClick; + Strings.Current.PropertyChanged += StringsOnPropertyChanged; + RefreshLocalizedText(); SetTheme(EffectiveTheme.Dark); } @@ -114,9 +138,10 @@ private static void ApplyMenuColors( public void UpdateUsage(double? remainingPercent) { + _remainingPercent = remainingPercent; _notifyIcon.Text = remainingPercent is null - ? "Codex Usage · unavailable" - : $"Codex · {Math.Round(remainingPercent.Value):0}% remaining"; + ? Strings.Get("Tray_Unavailable") + : Strings.Format("Tray_Remaining", Math.Round(remainingPercent.Value)); var nextIcon = UsageIconFactory.Create(remainingPercent); var previousIcon = _currentIcon; @@ -125,6 +150,27 @@ public void UpdateUsage(double? remainingPercent) previousIcon.Dispose(); } + private void StringsOnPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == "Item[]") + { + RefreshLocalizedText(); + } + } + + private void RefreshLocalizedText() + { + _openItem.Text = Strings.Get("Tray_Open"); + _refreshItem.Text = Strings.Get("Common_Refresh"); + _settingsItem.Text = Strings.Get("Tray_Settings"); + _displayModeItem.Text = Strings.Get("Tray_DisplayMode"); + _desktopWidgetModeItem.Text = Strings.Get("Tray_DesktopWidget"); + _taskbarIndicatorModeItem.Text = Strings.Get("Tray_TaskbarLabel"); + _updateItem.Text = Strings.Get("Tray_CheckUpdates"); + _exitItem.Text = Strings.Get("Common_Exit"); + UpdateUsage(_remainingPercent); + } + private void NotifyIconOnMouseClick(object? sender, Forms.MouseEventArgs e) { if (e.Button == Forms.MouseButtons.Left) @@ -141,6 +187,7 @@ public void Dispose() } _disposed = true; + Strings.Current.PropertyChanged -= StringsOnPropertyChanged; _notifyIcon.MouseClick -= NotifyIconOnMouseClick; _notifyIcon.Visible = false; _notifyIcon.ContextMenuStrip?.Dispose(); diff --git a/src/CodexUsageWidget/Localization/AppLanguageController.cs b/src/CodexUsageWidget/Localization/AppLanguageController.cs new file mode 100644 index 0000000..0811630 --- /dev/null +++ b/src/CodexUsageWidget/Localization/AppLanguageController.cs @@ -0,0 +1,50 @@ +using System.Globalization; +using CodexUsageWidget.Infrastructure.Settings; + +namespace CodexUsageWidget.Localization; + +public sealed class AppLanguageController +{ + private readonly LanguagePreferenceStore? _store; + private readonly CultureInfo _systemUiCulture; + + public AppLanguageController( + LanguagePreferenceStore store, + CultureInfo? systemUiCulture = null) + { + ArgumentNullException.ThrowIfNull(store); + _store = store; + _systemUiCulture = systemUiCulture ?? CultureInfo.CurrentUICulture; + Preference = store.Load(); + ApplyPreference(); + } + + public AppLanguageController( + LanguagePreference preference, + CultureInfo? systemUiCulture = null) + { + _systemUiCulture = systemUiCulture ?? CultureInfo.CurrentUICulture; + Preference = preference; + ApplyPreference(); + } + + public LanguagePreference Preference { get; private set; } + + public LanguagePreference EffectiveLanguage => + LanguagePreferenceResolver.Resolve(Preference, _systemUiCulture); + + public void SetPreference(LanguagePreference preference) + { + Preference = preference; + _store?.Save(preference); + ApplyPreference(); + } + + private void ApplyPreference() + { + var cultureName = EffectiveLanguage == LanguagePreference.SimplifiedChinese + ? "zh-CN" + : "en-US"; + Strings.Current.SetCulture(CultureInfo.GetCultureInfo(cultureName)); + } +} diff --git a/src/CodexUsageWidget/Localization/Strings.cs b/src/CodexUsageWidget/Localization/Strings.cs new file mode 100644 index 0000000..f764b73 --- /dev/null +++ b/src/CodexUsageWidget/Localization/Strings.cs @@ -0,0 +1,56 @@ +using System.ComponentModel; +using System.Globalization; +using System.Resources; + +namespace CodexUsageWidget.Localization; + +public sealed class Strings : INotifyPropertyChanged +{ + private static readonly ResourceManager ResourceManager = new( + "CodexUsageWidget.Resources.Strings", + typeof(Strings).Assembly); + private CultureInfo _culture = CultureInfo.GetCultureInfo("en-US"); + + private Strings() + { + } + + public static Strings Current { get; } = new(); + + public event PropertyChangedEventHandler? PropertyChanged; + + public string this[string key] => Get(key); + + public CultureInfo Culture => _culture; + + public static string Get(string key) => + ResourceManager.GetString(key, Current._culture) ?? key; + + public static string GetForCulture(string key, CultureInfo culture) => + ResourceManager.GetString(key, culture) ?? key; + + public static string Format(string key, params object?[] arguments) => + string.Format(CultureInfo.CurrentCulture, Get(key), arguments); + + public static string FormatForCulture( + CultureInfo culture, + string key, + params object?[] arguments) => + string.Format(culture, GetForCulture(key, culture), arguments); + + public void SetCulture(CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(culture); + var changed = !string.Equals( + _culture.Name, + culture.Name, + StringComparison.OrdinalIgnoreCase); + _culture = culture; + CultureInfo.CurrentUICulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + if (changed) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); + } + } +} diff --git a/src/CodexUsageWidget/Localization/UsageLabelLocalizer.cs b/src/CodexUsageWidget/Localization/UsageLabelLocalizer.cs new file mode 100644 index 0000000..767adbb --- /dev/null +++ b/src/CodexUsageWidget/Localization/UsageLabelLocalizer.cs @@ -0,0 +1,36 @@ +using System.Globalization; + +namespace CodexUsageWidget.Localization; + +public static class UsageLabelLocalizer +{ + public static string Localize(string label, CultureInfo? culture = null) + { + culture ??= Strings.Current.Culture; + return label switch + { + "Weekly limit" => Strings.GetForCulture("Usage_WeeklyLimit", culture), + "Primary limit" => Strings.GetForCulture("Usage_PrimaryLimit", culture), + "Secondary limit" => Strings.GetForCulture("Usage_SecondaryLimit", culture), + _ when TryReadDuration(label, 'd', out var days) => + Strings.FormatForCulture(culture, "Usage_DayLimit", days), + _ when TryReadDuration(label, 'h', out var hours) => + Strings.FormatForCulture(culture, "Usage_HourLimit", hours), + _ when TryReadDuration(label, 'm', out var minutes) => + Strings.FormatForCulture(culture, "Usage_MinuteLimit", minutes), + _ => label + }; + } + + private static bool TryReadDuration(string label, char unit, out long value) + { + value = 0; + var suffix = $"{unit} limit"; + return label.EndsWith(suffix, StringComparison.Ordinal) && + long.TryParse( + label.AsSpan(0, label.Length - suffix.Length), + NumberStyles.None, + CultureInfo.InvariantCulture, + out value); + } +} diff --git a/src/CodexUsageWidget/Program.cs b/src/CodexUsageWidget/Program.cs index 565ddd0..331f59c 100644 --- a/src/CodexUsageWidget/Program.cs +++ b/src/CodexUsageWidget/Program.cs @@ -4,7 +4,9 @@ using System.Security; using CodexUsageWidget.Infrastructure; using CodexUsageWidget.Infrastructure.Codex.Hooks; +using CodexUsageWidget.Infrastructure.Settings; using CodexUsageWidget.Infrastructure.Windows; +using CodexUsageWidget.Localization; namespace CodexUsageWidget; @@ -28,9 +30,10 @@ public static int Main(string[] args) catch (Exception ex) when ( ex is IOException or UnauthorizedAccessException or SecurityException or Win32Exception) { + _ = new AppLanguageController(new LanguagePreferenceStore()); System.Windows.MessageBox.Show( - "Codex Usage Widget could not install itself for the current user. " + ex.Message, - "Codex Usage Widget", + Strings.Format("App_InstallFailure", ex.Message), + Strings.Get("App_Name"), System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); return -1; diff --git a/src/CodexUsageWidget/Resources/Strings.resx b/src/CodexUsageWidget/Resources/Strings.resx new file mode 100644 index 0000000..10ae41f --- /dev/null +++ b/src/CodexUsageWidget/Resources/Strings.resx @@ -0,0 +1,193 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Codex Usage Widget + Codex Usage + Codex Usage Widget could not install itself for the current user. {0} + Codex Usage Widget could not start. See the log under {0} + Available + Cancel + Check again + Close + Exit + None available + Refresh + Settings + Unlimited + Codex task is running + Refresh usage + Show compact view + Show details + Toggle widget layout + Use taskbar label + The Windows startup preference could not be updated. + Windows could not open the widget release page. + Connecting… + Waiting for Codex + Local only · waiting for sync + Offline + Local only · select refresh to retry + Syncing… + Live · {0} + {0} remaining + Local only · updated {0} + No subscription limits returned. Run codex login first. + Codex CLI was not found on PATH. + Run codex login, then refresh. + Codex did not respond in time. + Codex usage could not be read. {0} + Workspace credits are depleted. + Workspace usage limit reached. + Codex usage limit reached. + Individual spend control reached. + 5h limit + Weekly limit + {0}d limit + {0}h limit + {0}m limit + Primary limit + Secondary limit + {0}% used + {0}% remaining + Reset time unavailable + Resets now + Resets in {0}h · {1} + Resets {0} + {0} remaining + ChatGPT credits + Individual spend limit + {0} of {1} · {2}% remaining + Spend limit resets + Rate-limit resets + {0} available + OVERVIEW + CREDITS & LIMITS + TOKEN ACTIVITY + Not quota consumption + MODEL-SPECIFIC LIMITS + Lifetime tokens + Peak daily tokens + Longest turn + Current streak + Longest streak + {0} days + {0} tokens + {0}% of chart peak + {0}h {1}m + {0}m + Choose how Codex Usage Widget looks and behaves. + GENERAL + Start with Windows + Launch the widget when you sign in + Theme + Use the Windows setting or keep the widget in one theme. + Use Windows setting + Changes with your Windows app theme + Use Windows theme + Light + Bright surfaces with dark text + Use light theme + Dark + Dark surfaces with light text + Use dark theme + Accent + Blue + Violet + Teal + Emerald + Pink + Use {0} accent + Use blue accent + Use violet accent + Use teal accent + Use emerald accent + Use pink accent + Widget layout + Choose how much information the desktop widget shows. + Compact + Summary at a glance + Use compact widget layout + Detailed + Full usage breakdown + Use detailed widget layout + USAGE + Displayed limit + Choose the limit shown in the widget, taskbar label, and tray icon. + Show the global five-hour usage window + Display 5-hour limit + Codex did not return a global 5h limit for this account. + Show the global weekly usage window + Display weekly limit + Most constrained + Show whichever available limit has the least remaining + Display most constrained limit + FEATURES + Language + Follow the Windows display language or always use one language. + Use Windows setting + Uses Simplified Chinese when supported; otherwise English + Use Windows language + English + Always use English + Use English + 简体中文 + Always use Simplified Chinese + Use Simplified Chinese + Codex activity + Show when a local Codex turn is running + Privacy: only activity type, session ID, and turn ID travel through a local user-only pipe. Prompts and responses never leave Codex. + Remove hooks + Copies /hooks and opens Codex + Checking Codex… + Reading the local hook configuration and Codex trust status. + Open in Codex + Review hooks + Setup required + Install three local lifecycle hooks to animate the taskbar dots while Codex works. You can review the exact hooks.json change before it is written. + Update available + Activity handlers from an older widget version are installed. Update them to the faster, path-independent bridge after reviewing the exact change. + Review update + One step left + Hooks are installed. Approve their exact definitions once in Codex before they can run. + Approve in Codex + Activity dots are ready + All three hooks are installed and trusted. + Review required + Codex paused the changed definitions. Review them again before activity reporting resumes. + Review in Codex + Hooks are disabled in Codex + Set hooks = true in the [features] section of your Codex config before installing. + Installed · status unavailable + Hooks are installed, but their trust status could not be verified. + Hooks are installed, but Codex could not report their trust status. {0} + Open hooks in Codex + Setup unavailable + The activity hook configuration could not be read. + The activity hook setup could not be completed. {0} + Codex did not report hook status in time. Check that the CLI is available, then try again. + Codex is open. Paste /hooks and approve the three definitions. Status refreshes when you return here. + Codex is open. Type /hooks and approve the three definitions. Status refreshes when you return here. + Review Codex activity hooks + Review activity hook installation + Review activity hook removal + The widget will write this exact ~/.codex/hooks.json content. Existing hooks and unknown fields are preserved. + Only handlers that exactly match this widget executable will be removed. Other hook definitions are preserved. + Install hooks + Open + Settings... + Display mode + Desktop widget + Taskbar label + Check for updates... + Codex Usage · unavailable + Codex · {0}% remaining + Open widget + Preview activity animation + Use desktop widget + Codex usage is currently unavailable. + {0}: {1}% remaining + {0}: {1}% remaining · resets {2} + diff --git a/src/CodexUsageWidget/Resources/Strings.zh-CN.resx b/src/CodexUsageWidget/Resources/Strings.zh-CN.resx new file mode 100644 index 0000000..084f5bf --- /dev/null +++ b/src/CodexUsageWidget/Resources/Strings.zh-CN.resx @@ -0,0 +1,193 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Codex 用量小组件 + Codex 用量 + Codex 用量小组件无法为当前用户完成安装。{0} + Codex 用量小组件无法启动。请查看以下目录中的日志:{0} + 可用 + 取消 + 重新检查 + 关闭 + 退出 + 暂无可用额度 + 刷新 + 设置 + 不限量 + Codex 任务正在运行 + 刷新用量 + 显示紧凑视图 + 显示详情 + 切换小组件布局 + 使用任务栏标签 + 无法更新开机启动设置。 + Windows 无法打开小组件的发布页面。 + 正在连接… + 正在等待 Codex + 仅限本机 · 等待同步 + 离线 + 仅限本机 · 请选择刷新后重试 + 正在同步… + 实时 · {0} + 剩余 {0} + 仅限本机 · 更新于 {0} + 未返回订阅用量限制。请先运行 codex login。 + 在 PATH 中找不到 Codex CLI。 + 请运行 codex login,然后刷新。 + Codex 响应超时。 + 无法读取 Codex 用量。{0} + 工作区额度已用尽。 + 已达到工作区用量限制。 + 已达到 Codex 用量限制。 + 已达到个人消费限制。 + 5 小时限制 + 每周限制 + {0} 天限制 + {0} 小时限制 + {0} 分钟限制 + 主要限制 + 次要限制 + 已用 {0}% + 剩余 {0}% + 暂无重置时间 + 现在重置 + {0} 小时后重置 · {1} + {0} 重置 + 剩余 {0} + ChatGPT 额度 + 个人消费限制 + 已用 {0} / {1} · 剩余 {2}% + 消费限制重置时间 + 用量限制重置次数 + 可用 {0} + 概览 + 额度与限制 + TOKEN 活动 + 不计入用量额度 + 模型专属限制 + 累计 Token + 单日 Token 峰值 + 最长任务时长 + 当前连续天数 + 最长连续天数 + {0} 天 + {0} Token + 图表峰值的 {0}% + {0} 小时 {1} 分钟 + {0} 分钟 + 选择 Codex 用量小组件的外观和行为。 + 常规 + 开机启动 + 登录 Windows 时启动小组件 + 主题 + 跟随 Windows 主题,或固定使用一种主题。 + 跟随 Windows + 随 Windows 应用主题切换 + 使用 Windows 主题 + 浅色 + 浅色界面和深色文字 + 使用浅色主题 + 深色 + 深色界面和浅色文字 + 使用深色主题 + 强调色 + 蓝色 + 紫色 + 青色 + 翠绿色 + 粉色 + 使用{0}强调色 + 使用蓝色强调色 + 使用紫色强调色 + 使用青色强调色 + 使用翠绿色强调色 + 使用粉色强调色 + 小组件布局 + 选择桌面小组件显示的信息量。 + 紧凑 + 快速查看用量摘要 + 使用紧凑布局 + 详情 + 显示完整用量明细 + 使用详情布局 + 用量 + 显示的限制 + 选择小组件、任务栏标签和托盘图标显示的限制。 + 显示全局五小时用量窗口 + 显示 5 小时限制 + Codex 未返回此账户的全局 5 小时限制。 + 显示全局每周用量窗口 + 显示每周限制 + 剩余额度最少 + 显示当前剩余比例最低的可用限制 + 显示剩余额度最少的限制 + 功能 + 语言 + 跟随 Windows 显示语言,或固定使用一种语言。 + 跟随 Windows + 支持简体中文时使用中文,否则使用英语 + 跟随 Windows 显示语言 + English + 始终使用英语 + 使用英语 + 简体中文 + 始终使用简体中文 + 使用简体中文 + Codex 运行状态 + Codex 本地任务运行时显示状态点 + 隐私说明:只有活动类型、会话 ID 和任务 ID 会通过仅限当前用户的本地管道传输。提示词和回复始终留在 Codex 中。 + 移除钩子 + 复制 /hooks 并打开 Codex + 正在检查 Codex… + 正在读取本地钩子配置和 Codex 信任状态。 + 在 Codex 中打开 + 检查钩子 + 需要设置 + 安装三个本地生命周期钩子,以便 Codex 工作时显示任务栏状态点。写入前可以检查 hooks.json 的确切改动。 + 有可用更新 + 已安装旧版小组件的活动处理程序。检查确切改动后,可将其更新为更快且不依赖路径的桥接程序。 + 检查更新 + 还差一步 + 钩子已安装。需要在 Codex 中批准一次确切定义,钩子才能运行。 + 在 Codex 中批准 + 运行状态点已就绪 + 三个钩子均已安装并受信任。 + 需要检查 + Codex 已暂停改动过的定义。请重新检查,活动报告随后会恢复。 + 在 Codex 中检查 + Codex 中的钩子已禁用 + 安装前,请在 Codex 配置的 [features] 部分设置 hooks = true。 + 已安装 · 状态不可用 + 钩子已安装,但无法验证其信任状态。 + 钩子已安装,但 Codex 无法返回其信任状态。{0} + 在 Codex 中打开钩子 + 设置不可用 + 无法读取活动钩子配置。 + 无法完成活动钩子设置。{0} + Codex 未及时返回钩子状态。请检查 CLI 是否可用,然后重试。 + Codex 已打开。请粘贴 /hooks 并批准三个定义。返回此处时会刷新状态。 + Codex 已打开。请输入 /hooks 并批准三个定义。返回此处时会刷新状态。 + 检查 Codex 活动钩子 + 检查活动钩子安装 + 检查活动钩子移除 + 小组件将写入以下确切的 ~/.codex/hooks.json 内容。现有钩子和未知字段会保留。 + 只会移除与此小组件可执行文件完全匹配的处理程序。其他钩子定义会保留。 + 安装钩子 + 打开 + 设置... + 显示模式 + 桌面小组件 + 任务栏标签 + 检查更新... + Codex 用量 · 暂不可用 + Codex · 剩余 {0}% + 打开小组件 + 预览活动动画 + 使用桌面小组件 + Codex 用量当前不可用。 + {0}:剩余 {1}% + {0}:剩余 {1}% · {2} 重置 + diff --git a/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml b/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml index 6b34e44..dfad399 100644 --- a/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml +++ b/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml @@ -2,7 +2,8 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:CodexUsageWidget.Views.Controls" - Title="Review Codex activity hooks" + xmlns:localization="clr-namespace:CodexUsageWidget.Views.Localization" + Title="{localization:Loc Activity_ReviewTitle}" Width="620" Height="520" ResizeMode="NoResize" @@ -32,7 +33,7 @@ Height="28" Margin="12,-4,-4,0" VerticalAlignment="Top" - ToolTip="Close" + ToolTip="{localization:Loc Common_Close}" IsCancel="True" Style="{StaticResource IconButton}"> - - -