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
16 changes: 14 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
48 changes: 48 additions & 0 deletions src/ClickLightWin/AppController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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()
{
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -187,6 +234,7 @@ private void ShowSettings()
public void Dispose()
{
_settingsStore.Save(_settings);
_updateTimer?.Stop();
_hook.Dispose();
_keyboardHook.Dispose();
_hotKeys.Dispose();
Expand Down
33 changes: 32 additions & 1 deletion src/ClickLightWin/Tray/TrayIcon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -46,13 +50,22 @@ 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));
_dragItem = Check("Show drag trail", () => Toggle(s => s.ShowDrag = !s.ShowDrag));
_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());
Expand Down Expand Up @@ -130,6 +143,23 @@ private ToolStripMenuItem PresetSubmenu(
public void ShowWarning(string title, string text) =>
_icon.ShowBalloonTip(5000, title, text, ToolTipIcon.Warning);

/// <summary>Show a transient informational balloon from the tray icon.</summary>
public void ShowInfo(string title, string text) =>
_icon.ShowBalloonTip(5000, title, text, ToolTipIcon.Info);

/// <summary>
/// Reveal the "Restart to update" prompt (top of the menu) and nudge the user
/// with a balloon. Clicking the item runs the applyUpdate callback.
/// </summary>
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<Settings> mutate)
{
mutate(_settings);
Expand Down Expand Up @@ -164,5 +194,6 @@ public void Dispose()
_iconImage.Dispose();
_menu.Dispose(); // NotifyIcon does not own its ContextMenuStrip
_menuFont.Dispose();
_updateFont.Dispose();
}
}
48 changes: 48 additions & 0 deletions src/ClickLightWin/Update/UpdateService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Velopack;
using Velopack.Sources;

namespace ClickLightWin.Update;

/// <summary>
/// Wraps Velopack's <see cref="UpdateManager"/> 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
/// <see cref="IsSupported"/> is false and every method is a no-op. The user is
/// never updated silently, they choose when to apply via the tray.
/// </summary>
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;

/// <summary>True only when running as a Velopack-installed app (not portable / dev).</summary>
public bool IsSupported => _manager.IsInstalled;

/// <summary>Version of a found-but-not-yet-applied update, or null if none is pending.</summary>
public string? PendingVersion => _pending?.TargetFullRelease?.Version?.ToString();

/// <summary>
/// Ask GitHub whether a newer stable release exists. Returns its version when
/// one is available (and remembers it for <see cref="DownloadAndRestartAsync"/>),
/// or null when up to date or unsupported.
/// </summary>
public async Task<string?> CheckAsync()
{
if (!IsSupported) return null;
_pending = await _manager.CheckForUpdatesAsync();
return PendingVersion;
}

/// <summary>
/// Download the pending update and restart into it. No-op when nothing is
/// pending. This does not return on success: the process is replaced.
/// </summary>
public async Task DownloadAndRestartAsync()
{
if (_pending is null) return;
await _manager.DownloadUpdatesAsync(_pending);
_manager.ApplyUpdatesAndRestart(_pending);
}
}
Loading