diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c45dcd9..c6025e3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,10 +1,11 @@
name: Release
# Push a tag like v1.2.3 to build the downloads and publish a GitHub Release.
-# Three assets are produced:
+# Assets produced:
# * ClickLight-vX.Y.Z-win-x64.exe - small framework-dependent app (needs .NET 10 installed)
# * ClickLight-vX.Y.Z-win-arm64.exe - the same for Windows-on-Arm machines
# * ClickLight-vX.Y.Z-Setup.exe - x64 installer that also installs .NET 10 if it is missing
+# * *.nupkg + releases.*.json - Velopack update feed the in-app updater reads
#
# Code signing (SignPath, free for open-source) is applied automatically once the
# repository is configured for it. The signing steps are skipped until then, so an
@@ -89,10 +90,21 @@ jobs:
wait-for-completion: true
output-artifact-directory: assets # overwrites the staged files with signed ones
+ # The in-app updater (Velopack GithubSource) reads the channel feed index and
+ # the full .nupkg from the release assets. Stage them AFTER signing so the
+ # SignPath step only ever sees the .exe files (and its overwrite of the assets
+ # directory cannot drop the feed). Keep their original names, which the
+ # updater matches on.
+ - name: Stage Velopack update feed
+ shell: pwsh
+ run: |
+ Copy-Item releases/*.nupkg assets/ -ErrorAction SilentlyContinue
+ Copy-Item releases/*.json assets/ -ErrorAction SilentlyContinue
+
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
- files: assets/*.exe
+ files: assets/* # exes + Velopack feed (.nupkg, releases.*.json)
body_path: RELEASE_NOTES.md # curated intro, prepended...
generate_release_notes: true # ...then the auto per-version changelog
fail_on_unmatched_files: true
diff --git a/README.md b/README.md
index e8a9c99..fffe5c9 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,9 @@ app runs headless apart from a tray icon; there is no main window.
- A modern settings window and a dark tray menu (feature toggles, preset submenus)
- Global toggle hotkey **Ctrl+Shift+L**, launch-at-login, single-instance guard
- Settings persisted to `%APPDATA%\ClickLightWin\settings.json`
+- **Auto-update** (installer build only): checks GitHub Releases in the background
+ and, when a newer version is out, shows a "Restart to update" prompt in the tray.
+ Nothing installs until you click it; the portable exe updates manually.
See [docs/04-build-checklist.md](docs/04-build-checklist.md) for the roadmap this was built against.
diff --git a/src/ClickLightWin/AppController.cs b/src/ClickLightWin/AppController.cs
index 8c8ad78..0254b13 100644
--- a/src/ClickLightWin/AppController.cs
+++ b/src/ClickLightWin/AppController.cs
@@ -1,5 +1,7 @@
+using System.Windows.Threading;
using ClickLightWin.Overlay;
using ClickLightWin.Tray;
+using ClickLightWin.Update;
using ClickLightWin.Views;
using Application = System.Windows.Application;
@@ -19,10 +21,12 @@ public sealed class AppController : IDisposable
private readonly HotKeyManager _hotKeys = new();
private readonly SettingsStore _settingsStore = new();
private readonly LaunchAtLoginController _launchAtLogin = new();
+ private readonly UpdateService _updates = new();
private Settings _settings = new();
private TrayIcon? _tray;
private OverlayManager? _overlays;
private SettingsWindow? _settingsWindow;
+ private DispatcherTimer? _updateTimer;
public void Start()
{
@@ -48,6 +52,7 @@ public void Start()
persist: () => _settingsStore.Save(_settings),
openSettings: ShowSettings,
clearAnnotations: ClearAnnotations,
+ applyUpdate: () => _ = ApplyUpdateAsync(),
quit: () => Application.Current.Shutdown());
// One overlay per monitor; rebuilds itself on display changes.
@@ -67,6 +72,48 @@ public void Start()
_hook.Install();
UpdateKeyboardHook(); // installs the keyboard hook only if the shortcut display is on
+
+ InitializeUpdates();
+ }
+
+ // Auto-check for a newer release on startup and every few hours. Only the
+ // installed (Setup.exe) build is Velopack-managed, so this quietly no-ops for
+ // the portable exe and `dotnet run`. Updates are never applied silently: a
+ // found update only reveals the "Restart to update" tray prompt.
+ private void InitializeUpdates()
+ {
+ if (!_updates.IsSupported) return;
+ _updateTimer = new DispatcherTimer { Interval = TimeSpan.FromHours(6) };
+ _updateTimer.Tick += (_, _) => _ = CheckForUpdatesAsync();
+ _updateTimer.Start();
+ _ = CheckForUpdatesAsync();
+ }
+
+ private async Task CheckForUpdatesAsync()
+ {
+ try
+ {
+ var version = await _updates.CheckAsync();
+ if (version is not null) _tray?.ShowUpdateAvailable(version);
+ }
+ catch
+ {
+ // A failed check (offline, GitHub hiccup) is not worth bothering the
+ // user about; the next scheduled check will try again.
+ }
+ }
+
+ private async Task ApplyUpdateAsync()
+ {
+ try
+ {
+ _tray?.ShowInfo("ClickLight", "Downloading the update. The app will restart when it is ready.");
+ await _updates.DownloadAndRestartAsync(); // does not return on success
+ }
+ catch
+ {
+ _tray?.ShowWarning("Update failed", "The update could not be downloaded. Please try again later.");
+ }
}
// Privacy: the keyboard hook only runs while the shortcut display is enabled
@@ -187,6 +234,7 @@ private void ShowSettings()
public void Dispose()
{
_settingsStore.Save(_settings);
+ _updateTimer?.Stop();
_hook.Dispose();
_keyboardHook.Dispose();
_hotKeys.Dispose();
diff --git a/src/ClickLightWin/Tray/TrayIcon.cs b/src/ClickLightWin/Tray/TrayIcon.cs
index baa89f0..cce9550 100644
--- a/src/ClickLightWin/Tray/TrayIcon.cs
+++ b/src/ClickLightWin/Tray/TrayIcon.cs
@@ -17,10 +17,13 @@ public sealed class TrayIcon : IDisposable
private readonly Icon _iconImage;
private readonly ContextMenuStrip _menu;
private readonly Font _menuFont;
+ private readonly Font _updateFont;
private readonly Settings _settings;
private readonly LaunchAtLoginController _launchAtLogin;
private readonly Action _persist;
+ private readonly ToolStripMenuItem _updateItem;
+ private readonly ToolStripSeparator _updateSeparator;
private readonly ToolStripMenuItem _enabledItem;
private readonly ToolStripMenuItem _laserItem;
private readonly ToolStripMenuItem _releaseItem;
@@ -31,7 +34,8 @@ public sealed class TrayIcon : IDisposable
private readonly List<(ToolStripMenuItem Item, double Value)> _durationItems = [];
public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin,
- Action persist, Action openSettings, Action clearAnnotations, Action quit)
+ Action persist, Action openSettings, Action clearAnnotations,
+ Action applyUpdate, Action quit)
{
_settings = settings;
_launchAtLogin = launchAtLogin;
@@ -46,6 +50,13 @@ public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin,
};
menu.Opening += (_, _) => RefreshChecks();
+ // Hidden until an update is found, then shown at the very top as a prompt.
+ _updateFont = new Font(_menuFont, FontStyle.Bold);
+ _updateItem = Item("Restart to update", applyUpdate);
+ _updateItem.Font = _updateFont;
+ _updateItem.Visible = false;
+ _updateSeparator = new ToolStripSeparator { Visible = false };
+
_enabledItem = Check("Enabled", () => Toggle(s => s.Enabled = !s.Enabled));
_laserItem = Check("Laser pointer mode", () => Toggle(s => s.ShowLaserPointer = !s.ShowLaserPointer));
_releaseItem = Check("Show release ring", () => Toggle(s => s.ShowRelease = !s.ShowRelease));
@@ -53,6 +64,8 @@ public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin,
_shortcutsItem = Check("Show keyboard shortcuts", () => Toggle(s => s.ShowShortcuts = !s.ShowShortcuts));
_launchItem = Check("Launch at login", () => _launchAtLogin.SetEnabled(!_launchAtLogin.IsEnabled));
+ menu.Items.Add(_updateItem);
+ menu.Items.Add(_updateSeparator);
menu.Items.Add(_enabledItem);
menu.Items.Add(_laserItem);
menu.Items.Add(new ToolStripSeparator());
@@ -130,6 +143,23 @@ private ToolStripMenuItem PresetSubmenu(
public void ShowWarning(string title, string text) =>
_icon.ShowBalloonTip(5000, title, text, ToolTipIcon.Warning);
+ /// Show a transient informational balloon from the tray icon.
+ public void ShowInfo(string title, string text) =>
+ _icon.ShowBalloonTip(5000, title, text, ToolTipIcon.Info);
+
+ ///
+ /// Reveal the "Restart to update" prompt (top of the menu) and nudge the user
+ /// with a balloon. Clicking the item runs the applyUpdate callback.
+ ///
+ public void ShowUpdateAvailable(string version)
+ {
+ _updateItem.Text = $"Restart to update to v{version}";
+ _updateItem.Visible = true;
+ _updateSeparator.Visible = true;
+ ShowInfo("ClickLight update available",
+ $"Version {version} is ready. Right-click the tray icon and choose \"Restart to update\".");
+ }
+
private void Toggle(Action mutate)
{
mutate(_settings);
@@ -164,5 +194,6 @@ public void Dispose()
_iconImage.Dispose();
_menu.Dispose(); // NotifyIcon does not own its ContextMenuStrip
_menuFont.Dispose();
+ _updateFont.Dispose();
}
}
diff --git a/src/ClickLightWin/Update/UpdateService.cs b/src/ClickLightWin/Update/UpdateService.cs
new file mode 100644
index 0000000..ac5148f
--- /dev/null
+++ b/src/ClickLightWin/Update/UpdateService.cs
@@ -0,0 +1,48 @@
+using Velopack;
+using Velopack.Sources;
+
+namespace ClickLightWin.Update;
+
+///
+/// Wraps Velopack's to check GitHub Releases for a
+/// newer build and apply it on demand. Active only for the installed (Setup.exe)
+/// build: the portable exe and `dotnet run` are not Velopack-managed, so
+/// is false and every method is a no-op. The user is
+/// never updated silently, they choose when to apply via the tray.
+///
+public sealed class UpdateService
+{
+ private const string RepoUrl = "https://github.com/dvdstelt/ClickLightWin";
+
+ private readonly UpdateManager _manager = new(new GithubSource(RepoUrl, null, prerelease: false));
+ private UpdateInfo? _pending;
+
+ /// True only when running as a Velopack-installed app (not portable / dev).
+ public bool IsSupported => _manager.IsInstalled;
+
+ /// Version of a found-but-not-yet-applied update, or null if none is pending.
+ public string? PendingVersion => _pending?.TargetFullRelease?.Version?.ToString();
+
+ ///
+ /// Ask GitHub whether a newer stable release exists. Returns its version when
+ /// one is available (and remembers it for ),
+ /// or null when up to date or unsupported.
+ ///
+ public async Task CheckAsync()
+ {
+ if (!IsSupported) return null;
+ _pending = await _manager.CheckForUpdatesAsync();
+ return PendingVersion;
+ }
+
+ ///
+ /// Download the pending update and restart into it. No-op when nothing is
+ /// pending. This does not return on success: the process is replaced.
+ ///
+ public async Task DownloadAndRestartAsync()
+ {
+ if (_pending is null) return;
+ await _manager.DownloadUpdatesAsync(_pending);
+ _manager.ApplyUpdatesAndRestart(_pending);
+ }
+}