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"> + + + + + + + - - - - + + +