diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..abf91fb --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,9 @@ +# Codex usage monitoring + +This context describes the subscription-usage concepts presented by the widget. + +## Language + +**Displayed limit**: +The general Codex rate-limit window chosen for summary usage displays. It can be the 5-hour window, the weekly window, or the available window with the least remaining allowance. +_Avoid_: Taskbar limit, primary limit diff --git a/README.md b/README.md index 7e9fd61..446c132 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,112 @@ # Codex Usage Widget -A local-only Windows widget that shows the remaining Codex subscription allowance. -It talks directly to the official Codex app server through -`account/rateLimits/read` and listens for live `account/rateLimits/updated` -notifications. +See the Codex limits available to your account, including 5-hour and weekly windows, +without opening a browser. Codex Usage Widget runs locally on Windows and reads usage +through the official Codex CLI app server. > This is an independent utility and is not an official OpenAI application. -![Codex Usage Widget desktop preview](docs/images/desktop-widget.png) +![Codex Usage Widget showing 5-hour and weekly limits](docs/images/desktop-widget-limits.png) -## Features +## Quick start -- Remaining percentage and reset time for every general Codex rate-limit window -- Compact and detailed widget layouts with optional token-activity history -- Credit, spend-control, earned-reset, and model-specific limit details when available -- Compact, movable, always-on-top desktop widget -- Native-looking taskbar label beside the Windows notification area -- Event-driven task activity animation through official local Codex lifecycle hooks -- Immediate taskbar-label hiding while another app is fullscreen on the same monitor -- Persistent desktop/taskbar display preference -- Optional per-user start with Windows registration -- Automatic refresh every two minutes and live server notifications -- Single-instance protection to prevent overlapping labels -- Per-monitor DPI support, local diagnostic logs, and graceful CLI reconnects -- Model-specific buckets stay out of the compact view and appear only in details -- No browser automation, token scraping, telemetry, or external backend - -## Requirements - -- Windows 10 version 1809 or newer -- Codex CLI available on `PATH` -- A completed local sign-in (`codex login`) - -The portable release includes the .NET runtime. A separate .NET installation is -therefore not required on the destination computer. - -## Install on another computer +The widget requires Windows 10 version 1809 or newer, the Codex CLI on `PATH`, and a +completed local sign-in. 1. Download the [latest Windows x64 portable release](https://github.com/ognjeeen/codex-usage-widget/releases/latest/download/codex-usage-widget-win-x64.zip). -2. Start `CodexUsageWidget.exe`, either after extraction or directly from the ZIP. -3. Ensure `codex --version` works in PowerShell and run `codex login` if needed. +2. Start `CodexUsageWidget.exe` after extraction or directly from the ZIP. +3. If usage does not appear, confirm that `codex --version` works in PowerShell and run + `codex login`. -When Windows starts the executable from a temporary ZIP location, the widget atomically -copies that exact version to `%LOCALAPPDATA%\CodexUsageWidget\app\` and relaunches -the stable per-user copy. An executable already extracted to a normal directory continues -to run in place. +The portable release includes the .NET runtime. The executable is not currently +code-signed, so Windows may show an unknown-publisher warning. Each GitHub release includes +a SHA-256 checksum for verification. -Only one instance can run at a time. Starting the executable again exits quietly. +When Windows starts the executable from a temporary ZIP location, the widget copies that +version to `%LOCALAPPDATA%\CodexUsageWidget\app\` and relaunches it. An executable +started from an extracted folder continues to run there. Only one widget instance runs at +a time. -The executable is not currently code-signed, so Windows may identify the -publisher as unknown. A SHA-256 checksum is attached to every -[GitHub Release](https://github.com/ognjeeen/codex-usage-widget/releases) for -verification before running the application. +If Codex is installed outside `PATH`, set `CODEX_USAGE_WIDGET_CODEX_PATH` to the full path +of `codex.cmd` or `codex.exe`. -If Codex is installed in a non-standard location, set -`CODEX_USAGE_WIDGET_CODEX_PATH` to the full path of `codex.cmd` or `codex.exe`. +## What it shows -## Display modes +- 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 layouts, including credits, spend controls, earned resets, token + activity, and model-specific limits when Codex returns them +- 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 +- Fullscreen-aware taskbar behavior, per-monitor DPI support, and optional start with Windows +- Local logs for diagnostics, with no telemetry or remote backend -- **Desktop widget** keeps the selected widget layout visible and always on top. -- **Taskbar label** shows `Codex 75%` directly to the left of the notification area. +## Display modes and limit selection -The desktop widget has two persisted layouts: +**Desktop widget.** Shows the selected limit as the headline and keeps every general limit +visible below it. Compact mode focuses on limits; Details adds account and token activity. -- **Compact** shows the general Codex windows and highlights the most constrained one. -- **Details** adds available credits and spend controls, token activity, earned resets, - and model-specific limits such as GPT-5.3-Codex-Spark. +**Taskbar label.** Shows the same selected percentage beside the Windows notification area. +The tray icon and its tooltip follow that selection too. + +Choose `5h limit`, `Weekly limit`, or `Most constrained` from the **Displayed limit** menu. +The 5-hour window is the default. Available windows depend on the Codex account, so the +widget falls back to an available window when Codex does not return the selected one. ![Codex Usage Widget detailed preview](docs/images/detailed-widget.png) -Token activity is informational and is intentionally presented separately from quota -consumption because tokens do not map linearly to the remaining subscription percentage. +Token activity is informational. Token counts do not map directly to the remaining +subscription percentage. ![Codex Usage Widget taskbar label preview](docs/images/taskbar-label.png) -Use the `−` button to switch to taskbar mode. Right-click the taskbar label or tray -icon to refresh, change display mode, or exit. - -Choose **Start with Windows** from either menu to register the running executable for -the signed-in Windows user. The option does not require administrator rights, and turning -it off removes the registration. If an extracted portable folder moves, the path is -refreshed the next time the widget is started manually. - -## Live Codex activity dots +Select the `−` button to move the widget to the taskbar. Right-click the taskbar label or +tray icon to refresh, change the display mode or displayed limit, configure activity dots, +toggle start with Windows, check for updates, or exit. -Activity dots turn the official local Codex lifecycle hooks into an at-a-glance signal -that work is still running. They are available in both the taskbar label and desktop -widget, without polling Codex or estimating activity from rate-limit changes. +## Activity dots -### What activity dots provide +Activity dots show whether at least one local Codex turn is running. Updates come from the +official Codex lifecycle hooks through a current-user-only named pipe. The widget does not +read prompt text, responses, transcript paths, or model output. -- One quiet dot while Codex is idle, expanding into a three-dot wave during active work -- Immediate, event-driven updates when a Codex turn starts or finishes -- Independent tracking of parallel Codex sessions, so one completed session cannot hide - another session that is still running -- A completion animation only after the final active turn finishes -- A dedicated setup window for installation status, trust approval, refresh, and removal +To enable them: -### Private and local by design +1. Open **Activity dots...** from the widget, taskbar label, or tray menu. +2. Select **Install hooks** and review the exact proposed `~/.codex/hooks.json` change. +3. Select **Copy /hooks and open Codex**, paste `/hooks`, and trust the three definitions. +4. Return to the widget and select **Check again**. -- No prompts, responses, transcript contents, transcript paths, or model output are - collected, stored, forwarded, or logged -- No telemetry, analytics, browser automation, remote backend, or credential access is used -- Hook signals stay on the current Windows account through a current-user-only named pipe -- Hook handlers use a small path-independent PowerShell-to-pipe bridge and never start the - WPF widget executable for each lifecycle event -- Only the lifecycle event type and the Codex-provided session and turn identifiers are - passed to the in-memory activity monitor -- Activity state is not persisted, so the widget does not build a history of your work -- Authentication remains entirely owned by the locally installed Codex CLI +Installation is always explicit. The widget never installs hooks during normal startup. +See [Activity dots](docs/ACTIVITY_DOTS.md) for privacy details, command-line setup, removal, +and recovery behavior. -Hook installation remains an explicit, reviewable action and is never performed during -normal widget startup. +## Privacy and local data -### Setup and removal +The widget talks only to the locally installed Codex CLI. It does not scrape a browser, +read authentication secrets, send telemetry, or use a remote backend. Codex remains the +owner of authentication. -Select the three-dot activity button in the desktop widget, or choose **Activity dots...** -from the tray or taskbar-label menu. The setup window reports whether the hooks are missing, -awaiting approval, active, modified, or disabled. Select **Install hooks**, review the exact -proposed `~/.codex/hooks.json` content, and confirm the change. +The application writes only under `%LOCALAPPDATA%\CodexUsageWidget`: -After installation, select **Copy /hooks and open Codex**. Paste `/hooks` into Codex, then -review and trust the exact new `UserPromptSubmit`, `Stop`, and `SessionEnd` definitions. -New or changed definitions require new trust. Return to the setup window and select -**Check again** to verify that activity reporting is ready. +- `app\\CodexUsageWidget.exe`: stable copy used after a direct ZIP launch +- `display-mode.txt`: desktop or taskbar display preference +- `widget-density.txt`: compact or detailed widget preference +- `displayed-limit.txt`: selected summary limit +- `logs\codex-usage-widget-YYYYMMDD.log`: diagnostic logs retained for 14 days -The setup window can remove handlers generated by the current widget and conservatively -recognized handlers from earlier Codex Usage Widget portable locations. Recognition is limited -to the exact command formats historically generated for `CodexUsageWidget.exe`; similarly named -handlers from other applications, existing hooks, and unknown configuration fields are preserved. +The widget displays ChatGPT and Codex subscription limits. It does not display OpenAI API +billing or API-key usage. -For scripted setup or recovery, the existing command-line flow remains available. From -PowerShell in the directory containing the widget executable, run: +## Uninstall -```powershell -.\CodexUsageWidget.exe --install-activity-hooks -``` - -The command displays the proposed content and writes it only after interactive confirmation. - -To perform the equivalent removal from PowerShell, run: - -```powershell -.\CodexUsageWidget.exe --uninstall-activity-hooks -``` - -If the widget is closed, the hook handler exits successfully after a short bounded -connection attempt and Codex continues normally. - -Activity state is intentionally in memory only. A task that started before the widget -or hooks were ready is not reconstructed. Each session owns at most one active turn: a -later `UserPromptSubmit` replaces an orphaned turn, and a late `Stop` for the old turn -cannot clear the current one. If Codex terminates without any later lifecycle event, a -widget restart still clears the in-memory state; no arbitrary timeout is used because -legitimate tasks can run for a long time. +1. If activity hooks are installed, open **Activity dots...** and select **Remove hooks**. +2. Turn off **Start with Windows** from the widget or tray menu. +3. Exit the widget. +4. Delete the extracted application folder and `%LOCALAPPDATA%\CodexUsageWidget`. The local + data folder contains any stable copy, saved preferences, and diagnostic logs. ## Development @@ -166,50 +118,35 @@ dotnet test .\CodexUsageWidget.slnx -c Release dotnet run --project .\src\CodexUsageWidget\CodexUsageWidget.csproj ``` -Warnings are treated as errors and the recommended .NET analyzers run during every -build. - -## Portable release +To preview both general rate-limit windows without reading Codex usage, close any running +widget instance and start a local preview build: ```powershell -.\scripts\publish.ps1 -Runtime win-x64 -``` - -The script runs the complete test suite and creates: - -```text -artifacts/release/codex-usage-widget-win-x64.zip +dotnet run --project .\src\CodexUsageWidget\CodexUsageWidget.csproj -p:EnableUsagePreview=true -- --preview-usage ``` -`win-arm64` is also supported through the script's `-Runtime` parameter. +Preview data reports 80% remaining for the 5-hour limit and 15% remaining for the weekly +limit. Standard release builds do not accept the preview flag. -Maintainer release instructions are documented in -[docs/RELEASING.md](docs/RELEASING.md). +Warnings are treated as errors and the recommended .NET analyzers run during every build. -## Local data +## Build a portable release -The application only writes under `%LOCALAPPDATA%\CodexUsageWidget`: - -- `app\\CodexUsageWidget.exe` — stable copy used after a direct ZIP launch -- `display-mode.txt` — the selected display mode -- `widget-density.txt` — the selected compact or detailed widget layout -- `logs\codex-usage-widget-YYYYMMDD.log` — diagnostics, retained for 14 days +```powershell +.\scripts\publish.ps1 -Runtime win-x64 +``` -No credentials are read or stored by the widget. Authentication remains owned by -the locally installed Codex CLI. +The script runs the complete test suite and creates +`artifacts/release/codex-usage-widget-win-x64.zip`. It also supports `win-arm64` through the +`-Runtime` parameter. See [Releasing](docs/RELEASING.md) for the full maintainer workflow. ## Architecture -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for component boundaries, -runtime flow, and extension guidance. - -## Usage semantics - -This displays ChatGPT/Codex subscription rate limits. It does not display OpenAI -API billing or API-key usage, which use a different accounting system. +See [Architecture](docs/ARCHITECTURE.md) for module responsibilities, runtime flow, and +extension guidance. ## License -Released under the [MIT License](LICENSE). You may use, modify, fork, publish, -redistribute, sublicense, or sell copies of the software as long as the copyright -notice and license text are retained. +Released under the [MIT License](LICENSE). You may use, modify, fork, publish, redistribute, +sublicense, or sell copies of the software as long as you retain the copyright notice and +license text. diff --git a/docs/ACTIVITY_DOTS.md b/docs/ACTIVITY_DOTS.md new file mode 100644 index 0000000..6a7cb0b --- /dev/null +++ b/docs/ACTIVITY_DOTS.md @@ -0,0 +1,82 @@ +# Activity dots + +Activity dots show whether Codex is working without estimating activity from rate-limit +changes. The widget receives lifecycle signals from official local Codex hooks and keeps the +state in memory. + +[Back to the README](../README.md) + +## What the widget tracks + +- One quiet dot while Codex is idle and a three-dot wave while work is active +- Start and completion events for parallel Codex sessions +- One active turn per session, so one completed session cannot hide another active session +- A completion animation after the final active turn finishes + +The widget accepts only the lifecycle event type and the session and turn identifiers +provided by Codex. It does not collect prompts, responses, transcript contents, transcript +paths, or model output. + +## Set up in the widget + +1. Select the three-dot activity button in the desktop widget, or choose + **Activity dots...** from the taskbar-label or tray menu. +2. Select **Install hooks**. +3. Review the exact proposed `~/.codex/hooks.json` content and confirm the change. +4. Select **Copy /hooks and open Codex**. +5. Paste `/hooks` into Codex and trust the new `UserPromptSubmit`, `Stop`, and `SessionEnd` + definitions. +6. Return to the setup window and select **Check again**. + +Codex owns hook trust. New or changed definitions require approval in Codex. The widget only +reads the reported trust state. + +To remove the handlers, return to the setup window and select **Remove hooks**. The widget +preserves unrelated hooks and unknown configuration fields. + +## Command-line setup and removal + +The same review-before-write flow is available from PowerShell in the directory containing +the widget executable: + +```powershell +.\CodexUsageWidget.exe --install-activity-hooks +``` + +To remove the recognized Codex Usage Widget handlers: + +```powershell +.\CodexUsageWidget.exe --uninstall-activity-hooks +``` + +The setup code recognizes only the exact command formats generated by current and earlier +portable versions of Codex Usage Widget. Similarly named handlers from other applications +are left unchanged. + +## Privacy and local transport + +- Hook signals stay on the current Windows account through a current-user-only named pipe. +- A small PowerShell bridge sends the signal without starting the WPF widget for each event. +- Activity state is never persisted, so the widget does not build a work history. +- The widget does not read Codex authentication data. +- No hook data is sent to telemetry, analytics, or a remote backend. + +Hook installation is always explicit. Normal widget startup does not create or modify hook +configuration. + +## Runtime behavior and recovery + +Activity state starts empty when the widget starts. A task that began before the widget or +hooks were ready cannot be reconstructed. + +Each session owns at most one active turn. A later `UserPromptSubmit` replaces an orphaned +turn in that session, and a late `Stop` for the old turn cannot clear the new one. Duplicate +events are harmless. `SessionEnd` removes only the matching session. + +If Codex terminates without sending a final lifecycle event, a later turn in the same session +replaces the stale turn. Restarting the widget also clears all in-memory activity state. The +widget does not use an arbitrary timeout because legitimate Codex tasks can run for a long +time. + +If the widget is closed, the hook bridge exits successfully after a short connection attempt +and Codex continues normally. diff --git a/docs/images/desktop-widget-limits.png b/docs/images/desktop-widget-limits.png new file mode 100644 index 0000000..4ed73f6 Binary files /dev/null and b/docs/images/desktop-widget-limits.png differ diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index de02dac..2fd0697 100644 --- a/src/CodexUsageWidget/App.xaml.cs +++ b/src/CodexUsageWidget/App.xaml.cs @@ -4,6 +4,7 @@ using CodexUsageWidget.Infrastructure.Codex; using CodexUsageWidget.Infrastructure.Codex.Hooks; using CodexUsageWidget.Infrastructure.Logging; +using CodexUsageWidget.Infrastructure.Preview; using CodexUsageWidget.Infrastructure.Settings; using CodexUsageWidget.Infrastructure.Windows; using CodexUsageWidget.Views; @@ -36,7 +37,17 @@ protected override void OnStartup(StartupEventArgs e) try { var appServerSession = new CodexAppServerSession(); - var usageProvider = new CodexUsageProvider(appServerSession); + var codexUsageProvider = new CodexUsageProvider(appServerSession); + IUsageProvider usageProvider = codexUsageProvider; + var usagePreviewEnabled = false; +#if DEBUG || USAGE_PREVIEW + if (e.Args.Contains("--preview-usage", StringComparer.OrdinalIgnoreCase)) + { + usagePreviewEnabled = true; + usageProvider = new PreviewUsageProvider(codexUsageProvider); + _logger.Info("Usage preview mode is active."); + } +#endif var usageMonitor = new UsageMonitor(usageProvider); usageMonitor.DiagnosticMessage += (_, message) => _logger.Info(message); @@ -44,7 +55,9 @@ protected override void OnStartup(StartupEventArgs e) activityMonitor.DiagnosticMessage += (_, message) => _logger.Info(message); var processPath = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot determine the widget executable path."); - var startupRegistrationService = new StartupRegistrationService(processPath); + var startupRegistrationService = new StartupRegistrationService( + processPath, + usagePreviewEnabled ? new PreviewStartupRegistrationStore() : null); if (!startupRegistrationService.TryRefreshExecutablePathIfEnabled()) { _logger.LogError("The Windows startup registration could not be refreshed."); @@ -61,6 +74,7 @@ protected override void OnStartup(StartupEventArgs e) new CodexCliLauncher(), new DisplayModeStore(), new WidgetDensityStore(), + new DisplayedLimitPreferenceStore(), startupRegistrationService, new TrayIconService()); MainWindow = window; diff --git a/src/CodexUsageWidget/Application/DisplayedLimitPreference.cs b/src/CodexUsageWidget/Application/DisplayedLimitPreference.cs new file mode 100644 index 0000000..7cebd49 --- /dev/null +++ b/src/CodexUsageWidget/Application/DisplayedLimitPreference.cs @@ -0,0 +1,8 @@ +namespace CodexUsageWidget.Application; + +public enum DisplayedLimitPreference +{ + FiveHour, + Weekly, + MostConstrained +} diff --git a/src/CodexUsageWidget/Application/DisplayedUsageSelector.cs b/src/CodexUsageWidget/Application/DisplayedUsageSelector.cs new file mode 100644 index 0000000..477c4a2 --- /dev/null +++ b/src/CodexUsageWidget/Application/DisplayedUsageSelector.cs @@ -0,0 +1,62 @@ +using CodexUsageWidget.Domain; + +namespace CodexUsageWidget.Application; + +public static class DisplayedUsageSelector +{ + private const long FiveHourDurationMinutes = 300; + private const long WeeklyDurationThresholdMinutes = 10_000; + + public static DisplayedLimitPreference ResolvePreference( + UsageSnapshot snapshot, + DisplayedLimitPreference preference) + { + if (IsAvailable(snapshot, preference)) + { + return preference; + } + + return preference switch + { + DisplayedLimitPreference.FiveHour + when IsAvailable(snapshot, DisplayedLimitPreference.Weekly) => + DisplayedLimitPreference.Weekly, + DisplayedLimitPreference.Weekly + when IsAvailable(snapshot, DisplayedLimitPreference.FiveHour) => + DisplayedLimitPreference.FiveHour, + _ when IsAvailable(snapshot, DisplayedLimitPreference.MostConstrained) => + DisplayedLimitPreference.MostConstrained, + _ => preference + }; + } + + public static bool IsAvailable( + UsageSnapshot snapshot, + DisplayedLimitPreference preference) => preference switch + { + DisplayedLimitPreference.FiveHour => snapshot.GeneralWindows.Any( + window => window.WindowDurationMinutes == FiveHourDurationMinutes), + DisplayedLimitPreference.Weekly => snapshot.GeneralWindows.Any( + window => window.WindowDurationMinutes >= WeeklyDurationThresholdMinutes), + DisplayedLimitPreference.MostConstrained => snapshot.MostConstrainedWindow is not null, + _ => false + }; + + public static UsageWindow? Select( + UsageSnapshot snapshot, + DisplayedLimitPreference preference) + { + var fiveHour = snapshot.GeneralWindows.FirstOrDefault( + window => window.WindowDurationMinutes == FiveHourDurationMinutes); + var weekly = snapshot.GeneralWindows.FirstOrDefault( + window => window.WindowDurationMinutes >= WeeklyDurationThresholdMinutes); + + return preference switch + { + DisplayedLimitPreference.FiveHour => fiveHour ?? weekly ?? snapshot.MostConstrainedWindow, + DisplayedLimitPreference.Weekly => weekly ?? fiveHour ?? snapshot.MostConstrainedWindow, + DisplayedLimitPreference.MostConstrained => snapshot.MostConstrainedWindow, + _ => fiveHour ?? weekly ?? snapshot.MostConstrainedWindow + }; + } +} diff --git a/src/CodexUsageWidget/CodexUsageWidget.csproj b/src/CodexUsageWidget/CodexUsageWidget.csproj index 6521d82..48510cd 100644 --- a/src/CodexUsageWidget/CodexUsageWidget.csproj +++ b/src/CodexUsageWidget/CodexUsageWidget.csproj @@ -17,6 +17,7 @@ https://github.com/ognjeeen/codex-usage-widget MIT $(DefineConstants);ACTIVITY_PREVIEW + $(DefineConstants);USAGE_PREVIEW diff --git a/src/CodexUsageWidget/Infrastructure/AppPaths.cs b/src/CodexUsageWidget/Infrastructure/AppPaths.cs index 8eeec42..d7fc402 100644 --- a/src/CodexUsageWidget/Infrastructure/AppPaths.cs +++ b/src/CodexUsageWidget/Infrastructure/AppPaths.cs @@ -12,5 +12,9 @@ public static class AppPaths public static string WidgetDensityFile => Path.Combine(LocalDataDirectory, "widget-density.txt"); + public static string DisplayedLimitPreferenceFile => Path.Combine( + LocalDataDirectory, + "displayed-limit.txt"); + public static string LogDirectory => Path.Combine(LocalDataDirectory, "logs"); } diff --git a/src/CodexUsageWidget/Infrastructure/Preview/PreviewStartupRegistrationStore.cs b/src/CodexUsageWidget/Infrastructure/Preview/PreviewStartupRegistrationStore.cs new file mode 100644 index 0000000..8d68c69 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Preview/PreviewStartupRegistrationStore.cs @@ -0,0 +1,14 @@ +using CodexUsageWidget.Infrastructure.Windows; + +namespace CodexUsageWidget.Infrastructure.Preview; + +public sealed class PreviewStartupRegistrationStore : IStartupRegistrationStore +{ + private string? _command; + + public string? LoadCommand() => _command; + + public void SaveCommand(string command) => _command = command; + + public void DeleteCommand() => _command = null; +} diff --git a/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs b/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs new file mode 100644 index 0000000..37f6092 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs @@ -0,0 +1,80 @@ +using CodexUsageWidget.Application; +using CodexUsageWidget.Domain; + +namespace CodexUsageWidget.Infrastructure.Preview; + +public sealed class PreviewUsageProvider : IUsageProvider +{ + private readonly IUsageProvider _wrappedProvider; + private readonly TimeProvider _timeProvider; + private bool _disposed; + + public PreviewUsageProvider( + IUsageProvider wrappedProvider, + TimeProvider? timeProvider = null) + { + _wrappedProvider = wrappedProvider; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public event EventHandler? RateLimitsChanged + { + add => _wrappedProvider.RateLimitsChanged += value; + remove => _wrappedProvider.RateLimitsChanged -= value; + } + + public event EventHandler? DiagnosticMessage + { + add => _wrappedProvider.DiagnosticMessage += value; + remove => _wrappedProvider.DiagnosticMessage -= value; + } + + public Task ReadUsageAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + + var now = _timeProvider.GetLocalNow(); + var snapshot = new UsageSnapshot( + new UsageRateLimits( + [ + new UsageLimitBucket( + "codex", + "Codex", + IsGeneral: true, + [ + new UsageWindow( + "5h limit", + UsedPercent: 20, + WindowDurationMinutes: 300, + now.AddHours(2)), + new UsageWindow( + "Weekly limit", + UsedPercent: 85, + WindowDurationMinutes: 10_080, + now.AddDays(2)) + ], + Credits: null, + IndividualLimit: null, + ReachedState: null, + SpendControlReached: null) + ], + PlanType: "preview", + ResetCredits: null), + TokenActivity: null, + FetchedAt: now); + + return Task.FromResult(snapshot); + } + + public ValueTask DisposeAsync() + { + if (_disposed) + { + return ValueTask.CompletedTask; + } + + _disposed = true; + return _wrappedProvider.DisposeAsync(); + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/DisplayedLimitPreferenceStore.cs b/src/CodexUsageWidget/Infrastructure/Settings/DisplayedLimitPreferenceStore.cs new file mode 100644 index 0000000..dba59d0 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/DisplayedLimitPreferenceStore.cs @@ -0,0 +1,57 @@ +using System.IO; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public sealed class DisplayedLimitPreferenceStore +{ + private readonly string _path; + + public DisplayedLimitPreferenceStore(string? path = null) + { + _path = path ?? AppPaths.DisplayedLimitPreferenceFile; + } + + public DisplayedLimitPreference Load() + { + try + { + return File.ReadAllText(_path).Trim().ToLowerInvariant() switch + { + "weekly" => DisplayedLimitPreference.Weekly, + "most-constrained" => DisplayedLimitPreference.MostConstrained, + _ => DisplayedLimitPreference.FiveHour + }; + } + catch (IOException) + { + return DisplayedLimitPreference.FiveHour; + } + catch (UnauthorizedAccessException) + { + return DisplayedLimitPreference.FiveHour; + } + } + + public void Save(DisplayedLimitPreference preference) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText( + _path, + preference switch + { + DisplayedLimitPreference.Weekly => "weekly", + DisplayedLimitPreference.MostConstrained => "most-constrained", + _ => "five-hour" + }); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/ExternalMouseDownWatcher.cs b/src/CodexUsageWidget/Infrastructure/Windows/ExternalMouseDownWatcher.cs new file mode 100644 index 0000000..3c0c4e8 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Windows/ExternalMouseDownWatcher.cs @@ -0,0 +1,121 @@ +using System.Runtime.InteropServices; + +namespace CodexUsageWidget.Infrastructure.Windows; + +public sealed class ExternalMouseDownWatcher : IDisposable +{ + private const int WhMouseLowLevel = 14; + private const int WmLeftButtonDown = 0x0201; + private const int WmRightButtonDown = 0x0204; + private const int WmMiddleButtonDown = 0x0207; + private const int WmXButtonDown = 0x020B; + + private readonly Action _externalMouseDown; + private readonly MouseHookCallback _callback; + private readonly uint _processId = (uint)Environment.ProcessId; + private IntPtr _hook; + + public ExternalMouseDownWatcher(Action externalMouseDown) + { + ArgumentNullException.ThrowIfNull(externalMouseDown); + + _externalMouseDown = externalMouseDown; + _callback = OnMouseHook; + _hook = SetWindowsHookEx( + WhMouseLowLevel, + _callback, + GetModuleHandle(null), + 0); + } + + public void Dispose() + { + if (_hook != IntPtr.Zero) + { + _ = UnhookWindowsHookEx(_hook); + _hook = IntPtr.Zero; + } + + GC.SuppressFinalize(this); + } + + private IntPtr OnMouseHook(int code, IntPtr message, IntPtr mouseData) + { + if (code >= 0 && IsMouseDown(message.ToInt32())) + { + try + { + var data = Marshal.PtrToStructure(mouseData); + var target = WindowFromPoint(data.Position); + _ = GetWindowThreadProcessId(target, out var targetProcessId); + if (target == IntPtr.Zero || targetProcessId != _processId) + { + _externalMouseDown(); + } + } + catch (Exception) + { + // Exceptions must not escape a native hook callback. + } + } + + return CallNextHookEx(IntPtr.Zero, code, message, mouseData); + } + + private static bool IsMouseDown(int message) => + message is WmLeftButtonDown or + WmRightButtonDown or + WmMiddleButtonDown or + WmXButtonDown; + + [DllImport("user32.dll")] + private static extern IntPtr SetWindowsHookEx( + int hookType, + MouseHookCallback callback, + IntPtr module, + uint threadId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UnhookWindowsHookEx(IntPtr hook); + + [DllImport("user32.dll")] + private static extern IntPtr CallNextHookEx( + IntPtr hook, + int code, + IntPtr message, + IntPtr mouseData); + + [DllImport("user32.dll")] + private static extern IntPtr WindowFromPoint(NativePoint point); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId( + IntPtr window, + out uint processId); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string? moduleName); + + private delegate IntPtr MouseHookCallback( + int code, + IntPtr message, + IntPtr mouseData); + + [StructLayout(LayoutKind.Sequential)] + private struct NativePoint + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct LowLevelMouseInput + { + public NativePoint Position; + public uint MouseData; + public uint Flags; + public uint Time; + public UIntPtr ExtraInfo; + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/GitHubReleaseLauncher.cs b/src/CodexUsageWidget/Infrastructure/Windows/GitHubReleaseLauncher.cs new file mode 100644 index 0000000..2b1c444 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Windows/GitHubReleaseLauncher.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace CodexUsageWidget.Infrastructure.Windows; + +internal static class GitHubReleaseLauncher +{ + private const string LatestReleaseUrl = + "https://github.com/ognjeeen/codex-usage-widget/releases/latest"; + + public static bool TryOpenLatestRelease() + { + try + { + var startInfo = new ProcessStartInfo(LatestReleaseUrl) + { + UseShellExecute = true + }; + + return Process.Start(startInfo) is not null; + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) + { + return false; + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs index 10bbc15..c10f5b7 100644 --- a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs +++ b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs @@ -1,3 +1,4 @@ +using CodexUsageWidget.Application; using CodexUsageWidget.Infrastructure.Settings; using Forms = System.Windows.Forms; @@ -5,16 +6,25 @@ namespace CodexUsageWidget.Infrastructure.Windows; public sealed class TrayIconService : IDisposable { + private const string FiveHourUnavailableText = + "Codex did not return a global 5h limit for this account."; + private readonly Forms.NotifyIcon _notifyIcon; private readonly Forms.ToolStripMenuItem _desktopWidgetModeItem; private readonly Forms.ToolStripMenuItem _taskbarIndicatorModeItem; + private readonly Forms.ToolStripMenuItem _fiveHourLimitItem; + private readonly Forms.ToolStripMenuItem _weeklyLimitItem; + private readonly Forms.ToolStripMenuItem _mostConstrainedLimitItem; private readonly Forms.ToolStripMenuItem _startWithWindowsItem; private System.Drawing.Icon _currentIcon; private bool _disposed; public TrayIconService() { - var menu = new Forms.ContextMenuStrip(); + var menu = new Forms.ContextMenuStrip + { + 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( @@ -35,6 +45,25 @@ public TrayIconService() displayModeMenu.DropDownItems.Add(_desktopWidgetModeItem); displayModeMenu.DropDownItems.Add(_taskbarIndicatorModeItem); menu.Items.Add(displayModeMenu); + + var displayedLimitMenu = new Forms.ToolStripMenuItem("Displayed limit"); + _fiveHourLimitItem = new Forms.ToolStripMenuItem("5h limit") + { + Enabled = false, + ToolTipText = FiveHourUnavailableText + }; + _fiveHourLimitItem.Click += (_, _) => + DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.FiveHour); + _weeklyLimitItem = new Forms.ToolStripMenuItem("Weekly limit"); + _weeklyLimitItem.Click += (_, _) => + DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.Weekly); + _mostConstrainedLimitItem = new Forms.ToolStripMenuItem("Most constrained"); + _mostConstrainedLimitItem.Click += (_, _) => + DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.MostConstrained); + displayedLimitMenu.DropDownItems.Add(_fiveHourLimitItem); + displayedLimitMenu.DropDownItems.Add(_weeklyLimitItem); + displayedLimitMenu.DropDownItems.Add(_mostConstrainedLimitItem); + menu.Items.Add(displayedLimitMenu); _startWithWindowsItem = new Forms.ToolStripMenuItem("Start with Windows") { CheckOnClick = true @@ -42,6 +71,10 @@ public TrayIconService() _startWithWindowsItem.Click += (_, _) => StartupToggleRequested?.Invoke(this, EventArgs.Empty); menu.Items.Add(_startWithWindowsItem); + menu.Items.Add( + "Check for updates...", + null, + (_, _) => UpdateCheckRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add(new Forms.ToolStripSeparator()); menu.Items.Add("Exit", null, (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty)); @@ -66,8 +99,12 @@ public TrayIconService() public event EventHandler? TaskbarModeRequested; + public event Action? DisplayedLimitPreferenceChanged; + public event EventHandler? StartupToggleRequested; + public event EventHandler? UpdateCheckRequested; + public event EventHandler? ExitRequested; public void SetDisplayMode(WidgetDisplayMode mode) @@ -78,6 +115,19 @@ public void SetDisplayMode(WidgetDisplayMode mode) public void SetStartupEnabled(bool enabled) => _startWithWindowsItem.Checked = enabled; + public void SetDisplayedLimitPreference(DisplayedLimitPreference preference) + { + _fiveHourLimitItem.Checked = preference == DisplayedLimitPreference.FiveHour; + _weeklyLimitItem.Checked = preference == DisplayedLimitPreference.Weekly; + _mostConstrainedLimitItem.Checked = preference == DisplayedLimitPreference.MostConstrained; + } + + public void SetFiveHourLimitAvailability(bool available) + { + _fiveHourLimitItem.Enabled = available; + _fiveHourLimitItem.ToolTipText = available ? string.Empty : FiveHourUnavailableText; + } + public void UpdateUsage(double? remainingPercent) { _notifyIcon.Text = remainingPercent is null diff --git a/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs b/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs index 1e90ea0..dd1b184 100644 --- a/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs @@ -20,6 +20,8 @@ public partial class ActivityHookSetupWindow : Window private readonly CancellationTokenSource _lifetime = new(); private bool _refreshInProgress; private bool _refreshTrustOnActivation; + private bool _reviewDialogOpen; + private bool _closeRequested; public ActivityHookSetupWindow( IActivityHookSetupService setupService, @@ -33,6 +35,8 @@ public ActivityHookSetupWindow( DataContext = ActivityHookSetupViewModel.Loading(); Loaded += ActivityHookSetupWindowOnLoaded; Activated += ActivityHookSetupWindowOnActivated; + Deactivated += ActivityHookSetupWindowOnDeactivated; + Closing += ActivityHookSetupWindowOnClosing; Closed += ActivityHookSetupWindowOnClosed; } @@ -55,14 +59,28 @@ private async void ActivityHookSetupWindowOnActivated(object? sender, EventArgs { if (_refreshTrustOnActivation && !_refreshInProgress) { + _refreshTrustOnActivation = false; await RefreshStatusAsync(); } } + private void ActivityHookSetupWindowOnDeactivated(object? sender, EventArgs e) + { + if (!_reviewDialogOpen && !_refreshTrustOnActivation) + { + RequestClose(); + } + } + + private void ActivityHookSetupWindowOnClosing(object? sender, CancelEventArgs e) => + _closeRequested = true; + private void ActivityHookSetupWindowOnClosed(object? sender, EventArgs e) { SizeChanged -= ActivityHookSetupWindowOnSizeChanged; Activated -= ActivityHookSetupWindowOnActivated; + Deactivated -= ActivityHookSetupWindowOnDeactivated; + Closing -= ActivityHookSetupWindowOnClosing; _lifetime.Cancel(); _lifetime.Dispose(); } @@ -90,7 +108,10 @@ private async Task RefreshStatusAsync() _refreshTrustOnActivation = false; } } - catch (OperationCanceledException) when (!_lifetime.IsCancellationRequested) + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (OperationCanceledException) { DataContext = ActivityHookSetupViewModel.Error( "Codex did not report hook status in time. Check that the CLI is available, then try again."); @@ -116,7 +137,18 @@ private void Header_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) } } - private void CloseButton_OnClick(object sender, RoutedEventArgs e) => Close(); + private void CloseButton_OnClick(object sender, RoutedEventArgs e) => RequestClose(); + + private void RequestClose() + { + if (_closeRequested) + { + return; + } + + _closeRequested = true; + Close(); + } private async void InstallButton_OnClick(object sender, RoutedEventArgs e) => await ReviewAndApplyAsync(ActivityHookChangeKind.Install); @@ -136,7 +168,18 @@ private async Task ReviewAndApplyAsync(ActivityHookChangeKind kind) } var reviewWindow = new ActivityHookChangeReviewWindow(preview) { Owner = this }; - if (reviewWindow.ShowDialog() != true) + bool accepted; + _reviewDialogOpen = true; + try + { + accepted = reviewWindow.ShowDialog() == true; + } + finally + { + _reviewDialogOpen = false; + } + + if (!accepted) { return; } diff --git a/src/CodexUsageWidget/Views/MainWindow.xaml b/src/CodexUsageWidget/Views/MainWindow.xaml index b916c98..29ecc72 100644 --- a/src/CodexUsageWidget/Views/MainWindow.xaml +++ b/src/CodexUsageWidget/Views/MainWindow.xaml @@ -70,20 +70,6 @@ - + + + diff --git a/src/CodexUsageWidget/Views/MainWindow.xaml.cs b/src/CodexUsageWidget/Views/MainWindow.xaml.cs index ec53955..cd4bf2f 100644 --- a/src/CodexUsageWidget/Views/MainWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/MainWindow.xaml.cs @@ -23,6 +23,7 @@ public partial class MainWindow : Window private readonly ActivityHookSetupWindowController _activityHookSetupWindows; private readonly DisplayModeStore _displayModeStore; private readonly WidgetDensityStore _densityStore; + private readonly DisplayedLimitPreferenceStore _displayedLimitPreferenceStore; private readonly StartupRegistrationService _startupRegistration; private readonly TrayIconService _trayIcon; private readonly TaskbarLabelWindow _taskbarLabel = new(); @@ -30,6 +31,8 @@ public partial class MainWindow : Window private readonly MainWindowCloseState _closeState = new(); private WidgetDisplayMode _displayMode; private WidgetDensity _density; + private DisplayedLimitPreference _displayedLimitPreference; + private UsageSnapshot? _latestSnapshot; private UsageWidgetViewModel _viewModel = UsageWidgetViewModel.Loading(); private bool _isRealActivityActive; private bool _isActivityPreviewEnabled; @@ -42,6 +45,7 @@ public MainWindow( ICodexLauncher codexLauncher, DisplayModeStore displayModeStore, WidgetDensityStore densityStore, + DisplayedLimitPreferenceStore displayedLimitPreferenceStore, StartupRegistrationService startupRegistration, TrayIconService trayIcon) { @@ -53,10 +57,12 @@ public MainWindow( codexLauncher); _displayModeStore = displayModeStore; _densityStore = densityStore; + _displayedLimitPreferenceStore = displayedLimitPreferenceStore; _startupRegistration = startupRegistration; _trayIcon = trayIcon; _displayMode = displayModeStore.Load(); _density = densityStore.Load(); + _displayedLimitPreference = displayedLimitPreferenceStore.Load(); _widgetVisibility = new WidgetVisibilityController(() => IsVisible, ShowWidget, Hide); InitializeComponent(); @@ -98,10 +104,14 @@ private void WireEvents() _isActivityPreviewEnabled = _taskbarLabel.IsActivityPreviewEnabled; ApplyActivityIndicatorState(); }); + _taskbarLabel.DisplayedLimitPreferenceChanged += preference => + Dispatcher.BeginInvoke(() => SetDisplayedLimitPreference(preference)); _taskbarLabel.DesktopModeRequested += (_, _) => Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.DesktopWidget)); _taskbarLabel.StartupToggleRequested += (_, _) => Dispatcher.BeginInvoke(ToggleStartupRegistration); + _taskbarLabel.UpdateCheckRequested += (_, _) => + Dispatcher.BeginInvoke(CheckForUpdates); _taskbarLabel.ExitRequested += (_, _) => Dispatcher.BeginInvoke(ExitApplication); _trayIcon.OpenRequested += (_, _) => Dispatcher.BeginInvoke(_widgetVisibility.Show); @@ -113,8 +123,12 @@ private void WireEvents() Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.DesktopWidget)); _trayIcon.TaskbarModeRequested += (_, _) => Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.TaskbarIndicator)); + _trayIcon.DisplayedLimitPreferenceChanged += preference => + Dispatcher.BeginInvoke(() => SetDisplayedLimitPreference(preference)); _trayIcon.StartupToggleRequested += (_, _) => Dispatcher.BeginInvoke(ToggleStartupRegistration); + _trayIcon.UpdateCheckRequested += (_, _) => + Dispatcher.BeginInvoke(CheckForUpdates); _trayIcon.ExitRequested += (_, _) => Dispatcher.BeginInvoke(ExitApplication); } @@ -122,6 +136,7 @@ private async void MainWindowOnLoaded(object sender, RoutedEventArgs e) { PositionNearWorkAreaEdge(); _trayIcon.SetDisplayMode(_displayMode); + SetDisplayedLimitPreferenceState(_displayedLimitPreference); SetStartupRegistrationState(_startupRegistration.IsEnabled); if (_displayMode == WidgetDisplayMode.TaskbarIndicator) { @@ -156,7 +171,9 @@ private void ApplyActivityIndicatorState() private void RenderSnapshot(UsageSnapshot snapshot) { - var nextViewModel = UsageWidgetViewModel.FromSnapshot(snapshot); + _latestSnapshot = snapshot; + var displayedWindow = ResolveDisplayedWindow(snapshot); + var nextViewModel = UsageWidgetViewModel.FromSnapshot(snapshot, displayedWindow); SetViewModel(nextViewModel); if (_density == WidgetDensity.Detailed) { @@ -167,17 +184,58 @@ private void RenderSnapshot(UsageSnapshot snapshot) ApplyDensity(repositionBottomEdge: true); } - _trayIcon.UpdateUsage(nextViewModel.HeadlineRemainingPercent); + _trayIcon.UpdateUsage(displayedWindow?.RemainingPercent); _taskbarLabel.UpdateUsage( - nextViewModel.HeadlineRemainingPercent, - nextViewModel.HeadlineResetsAt); + displayedWindow?.Label, + displayedWindow?.RemainingPercent, + displayedWindow?.ResetsAt); } private void RenderError(string message) { + _latestSnapshot = null; SetViewModel(UsageWidgetViewModel.Error(message)); _trayIcon.UpdateUsage(null); - _taskbarLabel.UpdateUsage(null, null); + _taskbarLabel.UpdateUsage(null, null, null); + } + + private void SetDisplayedLimitPreference(DisplayedLimitPreference preference) + { + _displayedLimitPreference = preference; + _displayedLimitPreferenceStore.Save(preference); + SetDisplayedLimitPreferenceState(preference); + + if (_latestSnapshot is { } snapshot) + { + RenderSnapshot(snapshot); + } + } + + private void SetDisplayedLimitPreferenceState(DisplayedLimitPreference preference) + { + _taskbarLabel.SetDisplayedLimitPreference(preference); + _trayIcon.SetDisplayedLimitPreference(preference); + } + + private UsageWindow? ResolveDisplayedWindow(UsageSnapshot snapshot) + { + var fiveHourAvailable = DisplayedUsageSelector.IsAvailable( + snapshot, + DisplayedLimitPreference.FiveHour); + _taskbarLabel.SetFiveHourLimitAvailability(fiveHourAvailable); + _trayIcon.SetFiveHourLimitAvailability(fiveHourAvailable); + + var resolvedPreference = DisplayedUsageSelector.ResolvePreference( + snapshot, + _displayedLimitPreference); + if (resolvedPreference != _displayedLimitPreference) + { + _displayedLimitPreference = resolvedPreference; + _displayedLimitPreferenceStore.Save(resolvedPreference); + SetDisplayedLimitPreferenceState(resolvedPreference); + } + + return DisplayedUsageSelector.Select(snapshot, _displayedLimitPreference); } private void SetViewModel(UsageWidgetViewModel viewModel) @@ -306,6 +364,20 @@ private void SetStartupRegistrationState(bool enabled) _trayIcon.SetStartupEnabled(enabled); } + private static void CheckForUpdates() + { + if (GitHubReleaseLauncher.TryOpenLatestRelease()) + { + return; + } + + System.Windows.MessageBox.Show( + "Windows could not open the widget release page.", + "Codex Usage Widget", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + private void Widget_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (e.ButtonState != MouseButtonState.Pressed || @@ -336,6 +408,9 @@ private async void RefreshButton_OnClick(object sender, RoutedEventArgs e) => private void DensityButton_OnClick(object sender, RoutedEventArgs e) => ToggleDensity(); + private void SettingsButton_OnClick(object sender, RoutedEventArgs e) => + _taskbarLabel.OpenMenu(SettingsButton); + private void ActivityDotsButton_OnClick(object sender, RoutedEventArgs e) => ShowActivityHookSetup(); diff --git a/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml b/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml index 4ce7228..3694960 100644 --- a/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml +++ b/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml @@ -1,12 +1,5 @@ - - - +