diff --git a/README.md b/README.md index 6bc52c5..fb40c1b 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,12 @@ of `codex.cmd` or `codex.exe`. - Remaining percentage and reset time for every general Codex usage window - A selectable displayed limit shared by the widget headline, taskbar label, and tray icon - Compact and detailed desktop-widget layouts selected from Settings or the quick chevron, - including credits, spend controls, earned resets, token activity, and model-specific limits - when Codex returns them + including credits, spend controls, earned-reset expiration and redemption, 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 +- Windows regional, 24-hour, and 12-hour time formats selected from 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,13 +67,17 @@ widget falls back to an available window when Codex does not return the selected Token activity is informational. Token counts do not map directly to the remaining subscription percentage. +When earned rate-limit resets are available, expand **Rate-limit resets** in the detailed +widget to see each expiration time. **Use reset** always requires confirmation, consumes the +selected earned reset, and lets Codex apply it to an eligible rate-limit window. + ![Codex Usage Widget taskbar label preview](docs/images/taskbar-label.png) 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. Language, 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, time format, +theme, accent color, widget layout, displayed limit, and Start with Windows changes apply as +soon as they are selected. ## Activity dots @@ -106,6 +111,9 @@ The application writes only under `%LOCALAPPDATA%\CodexUsageWidget`: - `theme.txt`: system, light, or dark theme preference - `accent-palette.txt`: selected preset accent color - `language.txt`: system, English, or Simplified Chinese language preference +- `time-format.txt`: Windows regional, 24-hour, or 12-hour time preference +- `pending-rate-limit-reset.json`: an unfinished reset attempt kept until Codex returns a + definitive outcome, so a retry cannot consume another reset - `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/README.zh-CN.md b/README.zh-CN.md index d86d694..137c50b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -32,10 +32,11 @@ Codex Usage Widget 是一款在 Windows 本地运行的小组件,通过官方 - 显示各个通用 Codex 用量周期的剩余百分比和重置时间 - 主窗口、任务栏标签和托盘图标共用一个可选择的用量限制 -- 可在设置或快捷展开按钮中切换紧凑和详情布局;详情布局会显示额度、消费限制、可用重置次数、 +- 可在设置或快捷展开按钮中切换紧凑和详情布局;详情布局会显示额度、消费限制、可用重置的到期时间与使用入口、 Token 活动以及 Codex 返回的模型专属限制 - 可在设置中选择跟随系统、浅色或深色主题,并提供五种预设强调色 - 自动检测 Windows 显示语言,不支持的语言回退到英语;也可在设置中固定使用英语或简体中文 +- 可在设置中选择 Windows 区域、24 小时制或 12 小时制时间格式 - 可移动、始终置顶的桌面小组件,以及位于 Windows 通知区域旁的紧凑任务栏标签 - 根据官方 Codex 本地生命周期钩子显示实时运行状态点 - 每两分钟自动刷新,并接收实时用量限制通知 @@ -57,10 +58,13 @@ Codex Usage Widget 是一款在 Windows 本地运行的小组件,通过官方 Token 活动仅供参考。Token 数量与订阅用量的剩余百分比并不直接对应。 +当有可用的用量限制重置时,可在详情模式中展开**用量限制重置次数**查看到期时间。选择**使用重置**后, +小组件始终要求确认,再使用所选重置,并由 Codex 将其应用到符合条件的用量限制。 + ![Codex Usage Widget 任务栏标签预览](docs/images/taskbar-label.png) 点击 `−` 按钮可将小组件移至任务栏。右键单击任务栏标签或托盘图标,可以刷新、切换显示模式、 -打开设置、检查更新或退出。也可以通过小组件上的齿轮按钮打开设置。语言、主题、强调色、小组件布局、 +打开设置、检查更新或退出。也可以通过小组件上的齿轮按钮打开设置。语言、时间格式、主题、强调色、小组件布局、 显示的用量限制和开机启动设置会在选择后立即生效。 ## 运行状态点 @@ -92,6 +96,9 @@ Token 活动仅供参考。Token 数量与订阅用量的剩余百分比并不 - `theme.txt`:跟随系统、浅色或深色主题偏好 - `accent-palette.txt`:所选的预设强调色 - `language.txt`:跟随系统、英语或简体中文语言偏好 +- `time-format.txt`:Windows 区域、24 小时制或 12 小时制时间偏好 +- `pending-rate-limit-reset.json`:在 Codex 返回明确结果前保存未完成的重置尝试, + 防止重试时使用另一个重置 - `logs\codex-usage-widget-YYYYMMDD.log`:诊断日志,保留 14 天 小组件显示 ChatGPT 和 Codex 订阅用量限制,不显示 OpenAI API 账单或 API Key 用量。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4d01bb..c4eb360 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,7 +13,7 @@ src/CodexUsageWidget/ ├── Infrastructure/ │ ├── Codex/ App-server integration plus lifecycle-hook parsing and local IPC │ ├── Logging/ Local file diagnostics -│ ├── Settings/ Persistent display preference +│ ├── Settings/ Persistent preferences and pending reset attempts │ └── Windows/ Tray icon and taskbar Win32 integration └── Views/ WPF shell, presentation models and focused controls tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence @@ -27,27 +27,34 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence the widget object graph. 2. `UsageMonitor` owns refresh scheduling, timeout handling and refresh coalescing. 3. `CodexUsageProvider` coordinates required rate-limit reads and optional token-activity reads. -4. `CodexAppServerSession` owns initialized app-server connection lifetime. -5. `JsonRpcConnection` owns stdin/stdout request correlation and process lifetime. -6. Endpoint-specific parsers convert Codex payloads into domain records. -7. A path-independent PowerShell hook bridge forwards minimal lifecycle signals to +4. `RateLimitResetUseCase` coordinates explicit redemption, normalizes failures for the view, + and waits for a fresh usage read after every definitive outcome. +5. `CodexRateLimitResetConsumer` owns the app-server request while + `RateLimitResetAttemptStore` durably keeps one idempotency key for retries until the server + returns a definitive outcome. +6. `CodexAppServerSession` owns initialized app-server connection lifetime. +7. `JsonRpcConnection` owns stdin/stdout request correlation and process lifetime. +8. Endpoint-specific parsers convert Codex payloads into domain records. +9. A path-independent PowerShell hook bridge forwards minimal lifecycle signals to `CodexActivityPipeSignalSource` over a current-user-only named pipe; `CodexActivityMonitor` owns one active turn per session and emits only final boolean transitions. -8. `CodexActivityHookSetupService` coordinates reviewable hook-file changes and reads +10. `CodexActivityHookSetupService` coordinates reviewable hook-file changes and reads trust state through `hooks/list`; `CodexHookTrustStatusParser` owns the protocol shape. -9. `ActivityHookSetupControl` presents setup status inside Settings while a separate review +11. `ActivityHookSetupControl` presents setup status inside Settings while a separate review dialog shows the exact proposed file content before installation or removal. -10. `UsageWidgetViewModel` maps snapshots to immutable presentation state. -11. `AppThemeController` applies the saved system, light, or dark theme plus the selected +12. `UsageWidgetViewModel` maps snapshots to immutable presentation state. +13. `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. `AppLanguageController` resolves the saved system, English, or Simplified Chinese +14. `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. +15. `TimeTextFormatter` applies the saved Windows regional, 24-hour, or 12-hour clock + preference to user-visible times while protocol and diagnostic timestamps remain unchanged. +16. `MainWindow` remains a window-lifecycle shell while the Settings window coordinates + activity-hook setup plus immediate theme, accent, time-format, widget-layout, + displayed-limit, and Windows startup preferences. Focused user controls render compact, + detailed, and repeated limit-row content. ## Dependency direction @@ -63,6 +70,12 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence - Failed app-server startup is disposed before a later refresh reconnects. - Optional token-activity failures degrade only the detailed activity section; core rate-limit monitoring remains available. +- Usage preview mode owns synthetic reset credits and redemption outcomes, so UI tests and + manual preview checks never consume a real account reset. +- Redemption writes its idempotency key before contacting Codex, preserves it across app + restarts after an uncertain response, and removes it only after a definitive outcome. +- Post-redemption refresh waits behind an active usage read instead of discarding the request, + so the view cannot offer a consumed credit again from a stale snapshot. - A semaphore prevents concurrent refreshes and a mutex prevents duplicate apps. - Activity hook IPC is bounded and local to the current Windows user. The hook path does not depend on the release extraction directory and does not start WPF. Accepted clients are diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index 81678cd..dd66e38 100644 --- a/src/CodexUsageWidget/App.xaml.cs +++ b/src/CodexUsageWidget/App.xaml.cs @@ -53,17 +53,24 @@ protected override void OnStartup(StartupEventArgs e) var appServerSession = new CodexAppServerSession(); var codexUsageProvider = new CodexUsageProvider(appServerSession); IUsageProvider usageProvider = codexUsageProvider; + IRateLimitResetConsumer resetConsumer = + new CodexRateLimitResetConsumer( + appServerSession, + new RateLimitResetAttemptStore()); var usagePreviewEnabled = false; #if DEBUG || USAGE_PREVIEW if (e.Args.Contains("--preview-usage", StringComparer.OrdinalIgnoreCase)) { usagePreviewEnabled = true; - usageProvider = new PreviewUsageProvider(codexUsageProvider); + var previewProvider = new PreviewUsageProvider(codexUsageProvider); + usageProvider = previewProvider; + resetConsumer = previewProvider; _logger.Info("Usage preview mode is active."); } #endif var usageMonitor = new UsageMonitor(usageProvider); usageMonitor.DiagnosticMessage += (_, message) => _logger.Info(message); + var resetUseCase = new RateLimitResetUseCase(resetConsumer, usageMonitor); activityMonitor = new CodexActivityMonitor(new CodexActivityPipeSignalSource()); activityMonitor.DiagnosticMessage += (_, message) => _logger.Info(message); @@ -89,6 +96,7 @@ protected override void OnStartup(StartupEventArgs e) var window = new MainWindow( usageMonitor, + resetUseCase, activityMonitor, activityHookSetupService, new CodexCliLauncher(), @@ -98,7 +106,8 @@ protected override void OnStartup(StartupEventArgs e) startupRegistrationService, new TrayIconService(), _themeController, - _languageController); + _languageController, + new TimeFormatPreferenceStore()); MainWindow = window; activityMonitor.StartAsync().GetAwaiter().GetResult(); window.Show(); diff --git a/src/CodexUsageWidget/Application/IRateLimitResetConsumer.cs b/src/CodexUsageWidget/Application/IRateLimitResetConsumer.cs new file mode 100644 index 0000000..5d61f54 --- /dev/null +++ b/src/CodexUsageWidget/Application/IRateLimitResetConsumer.cs @@ -0,0 +1,8 @@ +namespace CodexUsageWidget.Application; + +public interface IRateLimitResetConsumer +{ + Task ConsumeAsync( + string? creditId, + CancellationToken cancellationToken = default); +} diff --git a/src/CodexUsageWidget/Application/RateLimitResetOutcome.cs b/src/CodexUsageWidget/Application/RateLimitResetOutcome.cs new file mode 100644 index 0000000..09ca5c5 --- /dev/null +++ b/src/CodexUsageWidget/Application/RateLimitResetOutcome.cs @@ -0,0 +1,9 @@ +namespace CodexUsageWidget.Application; + +public enum RateLimitResetOutcome +{ + Reset, + AlreadyRedeemed, + NothingToReset, + NoCredit +} diff --git a/src/CodexUsageWidget/Application/RateLimitResetUseCase.cs b/src/CodexUsageWidget/Application/RateLimitResetUseCase.cs new file mode 100644 index 0000000..ac4fb1c --- /dev/null +++ b/src/CodexUsageWidget/Application/RateLimitResetUseCase.cs @@ -0,0 +1,54 @@ +namespace CodexUsageWidget.Application; + +public sealed class RateLimitResetUseCase +{ + private readonly IRateLimitResetConsumer _consumer; + private readonly UsageMonitor _usageMonitor; + private readonly TimeSpan _requestTimeout; + + public RateLimitResetUseCase( + IRateLimitResetConsumer consumer, + UsageMonitor usageMonitor, + TimeSpan? requestTimeout = null) + { + _consumer = consumer; + _usageMonitor = usageMonitor; + _requestTimeout = requestTimeout ?? TimeSpan.FromSeconds(12); + } + + public async Task UseAsync( + string? creditId, + CancellationToken cancellationToken = default) + { + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(_requestTimeout); + var outcome = await _consumer.ConsumeAsync(creditId, timeout.Token) + .ConfigureAwait(false); + + await _usageMonitor.RefreshAfterCurrentAsync(cancellationToken) + .ConfigureAwait(false); + return new RateLimitResetUseResult(outcome switch + { + RateLimitResetOutcome.Reset => RateLimitResetUseStatus.Reset, + RateLimitResetOutcome.AlreadyRedeemed => RateLimitResetUseStatus.AlreadyRedeemed, + RateLimitResetOutcome.NothingToReset => RateLimitResetUseStatus.NothingToReset, + RateLimitResetOutcome.NoCredit => RateLimitResetUseStatus.NoCredit, + _ => throw new InvalidOperationException( + $"Unknown rate-limit reset outcome: {outcome}.") + }); + } + catch (OperationCanceledException) + { + return new RateLimitResetUseResult(RateLimitResetUseStatus.TimedOut); + } + catch (Exception ex) + { + return new RateLimitResetUseResult( + RateLimitResetUseStatus.Failed, + ex.Message); + } + } +} diff --git a/src/CodexUsageWidget/Application/RateLimitResetUseResult.cs b/src/CodexUsageWidget/Application/RateLimitResetUseResult.cs new file mode 100644 index 0000000..77c5ae3 --- /dev/null +++ b/src/CodexUsageWidget/Application/RateLimitResetUseResult.cs @@ -0,0 +1,15 @@ +namespace CodexUsageWidget.Application; + +public enum RateLimitResetUseStatus +{ + Reset, + AlreadyRedeemed, + NothingToReset, + NoCredit, + TimedOut, + Failed +} + +public sealed record RateLimitResetUseResult( + RateLimitResetUseStatus Status, + string? ErrorMessage = null); diff --git a/src/CodexUsageWidget/Application/TimeFormatPreference.cs b/src/CodexUsageWidget/Application/TimeFormatPreference.cs new file mode 100644 index 0000000..375074d --- /dev/null +++ b/src/CodexUsageWidget/Application/TimeFormatPreference.cs @@ -0,0 +1,8 @@ +namespace CodexUsageWidget.Application; + +public enum TimeFormatPreference +{ + Automatic, + TwentyFourHour, + TwelveHour +} diff --git a/src/CodexUsageWidget/Application/TimeTextFormatter.cs b/src/CodexUsageWidget/Application/TimeTextFormatter.cs new file mode 100644 index 0000000..6d8f649 --- /dev/null +++ b/src/CodexUsageWidget/Application/TimeTextFormatter.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using CodexUsageWidget.Localization; + +namespace CodexUsageWidget.Application; + +public static class TimeTextFormatter +{ + public static string FormatTime( + DateTimeOffset value, + TimeFormatPreference preference = TimeFormatPreference.Automatic, + CultureInfo? culture = null, + CultureInfo? windowsRegionalCulture = null) + { + if (preference == TimeFormatPreference.TwelveHour) + { + return FormatTwelveHour(value, includeSeconds: false, culture); + } + + var format = preference == TimeFormatPreference.TwentyFourHour ? "HH:mm" : "t"; + var effectiveCulture = preference == TimeFormatPreference.Automatic + ? windowsRegionalCulture ?? Strings.Current.WindowsRegionalCulture + : culture ?? CultureInfo.CurrentCulture; + return value.ToString(format, effectiveCulture); + } + + public static string FormatTimeWithSeconds( + DateTimeOffset value, + TimeFormatPreference preference = TimeFormatPreference.Automatic, + CultureInfo? culture = null, + CultureInfo? windowsRegionalCulture = null) + { + if (preference == TimeFormatPreference.TwelveHour) + { + return FormatTwelveHour(value, includeSeconds: true, culture); + } + + var format = preference == TimeFormatPreference.TwentyFourHour ? "HH:mm:ss" : "T"; + var effectiveCulture = preference == TimeFormatPreference.Automatic + ? windowsRegionalCulture ?? Strings.Current.WindowsRegionalCulture + : culture ?? CultureInfo.CurrentCulture; + return value.ToString(format, effectiveCulture); + } + + public static string FormatDayAndTime( + DateTimeOffset value, + TimeFormatPreference preference = TimeFormatPreference.Automatic, + CultureInfo? culture = null, + CultureInfo? windowsRegionalCulture = null) + { + var effectiveCulture = culture ?? CultureInfo.CurrentCulture; + return $"{value.ToString("ddd", effectiveCulture)} " + + FormatTime(value, preference, effectiveCulture, windowsRegionalCulture); + } + + private static string FormatTwelveHour( + DateTimeOffset value, + bool includeSeconds, + CultureInfo? culture) + { + var effectiveCulture = culture ?? CultureInfo.CurrentCulture; + var timePattern = includeSeconds ? "h:mm:ss" : "h:mm"; + var format = effectiveCulture.TwoLetterISOLanguageName == "zh" + ? $"tt {timePattern}" + : $"{timePattern} tt"; + return value.ToString(format, effectiveCulture); + } +} diff --git a/src/CodexUsageWidget/Application/UsageMonitor.cs b/src/CodexUsageWidget/Application/UsageMonitor.cs index 573ec96..060450b 100644 --- a/src/CodexUsageWidget/Application/UsageMonitor.cs +++ b/src/CodexUsageWidget/Application/UsageMonitor.cs @@ -55,6 +55,17 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default) return; } + await RefreshWithGateHeldAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task RefreshAfterCurrentAsync(CancellationToken cancellationToken = default) + { + await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await RefreshWithGateHeldAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task RefreshWithGateHeldAsync(CancellationToken cancellationToken) + { try { RefreshStarted?.Invoke(); diff --git a/src/CodexUsageWidget/Application/UsageTextFormatter.cs b/src/CodexUsageWidget/Application/UsageTextFormatter.cs index 3f05ca3..fa5272c 100644 --- a/src/CodexUsageWidget/Application/UsageTextFormatter.cs +++ b/src/CodexUsageWidget/Application/UsageTextFormatter.cs @@ -23,7 +23,10 @@ public static string ToFriendlyError(string message) return Strings.Format("Error_Unexpected", detail); } - public static string FormatReset(DateTimeOffset reset, DateTimeOffset? now = null) + public static string FormatReset( + DateTimeOffset reset, + DateTimeOffset? now = null, + TimeFormatPreference timeFormatPreference = TimeFormatPreference.Automatic) { var remaining = reset - (now ?? DateTimeOffset.Now); if (remaining <= TimeSpan.Zero) @@ -36,12 +39,12 @@ public static string FormatReset(DateTimeOffset reset, DateTimeOffset? now = nul return Strings.Format( "Usage_ResetsInHours", Math.Max(1, (int)Math.Ceiling(remaining.TotalHours)), - reset.ToString("HH:mm", CultureInfo.CurrentCulture)); + TimeTextFormatter.FormatTime(reset, timeFormatPreference)); } return Strings.Format( "Usage_ResetsAt", - reset.ToString("ddd HH:mm", CultureInfo.CurrentCulture)); + TimeTextFormatter.FormatDayAndTime(reset, timeFormatPreference)); } public static string ColorForRemaining(double remainingPercent) => remainingPercent switch diff --git a/src/CodexUsageWidget/Application/WidgetVisibilityController.cs b/src/CodexUsageWidget/Application/WidgetVisibilityController.cs index ec89e25..6ee9aa2 100644 --- a/src/CodexUsageWidget/Application/WidgetVisibilityController.cs +++ b/src/CodexUsageWidget/Application/WidgetVisibilityController.cs @@ -15,9 +15,11 @@ public WidgetVisibilityController(Func isVisible, Action show, Action hide public void Show() => _show(); - public void HideOnDeactivated(bool taskbarInteractionInProgress) + public void HideOnDeactivated( + bool taskbarInteractionInProgress, + bool ownedDialogOpen = false) { - if (!taskbarInteractionInProgress) + if (!taskbarInteractionInProgress && !ownedDialogOpen) { _hide(); } diff --git a/src/CodexUsageWidget/Infrastructure/AppPaths.cs b/src/CodexUsageWidget/Infrastructure/AppPaths.cs index ff2aee1..a11d0aa 100644 --- a/src/CodexUsageWidget/Infrastructure/AppPaths.cs +++ b/src/CodexUsageWidget/Infrastructure/AppPaths.cs @@ -20,7 +20,15 @@ public static class AppPaths public static string LanguagePreferenceFile => Path.Combine(LocalDataDirectory, "language.txt"); + public static string TimeFormatPreferenceFile => Path.Combine( + LocalDataDirectory, + "time-format.txt"); + public static string AccentPaletteFile => Path.Combine(LocalDataDirectory, "accent-palette.txt"); + public static string PendingRateLimitResetAttemptFile => Path.Combine( + LocalDataDirectory, + "pending-rate-limit-reset.json"); + public static string LogDirectory => Path.Combine(LocalDataDirectory, "logs"); } diff --git a/src/CodexUsageWidget/Infrastructure/Codex/CodexRateLimitResetConsumer.cs b/src/CodexUsageWidget/Infrastructure/Codex/CodexRateLimitResetConsumer.cs new file mode 100644 index 0000000..848cd6f --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/CodexRateLimitResetConsumer.cs @@ -0,0 +1,47 @@ +using CodexUsageWidget.Application; +using CodexUsageWidget.Infrastructure.Settings; + +namespace CodexUsageWidget.Infrastructure.Codex; + +public sealed class CodexRateLimitResetConsumer( + ICodexAppServerSession session, + RateLimitResetAttemptStore attemptStore) : IRateLimitResetConsumer +{ + public async Task ConsumeAsync( + string? creditId, + CancellationToken cancellationToken = default) + { + if (creditId is not null && string.IsNullOrWhiteSpace(creditId)) + { + throw new ArgumentException("Credit ID cannot be empty.", nameof(creditId)); + } + + var idempotencyKey = attemptStore.GetOrCreate(creditId); + var parameters = new Dictionary + { + ["idempotencyKey"] = idempotencyKey + }; + if (creditId is not null) + { + parameters["creditId"] = creditId; + } + + var result = await session.RequestAsync( + "account/rateLimitResetCredit/consume", + parameters, + cancellationToken) + .ConfigureAwait(false); + + var outcome = result.GetProperty("outcome").GetString() switch + { + "reset" => RateLimitResetOutcome.Reset, + "alreadyRedeemed" => RateLimitResetOutcome.AlreadyRedeemed, + "nothingToReset" => RateLimitResetOutcome.NothingToReset, + "noCredit" => RateLimitResetOutcome.NoCredit, + var unknownOutcome => throw new InvalidOperationException( + $"Unknown rate-limit reset outcome: {unknownOutcome ?? ""}.") + }; + attemptStore.Complete(creditId); + return outcome; + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs b/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs index 37f6092..d8a1a06 100644 --- a/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs +++ b/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs @@ -3,10 +3,12 @@ namespace CodexUsageWidget.Infrastructure.Preview; -public sealed class PreviewUsageProvider : IUsageProvider +public sealed class PreviewUsageProvider : IUsageProvider, IRateLimitResetConsumer { private readonly IUsageProvider _wrappedProvider; private readonly TimeProvider _timeProvider; + private readonly object _resetCreditsLock = new(); + private readonly List _resetCredits; private bool _disposed; public PreviewUsageProvider( @@ -15,6 +17,13 @@ public PreviewUsageProvider( { _wrappedProvider = wrappedProvider; _timeProvider = timeProvider ?? TimeProvider.System; + var now = _timeProvider.GetLocalNow(); + _resetCredits = + [ + CreateResetCredit("preview-reset-1", now, now.AddDays(7)), + CreateResetCredit("preview-reset-2", now, now.AddDays(18)), + CreateResetCredit("preview-reset-3", now, now.AddDays(32)) + ]; } public event EventHandler? RateLimitsChanged @@ -35,6 +44,14 @@ public Task ReadUsageAsync(CancellationToken cancellationToken = cancellationToken.ThrowIfCancellationRequested(); var now = _timeProvider.GetLocalNow(); + ResetCreditSummary resetCredits; + lock (_resetCreditsLock) + { + resetCredits = new ResetCreditSummary( + _resetCredits.Count, + _resetCredits.ToArray()); + } + var snapshot = new UsageSnapshot( new UsageRateLimits( [ @@ -60,13 +77,51 @@ public Task ReadUsageAsync(CancellationToken cancellationToken = SpendControlReached: null) ], PlanType: "preview", - ResetCredits: null), + ResetCredits: resetCredits), TokenActivity: null, FetchedAt: now); return Task.FromResult(snapshot); } + public Task ConsumeAsync( + string? creditId, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + + lock (_resetCreditsLock) + { + var selected = creditId is null + ? _resetCredits + .OrderBy(credit => credit.ExpiresAt is null) + .ThenBy(credit => credit.ExpiresAt) + .FirstOrDefault() + : _resetCredits.FirstOrDefault(credit => + string.Equals(credit.Id, creditId, StringComparison.Ordinal)); + if (selected is null) + { + return Task.FromResult(RateLimitResetOutcome.NoCredit); + } + + _resetCredits.Remove(selected); + } + + return Task.FromResult(RateLimitResetOutcome.Reset); + } + + private static RateLimitResetCredit CreateResetCredit( + string id, + DateTimeOffset grantedAt, + DateTimeOffset expiresAt) => new( + id, + "available", + grantedAt, + expiresAt, + "Rate-limit reset", + "Reset an eligible Codex rate-limit window."); + public ValueTask DisposeAsync() { if (_disposed) diff --git a/src/CodexUsageWidget/Infrastructure/Settings/RateLimitResetAttemptStore.cs b/src/CodexUsageWidget/Infrastructure/Settings/RateLimitResetAttemptStore.cs new file mode 100644 index 0000000..d2ec99a --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/RateLimitResetAttemptStore.cs @@ -0,0 +1,141 @@ +using System.IO; +using System.Text.Json; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public sealed class RateLimitResetAttemptStore +{ + private readonly object _sync = new(); + private readonly string _path; + private List? _attempts; + + public RateLimitResetAttemptStore(string? path = null) + { + _path = Path.GetFullPath(path ?? AppPaths.PendingRateLimitResetAttemptFile); + } + + public string GetOrCreate(string? creditId) + { + lock (_sync) + { + var attempts = GetAttempts(); + var existing = attempts.FirstOrDefault(attempt => + string.Equals(attempt.CreditId, creditId, StringComparison.Ordinal)); + if (existing is not null) + { + return existing.IdempotencyKey; + } + + var created = new PersistedRateLimitResetAttempt( + creditId, + Guid.NewGuid().ToString()); + var updated = attempts.Append(created).ToList(); + Save(updated); + _attempts = updated; + return created.IdempotencyKey; + } + } + + public void Complete(string? creditId) + { + lock (_sync) + { + var attempts = GetAttempts(); + var updated = attempts.Where(attempt => + !string.Equals(attempt.CreditId, creditId, StringComparison.Ordinal)) + .ToList(); + if (updated.Count != attempts.Count) + { + Save(updated); + _attempts = updated; + } + } + } + + private List GetAttempts() => + _attempts ??= Load(); + + private List Load() + { + if (!File.Exists(_path)) + { + return []; + } + + try + { + var attempts = JsonSerializer.Deserialize>( + File.ReadAllText(_path)) ?? + throw new InvalidDataException( + "The pending rate-limit reset attempt file is empty."); + if (attempts.Any(attempt => + string.IsNullOrWhiteSpace(attempt.IdempotencyKey)) || + attempts.GroupBy(attempt => attempt.CreditId, StringComparer.Ordinal) + .Any(group => group.Count() > 1)) + { + throw new InvalidDataException( + "The pending rate-limit reset attempt file is invalid."); + } + + return attempts; + } + catch (JsonException ex) + { + throw new InvalidDataException( + "The pending rate-limit reset attempt file is invalid.", + ex); + } + } + + private void Save(IReadOnlyList attempts) + { + var directory = Path.GetDirectoryName(_path)!; + Directory.CreateDirectory(directory); + var temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(_path)}.{Guid.NewGuid():N}.tmp"); + try + { + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None)) + { + JsonSerializer.Serialize(stream, attempts); + stream.Flush(flushToDisk: true); + } + + if (File.Exists(_path)) + { + File.Replace(temporaryPath, _path, destinationBackupFileName: null); + } + else + { + File.Move(temporaryPath, _path); + } + } + finally + { + TryDeleteTemporaryFile(temporaryPath); + } + } + + private static void TryDeleteTemporaryFile(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private sealed record PersistedRateLimitResetAttempt( + string? CreditId, + string IdempotencyKey); +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/TimeFormatPreferenceStore.cs b/src/CodexUsageWidget/Infrastructure/Settings/TimeFormatPreferenceStore.cs new file mode 100644 index 0000000..dc85216 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/TimeFormatPreferenceStore.cs @@ -0,0 +1,57 @@ +using System.IO; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public sealed class TimeFormatPreferenceStore +{ + private readonly string _path; + + public TimeFormatPreferenceStore(string? path = null) + { + _path = path ?? AppPaths.TimeFormatPreferenceFile; + } + + public TimeFormatPreference Load() + { + try + { + return File.ReadAllText(_path).Trim().ToLowerInvariant() switch + { + "24-hour" => TimeFormatPreference.TwentyFourHour, + "12-hour" => TimeFormatPreference.TwelveHour, + _ => TimeFormatPreference.Automatic + }; + } + catch (IOException) + { + return TimeFormatPreference.Automatic; + } + catch (UnauthorizedAccessException) + { + return TimeFormatPreference.Automatic; + } + } + + public void Save(TimeFormatPreference preference) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText( + _path, + preference switch + { + TimeFormatPreference.TwentyFourHour => "24-hour", + TimeFormatPreference.TwelveHour => "12-hour", + _ => "automatic" + }); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/CodexUsageWidget/Localization/AppLanguageController.cs b/src/CodexUsageWidget/Localization/AppLanguageController.cs index 0811630..02fc275 100644 --- a/src/CodexUsageWidget/Localization/AppLanguageController.cs +++ b/src/CodexUsageWidget/Localization/AppLanguageController.cs @@ -7,23 +7,28 @@ public sealed class AppLanguageController { private readonly LanguagePreferenceStore? _store; private readonly CultureInfo _systemUiCulture; + private readonly CultureInfo _windowsRegionalCulture; public AppLanguageController( LanguagePreferenceStore store, - CultureInfo? systemUiCulture = null) + CultureInfo? systemUiCulture = null, + CultureInfo? windowsRegionalCulture = null) { ArgumentNullException.ThrowIfNull(store); _store = store; _systemUiCulture = systemUiCulture ?? CultureInfo.CurrentUICulture; + _windowsRegionalCulture = windowsRegionalCulture ?? CultureInfo.CurrentCulture; Preference = store.Load(); ApplyPreference(); } public AppLanguageController( LanguagePreference preference, - CultureInfo? systemUiCulture = null) + CultureInfo? systemUiCulture = null, + CultureInfo? windowsRegionalCulture = null) { _systemUiCulture = systemUiCulture ?? CultureInfo.CurrentUICulture; + _windowsRegionalCulture = windowsRegionalCulture ?? CultureInfo.CurrentCulture; Preference = preference; ApplyPreference(); } @@ -45,6 +50,8 @@ private void ApplyPreference() var cultureName = EffectiveLanguage == LanguagePreference.SimplifiedChinese ? "zh-CN" : "en-US"; - Strings.Current.SetCulture(CultureInfo.GetCultureInfo(cultureName)); + Strings.Current.SetCulture( + CultureInfo.GetCultureInfo(cultureName), + _windowsRegionalCulture); } } diff --git a/src/CodexUsageWidget/Localization/Strings.cs b/src/CodexUsageWidget/Localization/Strings.cs index 4cb00bb..34c301d 100644 --- a/src/CodexUsageWidget/Localization/Strings.cs +++ b/src/CodexUsageWidget/Localization/Strings.cs @@ -10,6 +10,7 @@ public sealed class Strings : INotifyPropertyChanged "CodexUsageWidget.Resources.Strings", typeof(Strings).Assembly); private CultureInfo _culture = CultureInfo.GetCultureInfo("en-US"); + private CultureInfo _windowsRegionalCulture = CultureInfo.CurrentCulture; private Strings() { @@ -23,6 +24,8 @@ private Strings() public CultureInfo Culture => _culture; + public CultureInfo WindowsRegionalCulture => _windowsRegionalCulture; + public static string Get(string key) => ResourceManager.GetString(key, Current._culture) ?? key; @@ -38,9 +41,16 @@ public static string FormatForCulture( params object?[] arguments) => string.Format(culture, GetForCulture(key, culture), arguments); - public void SetCulture(CultureInfo culture) + public void SetCulture( + CultureInfo culture, + CultureInfo? windowsRegionalCulture = null) { ArgumentNullException.ThrowIfNull(culture); + if (windowsRegionalCulture is not null) + { + _windowsRegionalCulture = windowsRegionalCulture; + } + var changed = !string.Equals( _culture.Name, culture.Name, diff --git a/src/CodexUsageWidget/Resources/Strings.resx b/src/CodexUsageWidget/Resources/Strings.resx index 6fb1883..abc78d9 100644 --- a/src/CodexUsageWidget/Resources/Strings.resx +++ b/src/CodexUsageWidget/Resources/Strings.resx @@ -63,6 +63,20 @@ Spend limit resets Rate-limit resets {0} available + Next reset expires {0:MMM d} at {1} + Expires {0:MMM d} at {1} + Expiration unavailable + Use reset + Use next reset + View available resets + Hide available resets + Use this rate-limit reset? + This reset expires {0:MMM d} at {1}. It will be applied to an eligible Codex limit and cannot be recovered. + This reset will be applied to an eligible Codex limit and cannot be recovered. + Codex will select the next available reset. It will be applied to an eligible Codex limit and cannot be recovered. + No eligible Codex limit can be reset right now. Your reset was not used. + This rate-limit reset is no longer available. + The rate-limit reset could not be used. {0} OVERVIEW CREDITS & LIMITS TOKEN ACTIVITY @@ -136,6 +150,17 @@ 简体中文 Always use Simplified Chinese Use Simplified Chinese + Time format + Choose how times are shown throughout the widget. + Use Windows setting + Uses your Windows regional time format + Use Windows time format + 24-hour + Show times such as 14:05 + Use 24-hour time format + 12-hour + Show times such as 2:05 PM + Use 12-hour time format 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. diff --git a/src/CodexUsageWidget/Resources/Strings.zh-CN.resx b/src/CodexUsageWidget/Resources/Strings.zh-CN.resx index 51eeff0..e3851ea 100644 --- a/src/CodexUsageWidget/Resources/Strings.zh-CN.resx +++ b/src/CodexUsageWidget/Resources/Strings.zh-CN.resx @@ -63,6 +63,20 @@ 消费限制重置时间 用量限制重置次数 可用 {0} + 下一个重置将于 {0:M月d日} {1} 到期 + 到期时间:{0:M月d日} {1} + 到期时间不可用 + 使用重置 + 使用下一个重置 + 查看可用重置 + 隐藏可用重置 + 使用此用量限制重置? + 此重置将于 {0:M月d日} {1} 到期。它将应用于符合条件的 Codex 用量限制,使用后无法恢复。 + 此重置将应用于符合条件的 Codex 用量限制,使用后无法恢复。 + Codex 将选择下一个可用重置,并将其应用于符合条件的用量限制。使用后无法恢复。 + 目前没有可重置的符合条件的 Codex 用量限制。你的重置尚未使用。 + 此用量限制重置已不可用。 + 无法使用用量限制重置:{0} 概览 额度与限制 TOKEN 活动 @@ -136,6 +150,17 @@ 简体中文 始终使用简体中文 使用简体中文 + 时间格式 + 选择小组件中时间的显示方式。 + 跟随 Windows + 使用 Windows 区域时间格式 + 使用 Windows 时间格式 + 24 小时制 + 以 14:05 等格式显示时间 + 使用 24 小时制 + 12 小时制 + 以 下午 2:05 等格式显示时间 + 使用 12 小时制 Codex 运行状态 Codex 本地任务运行时显示状态点 隐私说明:只有活动类型、会话 ID 和任务 ID 会通过仅限当前用户的本地管道传输。提示词和回复始终留在 Codex 中。 diff --git a/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml b/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml index dfad399..1828857 100644 --- a/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml +++ b/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml @@ -24,26 +24,12 @@ + Style="{StaticResource DialogTitle}" /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CodexUsageWidget/Views/MainWindow.xaml.cs b/src/CodexUsageWidget/Views/MainWindow.xaml.cs index 4be39d8..3b554a3 100644 --- a/src/CodexUsageWidget/Views/MainWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/MainWindow.xaml.cs @@ -20,12 +20,14 @@ public partial class MainWindow : Window private const double DetailedHeight = 620d; private readonly UsageMonitor _usageMonitor; + private readonly RateLimitResetUseCase _resetUseCase; private readonly CodexActivityMonitor _activityMonitor; private readonly IActivityHookSetupService _activityHookSetupService; private readonly ICodexLauncher _codexLauncher; private readonly DisplayModeStore _displayModeStore; private readonly WidgetDensityStore _densityStore; private readonly DisplayedLimitPreferenceStore _displayedLimitPreferenceStore; + private readonly TimeFormatPreferenceStore _timeFormatPreferenceStore; private readonly StartupRegistrationService _startupRegistration; private readonly TrayIconService _trayIcon; private readonly AppThemeController _themeController; @@ -37,15 +39,19 @@ public partial class MainWindow : Window private WidgetDisplayMode _displayMode; private WidgetDensity _density; private DisplayedLimitPreference _displayedLimitPreference; + private TimeFormatPreference _timeFormatPreference; private UsageSnapshot? _latestSnapshot; private UsageWidgetViewModel _viewModel = UsageWidgetViewModel.Loading(); private bool _isRealActivityActive; private bool _isActivityPreviewEnabled; private bool _isSettingsOpen; + private bool _isResetDialogOpen; + private bool _resetUsePending; private bool _shutdownStarted; public MainWindow( UsageMonitor usageMonitor, + RateLimitResetUseCase resetUseCase, CodexActivityMonitor activityMonitor, IActivityHookSetupService activityHookSetupService, ICodexLauncher codexLauncher, @@ -55,9 +61,11 @@ public MainWindow( StartupRegistrationService startupRegistration, TrayIconService trayIcon, AppThemeController themeController, - AppLanguageController languageController) + AppLanguageController languageController, + TimeFormatPreferenceStore timeFormatPreferenceStore) { _usageMonitor = usageMonitor; + _resetUseCase = resetUseCase; _activityMonitor = activityMonitor; _activityHookSetupService = activityHookSetupService; _codexLauncher = codexLauncher; @@ -68,12 +76,15 @@ public MainWindow( _trayIcon = trayIcon; _themeController = themeController; _languageController = languageController; + _timeFormatPreferenceStore = timeFormatPreferenceStore; _displayMode = displayModeStore.Load(); _density = densityStore.Load(); _displayedLimitPreference = displayedLimitPreferenceStore.Load(); + _timeFormatPreference = timeFormatPreferenceStore.Load(); _widgetVisibility = new WidgetVisibilityController(() => IsVisible, ShowWidget, Hide); InitializeComponent(); + _taskbarLabel.SetTimeFormatPreference(_timeFormatPreference); DataContext = _viewModel; ApplyDensity(repositionBottomEdge: false); WireEvents(); @@ -90,6 +101,7 @@ private void WireEvents() _usageMonitor.RefreshStarted += UsageMonitorOnRefreshStarted; _usageMonitor.SnapshotUpdated += UsageMonitorOnSnapshotUpdated; _usageMonitor.RefreshFailed += UsageMonitorOnRefreshFailed; + DetailedView.ResetUseRequested += DetailedViewOnResetUseRequested; _activityMonitor.ActivityChanged += ActivityMonitorOnActivityChanged; _taskbarLabel.OpenRequested += (_, _) => Dispatcher.BeginInvoke(_widgetVisibility.Show, DispatcherPriority.ApplicationIdle); @@ -169,7 +181,10 @@ private void RenderSnapshot(UsageSnapshot snapshot) { _latestSnapshot = snapshot; var displayedWindow = ResolveDisplayedWindow(snapshot); - var nextViewModel = UsageWidgetViewModel.FromSnapshot(snapshot, displayedWindow); + var nextViewModel = UsageWidgetViewModel.FromSnapshot( + snapshot, + displayedWindow, + _timeFormatPreference); SetViewModel(nextViewModel); if (_density == WidgetDensity.Detailed) { @@ -342,7 +357,8 @@ private void ShowSettings() _activityHookSetupService, _codexLauncher, _themeController.AccentPalette, - _languageController.Preference) + _languageController.Preference, + _timeFormatPreference) { Owner = this }; @@ -353,6 +369,7 @@ private void ShowSettings() window.DisplayedLimitPreferenceChanged += SetDisplayedLimitPreference; window.StartWithWindowsChanged += SetStartupRegistration; window.LanguagePreferenceChanged += SetLanguagePreference; + window.TimeFormatPreferenceChanged += SetTimeFormatPreference; window.ShowDialog(); } finally @@ -440,6 +457,107 @@ private void SetLanguagePreference(LanguagePreference preference) _ = _usageMonitor.RefreshAsync(); } + private void SetTimeFormatPreference(TimeFormatPreference preference) + { + _timeFormatPreference = preference; + _timeFormatPreferenceStore.Save(preference); + _taskbarLabel.SetTimeFormatPreference(preference); + if (_latestSnapshot is { } snapshot) + { + RenderSnapshot(snapshot); + } + } + + private async void DetailedViewOnResetUseRequested( + object? sender, + Controls.RateLimitResetRequestedEventArgs e) + { + if (_resetUsePending) + { + return; + } + + if (!ShowResetConfirmation(e.Credit)) + { + return; + } + + _resetUsePending = true; + DetailedView.SetResetUsePending(pending: true); + try + { + var result = await _resetUseCase.UseAsync(e.Credit.CreditId); + if (result.Status is RateLimitResetUseStatus.NothingToReset) + { + ShowResetMessage( + Strings.Get("Usage_ResetNothingToReset"), + MessageBoxImage.Information); + } + else if (result.Status is RateLimitResetUseStatus.NoCredit) + { + ShowResetMessage( + Strings.Get("Usage_ResetNoCredit"), + MessageBoxImage.Warning); + } + else if (result.Status is RateLimitResetUseStatus.TimedOut) + { + ShowResetMessage( + Strings.Get("Error_ResponseTimeout"), + MessageBoxImage.Warning); + } + else if (result.Status is RateLimitResetUseStatus.Failed) + { + ShowResetMessage( + Strings.Format( + "Usage_ResetFailure", + result.ErrorMessage ?? string.Empty), + MessageBoxImage.Warning); + } + } + finally + { + _resetUsePending = false; + DetailedView.SetResetUsePending(pending: false); + } + } + + private bool ShowResetConfirmation(RateLimitResetCreditViewModel credit) + { + _isResetDialogOpen = true; + try + { + var confirmation = new RateLimitResetConfirmationWindow( + credit, + _timeFormatPreference) + { + Owner = this + }; + return confirmation.ShowDialog() == true; + } + finally + { + _isResetDialogOpen = false; + } + } + + private void ShowResetMessage(string message, MessageBoxImage image) + { + _isResetDialogOpen = true; + try + { + System.Windows.MessageBox.Show( + this, + message, + Strings.Get("Usage_RateLimitResets"), + MessageBoxButton.OK, + image); + } + finally + { + _isResetDialogOpen = false; + } + } + private void Widget_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (e.ButtonState != MouseButtonState.Pressed || @@ -483,10 +601,11 @@ private void HideButton_OnClick(object sender, RoutedEventArgs e) => private void MainWindowOnDeactivated(object? sender, EventArgs e) { - if (_displayMode == WidgetDisplayMode.TaskbarIndicator && - !_isSettingsOpen) + if (_displayMode == WidgetDisplayMode.TaskbarIndicator) { - _widgetVisibility.HideOnDeactivated(_taskbarLabel.IsPointerOver); + _widgetVisibility.HideOnDeactivated( + _taskbarLabel.IsPointerOver, + ownedDialogOpen: _isSettingsOpen || _isResetDialogOpen); } } diff --git a/src/CodexUsageWidget/Views/RateLimitResetConfirmationWindow.xaml b/src/CodexUsageWidget/Views/RateLimitResetConfirmationWindow.xaml new file mode 100644 index 0000000..505f1b1 --- /dev/null +++ b/src/CodexUsageWidget/Views/RateLimitResetConfirmationWindow.xaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + +