diff --git a/README.md b/README.md
index ae88b3f..2582f6e 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ notifications.
- 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
@@ -73,6 +74,11 @@ consumption because tokens do not map linearly to the remaining subscription per
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 current portable
+executable for the signed-in Windows user. The option does not require administrator
+rights, and turning it off removes the registration. If the portable folder moves,
+the path is refreshed the next time the widget is started manually.
+
## Live Codex activity dots
Activity dots turn the official local Codex lifecycle hooks into an at-a-glance signal
diff --git a/assets/icon.svg b/assets/icon.svg
new file mode 100644
index 0000000..daaf2b9
--- /dev/null
+++ b/assets/icon.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs
index 65c7219..463ebad 100644
--- a/src/CodexUsageWidget/App.xaml.cs
+++ b/src/CodexUsageWidget/App.xaml.cs
@@ -43,6 +43,12 @@ protected override void OnStartup(StartupEventArgs e)
activityMonitor = new CodexActivityMonitor(new CodexActivityPipeSignalSource());
var processPath = Environment.ProcessPath ??
throw new InvalidOperationException("Cannot determine the widget executable path.");
+ var startupRegistrationService = new StartupRegistrationService(processPath);
+ if (!startupRegistrationService.TryRefreshExecutablePathIfEnabled())
+ {
+ _logger.LogError("The Windows startup registration could not be refreshed.");
+ }
+
var activityHookSetupService = new CodexActivityHookSetupService(
new CodexHookConfigurationManager(),
appServerSession,
@@ -55,6 +61,7 @@ protected override void OnStartup(StartupEventArgs e)
new CodexCliLauncher(),
new DisplayModeStore(),
new WidgetDensityStore(),
+ startupRegistrationService,
new TrayIconService());
MainWindow = window;
activityMonitor.StartAsync().GetAwaiter().GetResult();
@@ -86,6 +93,15 @@ protected override void OnExit(ExitEventArgs e)
base.OnExit(e);
}
+ protected override void OnSessionEnding(SessionEndingCancelEventArgs e)
+ {
+ base.OnSessionEnding(e);
+ if (!e.Cancel && MainWindow is MainWindow window)
+ {
+ window.NotifySessionEnding();
+ }
+ }
+
public void Dispose()
{
if (_disposed)
diff --git a/src/CodexUsageWidget/Assets/CodexUsageWidget.ico b/src/CodexUsageWidget/Assets/CodexUsageWidget.ico
new file mode 100644
index 0000000..69f27ad
Binary files /dev/null and b/src/CodexUsageWidget/Assets/CodexUsageWidget.ico differ
diff --git a/src/CodexUsageWidget/Assets/CodexUsageWidget.svg b/src/CodexUsageWidget/Assets/CodexUsageWidget.svg
new file mode 100644
index 0000000..daaf2b9
--- /dev/null
+++ b/src/CodexUsageWidget/Assets/CodexUsageWidget.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/CodexUsageWidget/CodexUsageWidget.csproj b/src/CodexUsageWidget/CodexUsageWidget.csproj
index 26767a7..917d03e 100644
--- a/src/CodexUsageWidget/CodexUsageWidget.csproj
+++ b/src/CodexUsageWidget/CodexUsageWidget.csproj
@@ -9,6 +9,7 @@
CodexUsageWidget
CodexUsageWidget
app.manifest
+ Assets\CodexUsageWidget.ico
1.2.2
Ognjen Marinković
ognjeeen
diff --git a/src/CodexUsageWidget/Infrastructure/Windows/IStartupRegistrationStore.cs b/src/CodexUsageWidget/Infrastructure/Windows/IStartupRegistrationStore.cs
new file mode 100644
index 0000000..8e2643e
--- /dev/null
+++ b/src/CodexUsageWidget/Infrastructure/Windows/IStartupRegistrationStore.cs
@@ -0,0 +1,10 @@
+namespace CodexUsageWidget.Infrastructure.Windows;
+
+public interface IStartupRegistrationStore
+{
+ string? LoadCommand();
+
+ void SaveCommand(string command);
+
+ void DeleteCommand();
+}
diff --git a/src/CodexUsageWidget/Infrastructure/Windows/RegistryStartupRegistrationStore.cs b/src/CodexUsageWidget/Infrastructure/Windows/RegistryStartupRegistrationStore.cs
new file mode 100644
index 0000000..6950b68
--- /dev/null
+++ b/src/CodexUsageWidget/Infrastructure/Windows/RegistryStartupRegistrationStore.cs
@@ -0,0 +1,32 @@
+using System.IO;
+using Microsoft.Win32;
+
+namespace CodexUsageWidget.Infrastructure.Windows;
+
+public sealed class RegistryStartupRegistrationStore : IStartupRegistrationStore
+{
+ private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
+ private const string ValueName = "CodexUsageWidget";
+
+ public string? LoadCommand()
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
+ return key?.GetValue(
+ ValueName,
+ defaultValue: null,
+ RegistryValueOptions.DoNotExpandEnvironmentNames) as string;
+ }
+
+ public void SaveCommand(string command)
+ {
+ using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true) ??
+ throw new IOException("The current-user Windows startup key could not be opened.");
+ key.SetValue(ValueName, command, RegistryValueKind.String);
+ }
+
+ public void DeleteCommand()
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true);
+ key?.DeleteValue(ValueName, throwOnMissingValue: false);
+ }
+}
diff --git a/src/CodexUsageWidget/Infrastructure/Windows/StartupRegistrationService.cs b/src/CodexUsageWidget/Infrastructure/Windows/StartupRegistrationService.cs
new file mode 100644
index 0000000..f72e73e
--- /dev/null
+++ b/src/CodexUsageWidget/Infrastructure/Windows/StartupRegistrationService.cs
@@ -0,0 +1,100 @@
+using System.IO;
+using System.Security;
+
+namespace CodexUsageWidget.Infrastructure.Windows;
+
+public sealed class StartupRegistrationService
+{
+ private readonly IStartupRegistrationStore _store;
+ private readonly string _startupCommand;
+
+ public StartupRegistrationService(
+ string executablePath,
+ IStartupRegistrationStore? store = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(executablePath);
+
+ _store = store ?? new RegistryStartupRegistrationStore();
+ _startupCommand = $"\"{Path.GetFullPath(executablePath)}\"";
+ }
+
+ public bool IsEnabled
+ {
+ get
+ {
+ try
+ {
+ return !string.IsNullOrWhiteSpace(_store.LoadCommand());
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ catch (SecurityException)
+ {
+ return false;
+ }
+ }
+ }
+
+ public bool TrySetEnabled(bool enabled)
+ {
+ try
+ {
+ if (enabled)
+ {
+ _store.SaveCommand(_startupCommand);
+ }
+ else
+ {
+ _store.DeleteCommand();
+ }
+
+ return true;
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ catch (SecurityException)
+ {
+ return false;
+ }
+ }
+
+ public bool TryRefreshExecutablePathIfEnabled()
+ {
+ try
+ {
+ var registeredCommand = _store.LoadCommand();
+ if (string.IsNullOrWhiteSpace(registeredCommand) ||
+ string.Equals(registeredCommand, _startupCommand, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ _store.SaveCommand(_startupCommand);
+ return true;
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ catch (SecurityException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs
index 143fc6c..10bbc15 100644
--- a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs
+++ b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs
@@ -8,6 +8,7 @@ public sealed class TrayIconService : IDisposable
private readonly Forms.NotifyIcon _notifyIcon;
private readonly Forms.ToolStripMenuItem _desktopWidgetModeItem;
private readonly Forms.ToolStripMenuItem _taskbarIndicatorModeItem;
+ private readonly Forms.ToolStripMenuItem _startWithWindowsItem;
private System.Drawing.Icon _currentIcon;
private bool _disposed;
@@ -34,6 +35,13 @@ public TrayIconService()
displayModeMenu.DropDownItems.Add(_desktopWidgetModeItem);
displayModeMenu.DropDownItems.Add(_taskbarIndicatorModeItem);
menu.Items.Add(displayModeMenu);
+ _startWithWindowsItem = new Forms.ToolStripMenuItem("Start with Windows")
+ {
+ CheckOnClick = true
+ };
+ _startWithWindowsItem.Click += (_, _) =>
+ StartupToggleRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(_startWithWindowsItem);
menu.Items.Add(new Forms.ToolStripSeparator());
menu.Items.Add("Exit", null, (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty));
@@ -58,6 +66,8 @@ public TrayIconService()
public event EventHandler? TaskbarModeRequested;
+ public event EventHandler? StartupToggleRequested;
+
public event EventHandler? ExitRequested;
public void SetDisplayMode(WidgetDisplayMode mode)
@@ -66,6 +76,8 @@ public void SetDisplayMode(WidgetDisplayMode mode)
_taskbarIndicatorModeItem.Checked = mode == WidgetDisplayMode.TaskbarIndicator;
}
+ public void SetStartupEnabled(bool enabled) => _startWithWindowsItem.Checked = enabled;
+
public void UpdateUsage(double? remainingPercent)
{
_notifyIcon.Text = remainingPercent is null
diff --git a/src/CodexUsageWidget/Infrastructure/Windows/UsageIconFactory.cs b/src/CodexUsageWidget/Infrastructure/Windows/UsageIconFactory.cs
index eb6bb4f..3efbf1e 100644
--- a/src/CodexUsageWidget/Infrastructure/Windows/UsageIconFactory.cs
+++ b/src/CodexUsageWidget/Infrastructure/Windows/UsageIconFactory.cs
@@ -1,6 +1,4 @@
using System.Drawing.Drawing2D;
-using System.Drawing.Text;
-using System.Globalization;
using System.Runtime.InteropServices;
namespace CodexUsageWidget.Infrastructure.Windows;
@@ -11,11 +9,13 @@ public static System.Drawing.Icon Create(double? remainingPercent)
{
using var bitmap = new System.Drawing.Bitmap(64, 64);
using var graphics = System.Drawing.Graphics.FromImage(bitmap);
- graphics.SmoothingMode = SmoothingMode.AntiAlias;
- graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
+ graphics.CompositingQuality = CompositingQuality.HighQuality;
+ graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
+ graphics.SmoothingMode = SmoothingMode.HighQuality;
var indicatorColor = remainingPercent switch
{
+ null => System.Drawing.Color.FromArgb(151, 163, 182),
<= 10 => System.Drawing.Color.FromArgb(240, 112, 112),
<= 25 => System.Drawing.Color.FromArgb(240, 179, 94),
_ => System.Drawing.Color.FromArgb(101, 216, 146)
@@ -23,34 +23,43 @@ public static System.Drawing.Icon Create(double? remainingPercent)
using var backgroundBrush = new System.Drawing.SolidBrush(
System.Drawing.Color.FromArgb(22, 29, 39));
- using var borderPen = new System.Drawing.Pen(indicatorColor, 6f);
- graphics.FillEllipse(backgroundBrush, 3, 3, 58, 58);
- graphics.DrawEllipse(borderPen, 6, 6, 52, 52);
+ graphics.FillEllipse(backgroundBrush, 1, 1, 62, 62);
- DrawPercentage(graphics, remainingPercent);
+ DrawTerminalPrompt(graphics);
+ DrawStatusIndicator(graphics, indicatorColor);
return CloneIcon(bitmap);
}
- private static void DrawPercentage(System.Drawing.Graphics graphics, double? remainingPercent)
+ private static void DrawTerminalPrompt(System.Drawing.Graphics graphics)
{
- var text = remainingPercent is null
- ? "?"
- : Math.Round(Math.Clamp(remainingPercent.Value, 0d, 100d))
- .ToString("0", CultureInfo.InvariantCulture);
- var fontSize = text.Length >= 3 ? 18f : 24f;
- using var font = new System.Drawing.Font(
- "Segoe UI",
- fontSize,
- System.Drawing.FontStyle.Bold,
- System.Drawing.GraphicsUnit.Pixel);
- using var textBrush = new System.Drawing.SolidBrush(System.Drawing.Color.White);
- var textSize = graphics.MeasureString(text, font);
- graphics.DrawString(
- text,
- font,
- textBrush,
- (64f - textSize.Width) / 2f,
- (64f - textSize.Height) / 2f - 1f);
+ using var promptPen = new System.Drawing.Pen(
+ System.Drawing.Color.FromArgb(246, 248, 252),
+ 7f)
+ {
+ StartCap = LineCap.Round,
+ EndCap = LineCap.Round,
+ LineJoin = LineJoin.Round
+ };
+
+ graphics.DrawLines(
+ promptPen,
+ [
+ new System.Drawing.PointF(19f, 18f),
+ new System.Drawing.PointF(34f, 32f),
+ new System.Drawing.PointF(19f, 46f)
+ ]);
+ }
+
+ private static void DrawStatusIndicator(
+ System.Drawing.Graphics graphics,
+ System.Drawing.Color indicatorColor)
+ {
+ using var outlineBrush = new System.Drawing.SolidBrush(
+ System.Drawing.Color.FromArgb(22, 29, 39));
+ using var indicatorBrush = new System.Drawing.SolidBrush(indicatorColor);
+
+ graphics.FillEllipse(outlineBrush, 37, 37, 26, 26);
+ graphics.FillEllipse(indicatorBrush, 41, 41, 18, 18);
}
private static System.Drawing.Icon CloneIcon(System.Drawing.Bitmap bitmap)
diff --git a/src/CodexUsageWidget/Views/MainWindow.xaml.cs b/src/CodexUsageWidget/Views/MainWindow.xaml.cs
index 5d0b71e..ec53955 100644
--- a/src/CodexUsageWidget/Views/MainWindow.xaml.cs
+++ b/src/CodexUsageWidget/Views/MainWindow.xaml.cs
@@ -23,15 +23,17 @@ public partial class MainWindow : Window
private readonly ActivityHookSetupWindowController _activityHookSetupWindows;
private readonly DisplayModeStore _displayModeStore;
private readonly WidgetDensityStore _densityStore;
+ private readonly StartupRegistrationService _startupRegistration;
private readonly TrayIconService _trayIcon;
private readonly TaskbarLabelWindow _taskbarLabel = new();
private readonly WidgetVisibilityController _widgetVisibility;
+ private readonly MainWindowCloseState _closeState = new();
private WidgetDisplayMode _displayMode;
private WidgetDensity _density;
private UsageWidgetViewModel _viewModel = UsageWidgetViewModel.Loading();
private bool _isRealActivityActive;
private bool _isActivityPreviewEnabled;
- private bool _allowClose;
+ private bool _shutdownStarted;
public MainWindow(
UsageMonitor usageMonitor,
@@ -40,6 +42,7 @@ public MainWindow(
ICodexLauncher codexLauncher,
DisplayModeStore displayModeStore,
WidgetDensityStore densityStore,
+ StartupRegistrationService startupRegistration,
TrayIconService trayIcon)
{
_usageMonitor = usageMonitor;
@@ -50,6 +53,7 @@ public MainWindow(
codexLauncher);
_displayModeStore = displayModeStore;
_densityStore = densityStore;
+ _startupRegistration = startupRegistration;
_trayIcon = trayIcon;
_displayMode = displayModeStore.Load();
_density = densityStore.Load();
@@ -96,6 +100,8 @@ private void WireEvents()
});
_taskbarLabel.DesktopModeRequested += (_, _) =>
Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.DesktopWidget));
+ _taskbarLabel.StartupToggleRequested += (_, _) =>
+ Dispatcher.BeginInvoke(ToggleStartupRegistration);
_taskbarLabel.ExitRequested += (_, _) => Dispatcher.BeginInvoke(ExitApplication);
_trayIcon.OpenRequested += (_, _) => Dispatcher.BeginInvoke(_widgetVisibility.Show);
@@ -107,6 +113,8 @@ private void WireEvents()
Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.DesktopWidget));
_trayIcon.TaskbarModeRequested += (_, _) =>
Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.TaskbarIndicator));
+ _trayIcon.StartupToggleRequested += (_, _) =>
+ Dispatcher.BeginInvoke(ToggleStartupRegistration);
_trayIcon.ExitRequested += (_, _) => Dispatcher.BeginInvoke(ExitApplication);
}
@@ -114,6 +122,7 @@ private async void MainWindowOnLoaded(object sender, RoutedEventArgs e)
{
PositionNearWorkAreaEdge();
_trayIcon.SetDisplayMode(_displayMode);
+ SetStartupRegistrationState(_startupRegistration.IsEnabled);
if (_displayMode == WidgetDisplayMode.TaskbarIndicator)
{
_taskbarLabel.ShowLabel();
@@ -274,6 +283,29 @@ private void SetDisplayMode(WidgetDisplayMode mode)
Hide();
}
+ private void ToggleStartupRegistration()
+ {
+ var enabled = !_startupRegistration.IsEnabled;
+ if (_startupRegistration.TrySetEnabled(enabled))
+ {
+ SetStartupRegistrationState(enabled);
+ return;
+ }
+
+ SetStartupRegistrationState(_startupRegistration.IsEnabled);
+ System.Windows.MessageBox.Show(
+ "The Windows startup preference could not be updated.",
+ "Codex Usage Widget",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ }
+
+ private void SetStartupRegistrationState(bool enabled)
+ {
+ _taskbarLabel.SetStartupEnabled(enabled);
+ _trayIcon.SetStartupEnabled(enabled);
+ }
+
private void Widget_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ButtonState != MouseButtonState.Pressed ||
@@ -323,24 +355,36 @@ private void MainWindowOnDeactivated(object? sender, EventArgs e)
private void ExitApplication()
{
- _allowClose = true;
+ _closeState.RequestExplicitExit();
Close();
}
+ internal void NotifySessionEnding() => _closeState.NotifySessionEnding();
+
private void MainWindowOnClosing(object? sender, CancelEventArgs e)
{
- if (!_allowClose)
+ var closeAction = _closeState.GetCloseAction();
+ if (closeAction == MainWindowCloseAction.MinimizeToTaskbar)
{
e.Cancel = true;
SetDisplayMode(WidgetDisplayMode.TaskbarIndicator);
return;
}
+ if (_shutdownStarted)
+ {
+ return;
+ }
+
+ _shutdownStarted = true;
_taskbarLabel.HideLabel();
- _taskbarLabel.Close();
+ _taskbarLabel.CloseLabel();
_trayIcon.Dispose();
_activityMonitor.DisposeAsync().AsTask().GetAwaiter().GetResult();
_usageMonitor.DisposeAsync().AsTask().GetAwaiter().GetResult();
- System.Windows.Application.Current.Shutdown();
+ if (closeAction == MainWindowCloseAction.CloseAndShutdownApplication)
+ {
+ System.Windows.Application.Current.Shutdown();
+ }
}
}
diff --git a/src/CodexUsageWidget/Views/MainWindowCloseState.cs b/src/CodexUsageWidget/Views/MainWindowCloseState.cs
new file mode 100644
index 0000000..16dc16c
--- /dev/null
+++ b/src/CodexUsageWidget/Views/MainWindowCloseState.cs
@@ -0,0 +1,21 @@
+namespace CodexUsageWidget.Views;
+
+public enum MainWindowCloseAction
+{
+ MinimizeToTaskbar,
+ CloseAndShutdownApplication,
+ CloseForSessionEnding
+}
+
+public sealed class MainWindowCloseState
+{
+ private MainWindowCloseAction _action = MainWindowCloseAction.MinimizeToTaskbar;
+
+ public MainWindowCloseAction GetCloseAction() => _action;
+
+ public void RequestExplicitExit() =>
+ _action = MainWindowCloseAction.CloseAndShutdownApplication;
+
+ public void NotifySessionEnding() =>
+ _action = MainWindowCloseAction.CloseForSessionEnding;
+}
diff --git a/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml b/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml
new file mode 100644
index 0000000..4ce7228
--- /dev/null
+++ b/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml
index 0011d96..e402bc6 100644
--- a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml
+++ b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml
@@ -14,6 +14,13 @@
Focusable="False"
SnapsToDevicePixels="True"
UseLayoutRounding="True">
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+ Click="ActivityPreviewMenuItem_OnClick">
+
+
+
+
-
+
+
-
+
UpdateVisibilityAndPosition();
_windowChangeWatcher = new WindowChangeWatcher(QueueVisibilityUpdate);
- Closed += (_, _) => _windowChangeWatcher.Dispose();
+ Closed += (_, _) =>
+ {
+ _isClosed = true;
+ _labelRequested = false;
+ _positionTimer.Stop();
+ _windowChangeWatcher.Dispose();
+ };
}
public event EventHandler? OpenRequested;
@@ -55,6 +62,8 @@ public TaskbarLabelWindow()
public event EventHandler? DesktopModeRequested;
+ public event EventHandler? StartupToggleRequested;
+
public event EventHandler? ExitRequested;
public bool IsPointerOver => IsMouseOver;
@@ -75,7 +84,18 @@ public void HideLabel()
{
_labelRequested = false;
_positionTimer.Stop();
- Hide();
+ if (!_isClosed)
+ {
+ Hide();
+ }
+ }
+
+ public void CloseLabel()
+ {
+ if (!_isClosed)
+ {
+ Close();
+ }
}
public void SetActivityState(bool isActive)
@@ -89,6 +109,8 @@ public void SetActivityState(bool isActive)
ActivityDots.IsActive = isActive;
}
+ public void SetStartupEnabled(bool enabled) => StartWithWindowsMenuItem.IsChecked = enabled;
+
public void UpdateUsage(double? remainingPercent, DateTimeOffset? resetsAt)
{
if (remainingPercent is null)
@@ -115,7 +137,7 @@ private void Reposition()
private void UpdateVisibilityAndPosition()
{
- if (!_labelRequested || _windowHandle == IntPtr.Zero)
+ if (_isClosed || !_labelRequested || _windowHandle == IntPtr.Zero)
{
return;
}
@@ -174,6 +196,9 @@ private void ActivityPreviewMenuItem_OnClick(object sender, RoutedEventArgs e)
private void DesktopModeMenuItem_OnClick(object sender, RoutedEventArgs e) =>
DesktopModeRequested?.Invoke(this, EventArgs.Empty);
+ private void StartWithWindowsMenuItem_OnClick(object sender, RoutedEventArgs e) =>
+ StartupToggleRequested?.Invoke(this, EventArgs.Empty);
+
private void ExitMenuItem_OnClick(object sender, RoutedEventArgs e) =>
ExitRequested?.Invoke(this, EventArgs.Empty);
}
diff --git a/tests/CodexUsageWidget.Tests/MainWindowCloseStateTests.cs b/tests/CodexUsageWidget.Tests/MainWindowCloseStateTests.cs
new file mode 100644
index 0000000..618bbd7
--- /dev/null
+++ b/tests/CodexUsageWidget.Tests/MainWindowCloseStateTests.cs
@@ -0,0 +1,45 @@
+using CodexUsageWidget.Views;
+
+namespace CodexUsageWidget.Tests;
+
+public sealed class MainWindowCloseStateTests
+{
+ [Fact]
+ public void OrdinaryWindowCloseMinimizesToTaskbar()
+ {
+ var state = new MainWindowCloseState();
+
+ Assert.Equal(MainWindowCloseAction.MinimizeToTaskbar, state.GetCloseAction());
+ }
+
+ [Fact]
+ public void ExplicitExitClosesAndShutsDownApplication()
+ {
+ var state = new MainWindowCloseState();
+
+ state.RequestExplicitExit();
+
+ Assert.Equal(MainWindowCloseAction.CloseAndShutdownApplication, state.GetCloseAction());
+ }
+
+ [Fact]
+ public void WindowsSessionEndingClosesWithoutNestedApplicationShutdown()
+ {
+ var state = new MainWindowCloseState();
+
+ state.NotifySessionEnding();
+
+ Assert.Equal(MainWindowCloseAction.CloseForSessionEnding, state.GetCloseAction());
+ }
+
+ [Fact]
+ public void WindowsSessionEndingTakesPrecedenceOverConcurrentExplicitExit()
+ {
+ var state = new MainWindowCloseState();
+ state.RequestExplicitExit();
+
+ state.NotifySessionEnding();
+
+ Assert.Equal(MainWindowCloseAction.CloseForSessionEnding, state.GetCloseAction());
+ }
+}
diff --git a/tests/CodexUsageWidget.Tests/StartupRegistrationServiceTests.cs b/tests/CodexUsageWidget.Tests/StartupRegistrationServiceTests.cs
new file mode 100644
index 0000000..68b83ed
--- /dev/null
+++ b/tests/CodexUsageWidget.Tests/StartupRegistrationServiceTests.cs
@@ -0,0 +1,104 @@
+using CodexUsageWidget.Infrastructure.Windows;
+
+namespace CodexUsageWidget.Tests;
+
+public sealed class StartupRegistrationServiceTests
+{
+ private const string ExecutablePath = @"C:\Apps\Codex Usage Widget\CodexUsageWidget.exe";
+ private const string StartupCommand = "\"C:\\Apps\\Codex Usage Widget\\CodexUsageWidget.exe\"";
+
+ [Fact]
+ public void MissingRegistrationIsDisabled()
+ {
+ var service = new StartupRegistrationService(ExecutablePath, new FakeStore());
+
+ Assert.False(service.IsEnabled);
+ }
+
+ [Fact]
+ public void EnablingWritesQuotedExecutablePath()
+ {
+ var store = new FakeStore();
+ var service = new StartupRegistrationService(ExecutablePath, store);
+
+ var result = service.TrySetEnabled(enabled: true);
+
+ Assert.True(result);
+ Assert.Equal(StartupCommand, store.Command);
+ Assert.True(service.IsEnabled);
+ }
+
+ [Fact]
+ public void DisablingDeletesRegistration()
+ {
+ var store = new FakeStore { Command = StartupCommand };
+ var service = new StartupRegistrationService(ExecutablePath, store);
+
+ var result = service.TrySetEnabled(enabled: false);
+
+ Assert.True(result);
+ Assert.Null(store.Command);
+ Assert.False(service.IsEnabled);
+ }
+
+ [Fact]
+ public void RefreshUpdatesMovedPortableExecutablePath()
+ {
+ var store = new FakeStore { Command = "\"C:\\Old location\\CodexUsageWidget.exe\"" };
+ var service = new StartupRegistrationService(ExecutablePath, store);
+
+ var result = service.TryRefreshExecutablePathIfEnabled();
+
+ Assert.True(result);
+ Assert.Equal(StartupCommand, store.Command);
+ }
+
+ [Fact]
+ public void RefreshDoesNotCreateMissingRegistration()
+ {
+ var store = new FakeStore();
+ var service = new StartupRegistrationService(ExecutablePath, store);
+
+ var result = service.TryRefreshExecutablePathIfEnabled();
+
+ Assert.True(result);
+ Assert.Null(store.Command);
+ Assert.Equal(0, store.SaveCount);
+ }
+
+ [Fact]
+ public void RegistryFailureDoesNotEscapeIntoTheUi()
+ {
+ var store = new FakeStore { WriteException = new UnauthorizedAccessException() };
+ var service = new StartupRegistrationService(ExecutablePath, store);
+
+ var result = service.TrySetEnabled(enabled: true);
+
+ Assert.False(result);
+ Assert.False(service.IsEnabled);
+ }
+
+ private sealed class FakeStore : IStartupRegistrationStore
+ {
+ public string? Command { get; set; }
+
+ public Exception? WriteException { get; init; }
+
+ public int SaveCount { get; private set; }
+
+ public string? LoadCommand() => Command;
+
+ public void SaveCommand(string command)
+ {
+ if (WriteException is not null)
+ {
+ throw WriteException;
+ }
+
+ Command = command;
+ SaveCount++;
+ }
+
+ public void DeleteCommand() => Command = null;
+ }
+}