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); + } +}