Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions src/CodexUsageWidget/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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)
Expand Down
Binary file not shown.
12 changes: 12 additions & 0 deletions src/CodexUsageWidget/Assets/CodexUsageWidget.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/CodexUsageWidget/CodexUsageWidget.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<AssemblyName>CodexUsageWidget</AssemblyName>
<RootNamespace>CodexUsageWidget</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\CodexUsageWidget.ico</ApplicationIcon>
<Version>1.2.2</Version>
<Authors>Ognjen Marinković</Authors>
<Company>ognjeeen</Company>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CodexUsageWidget.Infrastructure.Windows;

public interface IStartupRegistrationStore
{
string? LoadCommand();

void SaveCommand(string command);

void DeleteCommand();
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
12 changes: 12 additions & 0 deletions src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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));

Expand All @@ -58,6 +66,8 @@ public TrayIconService()

public event EventHandler? TaskbarModeRequested;

public event EventHandler? StartupToggleRequested;

public event EventHandler? ExitRequested;

public void SetDisplayMode(WidgetDisplayMode mode)
Expand All @@ -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
Expand Down
63 changes: 36 additions & 27 deletions src/CodexUsageWidget/Infrastructure/Windows/UsageIconFactory.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Drawing.Drawing2D;
using System.Drawing.Text;
using System.Globalization;
using System.Runtime.InteropServices;

namespace CodexUsageWidget.Infrastructure.Windows;
Expand All @@ -11,46 +9,57 @@ 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)
};

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)
Expand Down
Loading