diff --git a/README.md b/README.md
index 0d48bae..49b02f6 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,12 @@ Most AV kit ships set to DHCP, and on a bench or a fresh site there's usually no
2. Double-click it. Windows asks for administrator rights, because changing adapter settings and serving DHCP require them.
3. The exe isn't code-signed yet, so the first time Windows SmartScreen says "Windows protected your PC". Click **More info**, then **Run anyway**.
+### Updates
+
+When the laptop has internet, ProDHCPServer checks GitHub for a newer release a few seconds after it starts. If there is one, a green banner offers **Install and restart**: it downloads the new exe, checks it against the SHA-256 published with the release, swaps it in place of the old one and restarts. The server has to be stopped first. Nothing is changed if any step fails, and the previous exe is deleted the next time the app starts.
+
+On an isolated AV network the check simply doesn't happen. Turn the start-up check off, or check by hand, under Settings > Updates. On a work laptop that only runs approved programs, Windows may block the new version from starting; the app then puts the old one back and says so.
+
## Using it
1. Connect the device to your laptop's Ethernet port (directly or through a small switch) and power it on.
@@ -104,9 +110,10 @@ Each phase ends with a hardware test pass on real AV equipment before the next o
| What | Where |
|---|---|
-| Your settings (last adapter, profile in use, look and theme) | `%LOCALAPPDATA%\ProDHCPServer\settings.json` |
+| Your settings (last adapter, profile in use, look and theme, update check) | `%LOCALAPPDATA%\ProDHCPServer\settings.json` |
| Your profiles | `%LOCALAPPDATA%\ProDHCPServer\profiles.json` |
| Adapter backup (only while a session runs, or after a crash) | `%ProgramData%\ProDHCPServer\adapter-backup.json` |
+| The previous version, for a moment after an update | `ProDHCPServer.exe.old` next to the exe, deleted on the next start |
While serving, the app also adds two Windows Firewall rules for this exe only: one so it can receive DHCP traffic (UDP 67 and 68), and one on wired adapters so it can hear devices announcing themselves (mDNS, UPnP, vendor discovery). Both are removed on Stop.
diff --git a/docs/hardware-test-updates.md b/docs/hardware-test-updates.md
new file mode 100644
index 0000000..2299de7
--- /dev/null
+++ b/docs/hardware-test-updates.md
@@ -0,0 +1,47 @@
+# Update checker test
+
+About 15 minutes on the unmanaged laptop, with internet. No AV devices needed.
+
+The updater can only update to a release that's already published on GitHub, so this test updates a test build to the newest published release (v1.1.0). v1.1.0 doesn't have the updater itself, so after the update it leaves `ProDHCPServer.exe.old` behind; delete it by hand at the end.
+
+Download `ProDHCPServer.exe` from this PR's latest **Build and test** run (Actions tab → run → Artifacts → `ProDHCPServer-win-x64`) and put it in a folder of its own, for example `C:\Tools\ProDHCPServer\`. Click **More info → Run anyway** on the SmartScreen warning.
+
+Tick each box. If something fails, use **Export log…** and send me the file.
+
+## 1. Test builds don't nag
+
+- [ ] Start the app with internet. After 10 seconds there is **no** green update banner (test builds only check when asked).
+- [ ] Open Settings (gear, or Ctrl+,) and scroll to **Updates**. It says "This is test build 0.1.… It doesn't check for updates by itself; use Check now."
+
+## 2. Offline
+
+- [ ] Unplug or turn off the internet connection (Wi-Fi off, no network cable to the internet).
+- [ ] Press **Check now**. Within about 10 seconds it says "Couldn't reach GitHub to check for updates. That's normal on an isolated AV network…". No error banner, no popup.
+- [ ] The app otherwise works as normal: **Start** and **Stop** a session on a device or an empty port.
+
+## 3. Check and skip
+
+- [ ] Reconnect the internet. Press **Check now**. It says "Version 1.1.0 is available. You have test build 0.1.…", and the green banner appears at the top of the main window with **Install and restart**, **What's new**, **Not now** and **Skip this version**.
+- [ ] **What's new** opens the release page in your normal browser.
+- [ ] **Not now** hides the banner.
+
+## 4. Can't install while serving
+
+- [ ] Press **Check now** again so the banner is back, then **Start** the server.
+- [ ] **Install and restart** is greyed out and the banner says "Stop the server first: installing restarts ProDHCPServer."
+- [ ] Press **Stop**. **Install and restart** becomes clickable.
+
+## 5. Install
+
+- [ ] Press **Install and restart**. The banner shows "Downloading 1.1.0… nn%" with a progress bar, then the window closes and ProDHCPServer opens again by itself within a few seconds, with **no** UAC prompt.
+- [ ] The log's first line says "ProDHCPServer 1.1.0 started (administrator)."
+- [ ] In the folder, `ProDHCPServer.exe` is the new version (right-click → Properties → Details → Product version 1.1.0) and `ProDHCPServer.exe.old` is the test build. Close the app and delete the `.old` file.
+
+## Later, once a release with the updater is out
+
+When the release after this one is published, run the earlier release with the updater and check:
+
+- [ ] A few seconds after start, the green banner appears without pressing anything.
+- [ ] **Skip this version**, then restart the app: no banner. **Check now** in Settings still offers it.
+- [ ] Untick **Check for a new version when ProDHCPServer starts**, restart: no check in the log.
+- [ ] After **Install and restart**, the log says "started (administrator) after updating." and `ProDHCPServer.exe.old` is gone within a few seconds.
diff --git a/src/ProDHCPServer.App/App.xaml.cs b/src/ProDHCPServer.App/App.xaml.cs
index 7a3dbce..0f2a78b 100644
--- a/src/ProDHCPServer.App/App.xaml.cs
+++ b/src/ProDHCPServer.App/App.xaml.cs
@@ -42,7 +42,10 @@ protected override void OnStartup(StartupEventArgs e)
return;
}
- _singleInstance = new Mutex(true, @"Global\ProDHCPServer.SingleInstance", out bool first);
+ _singleInstance = new Mutex(true, SingleInstanceName, out bool first);
+ // Started by an update: the previous version is still closing, so give it time to let go.
+ bool updated = e.Args.Contains(UpdateViewModel.UpdatedFlag);
+ if (!first && updated) first = WaitForPreviousVersion(_singleInstance);
if (!first)
{
MessageBox.Show("ProDHCPServer is already running. Two copies would fight over the adapter, so this one will close.",
@@ -52,7 +55,9 @@ protected override void OnStartup(StartupEventArgs e)
}
_log = new SessionLog();
- _log.Add(LogSeverity.Info, LogCategory.App, $"ProDHCPServer {Version} started (administrator).");
+ _log.Add(LogSeverity.Info, LogCategory.App, updated
+ ? $"ProDHCPServer {Version} started (administrator) after updating."
+ : $"ProDHCPServer {Version} started (administrator).");
var adapters = new WindowsAdapterControl();
var sessions = new AdapterSessionManager(adapters, new FileSessionStore(Path.Combine(DataFolder, "adapter-backup.json")));
_controller = new ServerController(adapters, sessions, _log, Version);
@@ -78,6 +83,27 @@ protected override void OnStartup(StartupEventArgs e)
window.Show();
}
+ private const string SingleInstanceName = @"Global\ProDHCPServer.SingleInstance";
+
+ ///
+ /// Lets go of the one-copy lock just before an update starts the new version, so it can start even if
+ /// it's an older build without . undoes it.
+ ///
+ public void ReleaseSingleInstance()
+ {
+ _singleInstance?.ReleaseMutex();
+ _singleInstance?.Dispose();
+ _singleInstance = null;
+ }
+
+ public void RetakeSingleInstance() => _singleInstance ??= new Mutex(true, SingleInstanceName, out _);
+
+ private static bool WaitForPreviousVersion(Mutex mutex)
+ {
+ try { return mutex.WaitOne(TimeSpan.FromSeconds(30)); }
+ catch (AbandonedMutexException) { return true; } // it exited without releasing: the mutex is ours now
+ }
+
private void OnUiException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
// Keep running: show the error rather than vanish with the adapter half-configured.
diff --git a/src/ProDHCPServer.App/MainWindow.xaml b/src/ProDHCPServer.App/MainWindow.xaml
index 34fd4f9..2061a85 100644
--- a/src/ProDHCPServer.App/MainWindow.xaml
+++ b/src/ProDHCPServer.App/MainWindow.xaml
@@ -144,6 +144,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -754,6 +783,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProDHCPServer.App/ViewModels/MainViewModel.cs b/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
index ac05f6c..bf41f2f 100644
--- a/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
+++ b/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
@@ -67,6 +67,11 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
SaveSettings();
ApplyTheme();
};
+ Update = new UpdateViewModel(App.Version, log, () => _settings, updated =>
+ {
+ _settings = updated;
+ SaveSettings();
+ }, OpenUrl);
Steps = new ObservableCollection(Enum.GetValues().Select(id => new StepViewModel(id)));
RebuildLog();
@@ -89,10 +94,12 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
_timer.Tick += async (_, _) => await RefreshAsync();
_timer.Start();
_ = RefreshAsync();
+ _ = Update.CheckOnStartupAsync();
}
public SessionLog Log { get; }
public SettingsViewModel Settings { get; }
+ public UpdateViewModel Update { get; }
public ObservableCollection Adapters { get; } = [];
public ObservableCollection Steps { get; }
public ObservableCollection Devices { get; } = [];
@@ -124,6 +131,7 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
partial void OnIsRunningChanged(bool value)
{
Settings.IsLocked = value;
+ Update.ServerRunning = value;
SweepCommand.NotifyCanExecuteChanged();
}
diff --git a/src/ProDHCPServer.App/ViewModels/UpdateViewModel.cs b/src/ProDHCPServer.App/ViewModels/UpdateViewModel.cs
new file mode 100644
index 0000000..d2511fe
--- /dev/null
+++ b/src/ProDHCPServer.App/ViewModels/UpdateViewModel.cs
@@ -0,0 +1,240 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ProDHCPServer.Core;
+using ProDHCPServer.Core.Updates;
+
+namespace ProDHCPServer.App.ViewModels;
+
+///
+/// Update checks and in-place updates: a quiet check against GitHub's latest release at start-up (release
+/// builds only), "Check now" in settings, and Install, which downloads the new exe, checks its SHA-256,
+/// swaps it in and restarts. Every failure leaves the current version in place and says what to do next.
+///
+public sealed partial class UpdateViewModel : ObservableObject
+{
+ /// Command-line flag the new exe is started with, so it waits for this one to close.
+ public const string UpdatedFlag = "--updated";
+
+ private static readonly HttpClient Http = CreateClient();
+
+ private readonly UpdateChecker _checker;
+ private readonly SessionLog _log;
+ private readonly Func _getSettings;
+ private readonly Action _saveSettings;
+ private readonly Action _openUrl;
+ private bool _loading;
+
+ public UpdateViewModel(string currentVersion, SessionLog log, Func getSettings, Action saveSettings, Action openUrl)
+ {
+ _checker = new UpdateChecker(Http, currentVersion);
+ _log = log;
+ _getSettings = getSettings;
+ _saveSettings = saveSettings;
+ _openUrl = openUrl;
+ _loading = true;
+ CheckOnStart = getSettings().CheckForUpdates;
+ _loading = false;
+ Status = AppVersion.IsTestBuild(currentVersion)
+ ? $"This is test build {currentVersion}. It doesn't check for updates by itself; use Check now."
+ : $"You have version {currentVersion}.";
+ }
+
+ private static HttpClient CreateClient()
+ {
+ var client = new HttpClient();
+ // GitHub's API refuses requests without a User-Agent.
+ client.DefaultRequestHeaders.UserAgent.ParseAdd($"ProDHCPServer/{App.Version}");
+ return client;
+ }
+
+ /// The single-file exe this process runs from, or null when running from a build folder (updates can't swap that).
+ private static string? ExePath =>
+ // A build folder has ProDHCPServer.dll beside the exe; the published single file doesn't.
+ Environment.ProcessPath is { } p && !File.Exists(Path.ChangeExtension(p, ".dll")) ? p : null;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(Busy))]
+ [NotifyCanExecuteChangedFor(nameof(CheckNowCommand), nameof(InstallCommand))]
+ private bool _isChecking;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(Busy))]
+ [NotifyCanExecuteChangedFor(nameof(CheckNowCommand), nameof(InstallCommand))]
+ private bool _isInstalling;
+
+ /// Download progress, 0 to 100.
+ [ObservableProperty] private double _progress;
+
+ [ObservableProperty] private string _status = "";
+
+ /// Why the last Install didn't work, shown on the banner.
+ [ObservableProperty] private string? _installError;
+
+ /// The newer release, once a check finds one.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(InstallHint))]
+ [NotifyCanExecuteChangedFor(nameof(InstallCommand), nameof(OpenReleaseCommand))]
+ private ReleaseInfo? _available;
+
+ /// The banner on the main window; cleared by Not now or Skip.
+ [ObservableProperty] private string? _bannerText;
+
+ [ObservableProperty] private bool _checkOnStart;
+
+ /// Set while the DHCP server runs: installing restarts the app, so Stop comes first.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(InstallHint))]
+ [NotifyCanExecuteChangedFor(nameof(InstallCommand))]
+ private bool _serverRunning;
+
+ public bool Busy => IsChecking || IsInstalling;
+
+ public string? InstallHint => Available is null ? null
+ : ServerRunning ? "Stop the server first: installing restarts ProDHCPServer."
+ : !Available.CanInstall || ExePath is null ? "This copy can't update itself. Use Release page to download the new version."
+ : null;
+
+ partial void OnCheckOnStartChanged(bool value)
+ {
+ if (_loading) return;
+ _saveSettings(_getSettings() with { CheckForUpdates = value });
+ }
+
+ /// The start-up check: release builds only, a few seconds after the window opens, and silent unless there is something new.
+ public async Task CheckOnStartupAsync()
+ {
+ CleanUpAfterUpdate();
+ if (!CheckOnStart || AppVersion.IsTestBuild(_checker.CurrentVersion)) return;
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ var result = await RunCheckAsync();
+ if (result.Outcome == UpdateCheckOutcome.Available && result.Latest is { } latest
+ && _getSettings().SkippedUpdate != UpdateChecker.Show(latest.Version))
+ BannerText = $"ProDHCPServer {UpdateChecker.Show(latest.Version)} is available. You have {_checker.CurrentVersion}.";
+ }
+
+ private bool CanCheck() => !Busy;
+
+ [RelayCommand(CanExecute = nameof(CanCheck))]
+ private async Task CheckNow()
+ {
+ var result = await RunCheckAsync();
+ // Asked for by hand, so a skipped version is offered again.
+ if (result.Outcome == UpdateCheckOutcome.Available && result.Latest is { } latest)
+ BannerText = $"ProDHCPServer {UpdateChecker.Show(latest.Version)} is available. You have {_checker.CurrentVersion}.";
+ }
+
+ private async Task RunCheckAsync()
+ {
+ IsChecking = true;
+ Status = "Checking GitHub for a newer version…";
+ try
+ {
+ var result = await _checker.CheckAsync();
+ Status = result.Message;
+ Available = result.Outcome == UpdateCheckOutcome.Available ? result.Latest : null;
+ if (Available is null) BannerText = null;
+ _log.Add(LogSeverity.Info, LogCategory.App, $"Update check: {result.Message}");
+ return result;
+ }
+ finally { IsChecking = false; }
+ }
+
+ private bool CanInstall() => !Busy && !ServerRunning && Available is { CanInstall: true } && ExePath is not null;
+
+ [RelayCommand(CanExecute = nameof(CanInstall))]
+ private async Task Install()
+ {
+ if (Available is not { } release || ExePath is not { } exe) return;
+ var version = UpdateChecker.Show(release.Version);
+ IsInstalling = true;
+ InstallError = null;
+ Progress = 0;
+ Status = $"Downloading {version}…";
+ try
+ {
+ var download = ExeSwap.DownloadPath(exe);
+ await _checker.DownloadAsync(release, download, new Progress(p =>
+ {
+ Progress = p * 100;
+ Status = $"Downloading {version}… {p:P0}";
+ }));
+ Status = $"Installing {version}…";
+ _log.Add(LogSeverity.Info, LogCategory.App, $"Downloaded {release.Exe!.Name} and checked its SHA-256. Installing.");
+ try { ExeSwap.Install(exe, download); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ UpdateChecker.TryDelete(download);
+ throw new UpdateException($"Couldn't replace ProDHCPServer.exe ({ex.Message}). You still have {_checker.CurrentVersion}.", ex);
+ }
+ if (!TryStartNewVersion(exe, out var why))
+ {
+ ExeSwap.RollBack(exe);
+ throw new UpdateException(why);
+ }
+ _log.Add(LogSeverity.Success, LogCategory.App, $"Restarting into ProDHCPServer {version}.");
+ Application.Current.MainWindow?.Close();
+ }
+ catch (UpdateException ex)
+ {
+ Status = ex.Message;
+ InstallError = ex.Message;
+ _log.Add(LogSeverity.Warning, LogCategory.App, $"Update to {version} didn't install: {ex.Message}", ex.InnerException?.ToString());
+ }
+ finally { IsInstalling = false; }
+ }
+
+ /// Starts the new exe (it inherits this process's administrator rights, so no UAC prompt).
+ private bool TryStartNewVersion(string exe, out string why)
+ {
+ var app = (App)Application.Current;
+ app.ReleaseSingleInstance();
+ try
+ {
+ Process.Start(new ProcessStartInfo(exe, UpdatedFlag) { UseShellExecute = false, WorkingDirectory = Path.GetDirectoryName(exe)! });
+ why = "";
+ return true;
+ }
+ catch (Win32Exception ex)
+ {
+ app.RetakeSingleInstance();
+ // Work laptops with Defender attack surface reduction block new unsigned programs at this point.
+ why = $"Windows wouldn't start the new version ({ex.Message}). Work laptops that only run approved programs often block new versions like this. " +
+ $"You still have {_checker.CurrentVersion}. Ask IT to allow ProDHCPServer, or download it from the release page.";
+ return false;
+ }
+ }
+
+ private bool HasRelease() => Available is not null;
+
+ [RelayCommand(CanExecute = nameof(HasRelease))]
+ private void OpenRelease()
+ {
+ if (Available is { } r) _openUrl(r.PageUrl);
+ }
+
+ [RelayCommand]
+ private void NotNow() => BannerText = null;
+
+ [RelayCommand]
+ private void Skip()
+ {
+ if (Available is { } r) _saveSettings(_getSettings() with { SkippedUpdate = UpdateChecker.Show(r.Version) });
+ BannerText = null;
+ }
+
+ /// Deletes the previous exe a recent update left beside this one. It may still be closing, so this retries for a little while.
+ private void CleanUpAfterUpdate()
+ {
+ if (ExePath is not { } exe) return;
+ if (!File.Exists(ExeSwap.BackupPath(exe)) && !File.Exists(ExeSwap.DownloadPath(exe))) return;
+ _ = Task.Run(async () =>
+ {
+ for (int i = 0; i < 10 && !ExeSwap.CleanUp(exe); i++) await Task.Delay(1000);
+ });
+ }
+}
diff --git a/src/ProDHCPServer.Core/AppSettings.cs b/src/ProDHCPServer.Core/AppSettings.cs
index ca7b932..20b4f52 100644
--- a/src/ProDHCPServer.Core/AppSettings.cs
+++ b/src/ProDHCPServer.Core/AppSettings.cs
@@ -23,6 +23,10 @@ public sealed record AppSettings
public string Theme { get; init; } = "System";
/// Which look the window uses (a name from the App's theme list); unknown values fall back to the default.
public string Look { get; init; } = "Faceplate";
+ /// Look for a newer release on GitHub when the app starts.
+ public bool CheckForUpdates { get; init; } = true;
+ /// A release version the user chose to skip ("1.2.0"); the start-up check won't mention it again.
+ public string? SkippedUpdate { get; init; }
}
public sealed class SettingsStore(string path)
diff --git a/src/ProDHCPServer.Core/Updates/Updater.cs b/src/ProDHCPServer.Core/Updates/Updater.cs
new file mode 100644
index 0000000..735edd3
--- /dev/null
+++ b/src/ProDHCPServer.Core/Updates/Updater.cs
@@ -0,0 +1,269 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace ProDHCPServer.Core.Updates;
+
+public sealed record ReleaseAsset(string Name, string Url, long Size);
+
+/// A published GitHub release: its version, page, and the exe and checksum the Release workflow uploads.
+public sealed record ReleaseInfo(Version Version, string Tag, string PageUrl, ReleaseAsset? Exe, ReleaseAsset? Checksum)
+{
+ /// True when the release has both files needed to update in place.
+ public bool CanInstall => Exe is not null && Checksum is not null;
+}
+
+public enum UpdateCheckOutcome { UpToDate, Available, Failed }
+
+public sealed record UpdateCheckResult(UpdateCheckOutcome Outcome, ReleaseInfo? Latest, string Message);
+
+/// An update step that failed, with a message that can go straight on screen.
+public sealed class UpdateException(string message, Exception? inner = null) : Exception(message, inner);
+
+public static class AppVersion
+{
+ /// "1.2.3", "v1.2.3" or "1.2.3+sha" as a three-part version, or null.
+ public static Version? Parse(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ var s = text.Trim().TrimStart('v', 'V').Split('+', '-')[0];
+ if (!Version.TryParse(s, out var v)) return null;
+ return new Version(v.Major, v.Minor, Math.Max(v.Build, 0));
+ }
+
+ /// CI builds are numbered 0.1.(run number) and local builds have no version; releases start at 1.0.0.
+ public static bool IsTestBuild(string? version) => Parse(version) is not { Major: > 0 };
+}
+
+///
+/// Checks GitHub for the latest published release and downloads its exe, checking the SHA-256 the Release
+/// workflow publishes beside it. Field laptops are often offline or on an isolated AV network, so a failed
+/// check is an ordinary outcome, not an error.
+///
+public sealed class UpdateChecker(HttpClient http, string currentVersion)
+{
+ public const string LatestReleaseApi = "https://api.github.com/repos/cookingwithsimon/ProDHCPServer/releases/latest";
+ public const string LatestReleasePage = "https://github.com/cookingwithsimon/ProDHCPServer/releases/latest";
+
+ /// The exe is about 70 MB; anything far bigger isn't ours.
+ public const long MaxDownloadBytes = 400L * 1024 * 1024;
+
+ public static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
+
+ public string CurrentVersion { get; } = currentVersion;
+
+ public async Task CheckAsync(CancellationToken ct = default)
+ {
+ string json;
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(CheckTimeout);
+ using var request = new HttpRequestMessage(HttpMethod.Get, LatestReleaseApi);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+ using var response = await http.SendAsync(request, timeout.Token);
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ return Failed("GitHub has no published ProDHCPServer release yet.");
+ if (response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests)
+ return Failed("GitHub is limiting update checks from this network for now. Try again in an hour.");
+ if (!response.IsSuccessStatusCode)
+ return Failed($"GitHub answered the update check with an error ({(int)response.StatusCode}). Try again later.");
+ json = await response.Content.ReadAsStringAsync(timeout.Token);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ return Failed(OfflineMessage);
+ }
+ catch (HttpRequestException)
+ {
+ return Failed(OfflineMessage);
+ }
+
+ if (ParseRelease(json) is not { } latest)
+ return Failed("GitHub's answer didn't look like a ProDHCPServer release. Try again later.");
+ var current = AppVersion.Parse(CurrentVersion);
+ if (current is not null && latest.Version <= current)
+ return new(UpdateCheckOutcome.UpToDate, latest, $"You have the latest version ({Show(current)}).");
+ var have = AppVersion.IsTestBuild(CurrentVersion) ? $"You have test build {CurrentVersion}." : $"You have {Show(current!)}.";
+ return new(UpdateCheckOutcome.Available, latest, $"Version {Show(latest.Version)} is available. {have}");
+ }
+
+ private const string OfflineMessage =
+ "Couldn't reach GitHub to check for updates. That's normal on an isolated AV network; try again when this laptop has internet.";
+
+ private static UpdateCheckResult Failed(string message) => new(UpdateCheckOutcome.Failed, null, message);
+
+ public static string Show(Version v) => $"{v.Major}.{v.Minor}.{v.Build}";
+
+ /// Reads GitHub's release JSON. Drafts and pre-releases are ignored; returns null if it isn't a usable release.
+ public static ReleaseInfo? ParseRelease(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return null;
+ if (Bool(root, "draft") || Bool(root, "prerelease")) return null;
+ var tag = Str(root, "tag_name");
+ if (AppVersion.Parse(tag) is not { } version) return null;
+ var page = Str(root, "html_url");
+ if (!IsGitHubUrl(page)) page = LatestReleasePage;
+
+ var assets = new List();
+ if (root.TryGetProperty("assets", out var list) && list.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var a in list.EnumerateArray())
+ {
+ var name = Str(a, "name");
+ var url = Str(a, "browser_download_url");
+ if (name is null || !IsGitHubUrl(url)) continue;
+ var size = a.TryGetProperty("size", out var s) && s.TryGetInt64(out var n) ? n : 0;
+ assets.Add(new ReleaseAsset(name, url!, size));
+ }
+ }
+ var exe = assets.FirstOrDefault(a => a.Name.StartsWith("ProDHCPServer", StringComparison.OrdinalIgnoreCase)
+ && a.Name.EndsWith("-win-x64.exe", StringComparison.OrdinalIgnoreCase));
+ var sum = exe is null ? null : assets.FirstOrDefault(a => string.Equals(a.Name, exe.Name + ".sha256", StringComparison.OrdinalIgnoreCase));
+ return new ReleaseInfo(version, tag!, page!, exe, sum);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string? Str(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+
+ private static bool Bool(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
+
+ /// Only download from GitHub over HTTPS, whatever the JSON says.
+ private static bool IsGitHubUrl(string? url) =>
+ Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && uri.Host == "github.com";
+
+ /// The hash for from a sha256sum-style file ("hash name"), or a bare hash.
+ public static string? ParseChecksum(string text, string fileName)
+ {
+ foreach (var raw in text.Split('\n'))
+ {
+ var line = raw.Trim();
+ if (line.Length < 64) continue;
+ var hash = line[..64];
+ if (!hash.All(Uri.IsHexDigit)) continue;
+ var rest = line[64..].Trim().TrimStart('*');
+ if (rest.Length == 0 || string.Equals(rest, fileName, StringComparison.OrdinalIgnoreCase)) return hash.ToLowerInvariant();
+ }
+ return null;
+ }
+
+ ///
+ /// Downloads the release's exe to and checks it against the published SHA-256.
+ /// On any failure the partial file is deleted and an explains what happened.
+ ///
+ public async Task DownloadAsync(ReleaseInfo release, string destination, IProgress? progress = null, CancellationToken ct = default)
+ {
+ if (release.Exe is not { } exe || release.Checksum is not { } checksum)
+ throw new UpdateException("This release doesn't have the files needed to update in place. Download it from the release page instead.");
+ try
+ {
+ string expected;
+ try
+ {
+ var text = await http.GetStringAsync(checksum.Url, ct);
+ expected = ParseChecksum(text, exe.Name)
+ ?? throw new UpdateException("The release's checksum file couldn't be read, so the download can't be checked. Nothing was changed.");
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new UpdateException("Couldn't download the update. Check this laptop has internet and try again. Nothing was changed.", ex);
+ }
+
+ using var response = await http.GetAsync(exe.Url, HttpCompletionOption.ResponseHeadersRead, ct);
+ if (!response.IsSuccessStatusCode)
+ throw new UpdateException($"GitHub didn't send the update ({(int)response.StatusCode}). Try again later. Nothing was changed.");
+ var total = response.Content.Headers.ContentLength ?? (exe.Size > 0 ? exe.Size : (long?)null);
+ if (total > MaxDownloadBytes)
+ throw new UpdateException("The update file is far bigger than expected, so it wasn't downloaded. Nothing was changed.");
+
+ using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+ long received = 0;
+ await using (var source = await response.Content.ReadAsStreamAsync(ct))
+ await using (var file = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true))
+ {
+ var buffer = new byte[81920];
+ int read;
+ while ((read = await source.ReadAsync(buffer, ct)) > 0)
+ {
+ received += read;
+ if (received > MaxDownloadBytes)
+ throw new UpdateException("The update file is far bigger than expected, so the download was stopped. Nothing was changed.");
+ hash.AppendData(buffer, 0, read);
+ await file.WriteAsync(buffer.AsMemory(0, read), ct);
+ if (total > 0) progress?.Report(Math.Min(1.0, (double)received / total.Value));
+ }
+ }
+ var actual = Convert.ToHexStringLower(hash.GetHashAndReset());
+ if (actual != expected)
+ throw new UpdateException("The downloaded update didn't match its published checksum, so it was thrown away. Try again; if it keeps happening, download it from the release page. Nothing was changed.");
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException or OperationCanceledException or UpdateException)
+ {
+ TryDelete(destination);
+ if (ex is OperationCanceledException && ct.IsCancellationRequested) throw;
+ if (ex is UpdateException) throw;
+ if (ex is IOException or UnauthorizedAccessException)
+ throw new UpdateException($"Couldn't save the update next to ProDHCPServer.exe ({ex.Message}). Nothing was changed.", ex);
+ throw new UpdateException("The download stopped part way. Check this laptop has internet and try again. Nothing was changed.", ex);
+ }
+ }
+
+ public static void TryDelete(string path)
+ {
+ try { File.Delete(path); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+}
+
+///
+/// Swaps a downloaded exe in for the running one. Windows won't delete a running exe but will rename it,
+/// so the old one moves aside to "ProDHCPServer.exe.old" and is deleted the next time the app starts.
+///
+public static class ExeSwap
+{
+ public static string DownloadPath(string exePath) => exePath + ".download";
+ public static string BackupPath(string exePath) => exePath + ".old";
+
+ /// Puts in place of , keeping the old one as the backup.
+ public static void Install(string exePath, string newExe)
+ {
+ var backup = BackupPath(exePath);
+ File.Move(exePath, backup, overwrite: true);
+ try
+ {
+ File.Move(newExe, exePath);
+ }
+ catch
+ {
+ File.Move(backup, exePath, overwrite: true);
+ throw;
+ }
+ }
+
+ /// Puts the old exe back after the new one wouldn't start. The new one is deleted.
+ public static void RollBack(string exePath)
+ {
+ var backup = BackupPath(exePath);
+ if (!File.Exists(backup)) return;
+ File.Move(backup, exePath, overwrite: true);
+ }
+
+ /// Deletes the previous version and any half-finished download. Returns true if nothing is left behind.
+ public static bool CleanUp(string exePath)
+ {
+ UpdateChecker.TryDelete(DownloadPath(exePath));
+ UpdateChecker.TryDelete(BackupPath(exePath));
+ return !File.Exists(BackupPath(exePath)) && !File.Exists(DownloadPath(exePath));
+ }
+}
diff --git a/tests/ProDHCPServer.Core.Tests/UpdaterTests.cs b/tests/ProDHCPServer.Core.Tests/UpdaterTests.cs
new file mode 100644
index 0000000..b2a99f4
--- /dev/null
+++ b/tests/ProDHCPServer.Core.Tests/UpdaterTests.cs
@@ -0,0 +1,189 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using ProDHCPServer.Core.Updates;
+
+namespace ProDHCPServer.Core.Tests;
+
+public class UpdaterTests
+{
+ private const string ExeName = "ProDHCPServer-v1.2.0-win-x64.exe";
+ private const string ExeUrl = "https://github.com/cookingwithsimon/ProDHCPServer/releases/download/v1.2.0/" + ExeName;
+ private const string SumUrl = ExeUrl + ".sha256";
+
+ private static string ReleaseJson(string tag = "v1.2.0", bool draft = false, bool prerelease = false, string exeUrl = ExeUrl) => $$"""
+ {
+ "tag_name": "{{tag}}",
+ "html_url": "https://github.com/cookingwithsimon/ProDHCPServer/releases/tag/{{tag}}",
+ "draft": {{(draft ? "true" : "false")}},
+ "prerelease": {{(prerelease ? "true" : "false")}},
+ "assets": [
+ { "name": "{{ExeName}}", "browser_download_url": "{{exeUrl}}", "size": 11 },
+ { "name": "{{ExeName}}.sha256", "browser_download_url": "{{SumUrl}}", "size": 100 }
+ ]
+ }
+ """;
+
+ /// Answers each URL from a table; anything else is a network failure.
+ private sealed class FakeHandler(Dictionary> routes) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ routes.TryGetValue(request.RequestUri!.AbsoluteUri, out var make)
+ ? Task.FromResult(make())
+ : throw new HttpRequestException("No route to host");
+ }
+
+ private static Func Ok(string body) => () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) };
+ private static Func Ok(byte[] body) => () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(body) };
+
+ private static UpdateChecker Checker(string version, Dictionary> routes) =>
+ new(new HttpClient(new FakeHandler(routes)), version);
+
+ [Theory]
+ [InlineData("1.2.3", "1.2.3")]
+ [InlineData("v1.2.3", "1.2.3")]
+ [InlineData("1.2.3+abc123", "1.2.3")]
+ [InlineData("1.2", "1.2.0")]
+ public void Parses_versions(string text, string expected) => Assert.Equal(Version.Parse(expected), AppVersion.Parse(text));
+
+ [Theory]
+ [InlineData("0.1.57", true)]
+ [InlineData("dev", true)]
+ [InlineData(null, true)]
+ [InlineData("1.0.0", false)]
+ public void Ci_and_local_builds_are_test_builds(string? version, bool test) => Assert.Equal(test, AppVersion.IsTestBuild(version));
+
+ [Fact]
+ public void Reads_release_and_finds_exe_and_checksum()
+ {
+ var r = UpdateChecker.ParseRelease(ReleaseJson())!;
+ Assert.Equal(new Version(1, 2, 0), r.Version);
+ Assert.Equal(ExeName, r.Exe!.Name);
+ Assert.Equal(SumUrl, r.Checksum!.Url);
+ Assert.True(r.CanInstall);
+ }
+
+ [Fact]
+ public void Ignores_drafts_prereleases_odd_tags_and_non_github_downloads()
+ {
+ Assert.Null(UpdateChecker.ParseRelease(ReleaseJson(draft: true)));
+ Assert.Null(UpdateChecker.ParseRelease(ReleaseJson(prerelease: true)));
+ Assert.Null(UpdateChecker.ParseRelease(ReleaseJson(tag: "latest")));
+ Assert.Null(UpdateChecker.ParseRelease("not json"));
+ var elsewhere = UpdateChecker.ParseRelease(ReleaseJson(exeUrl: "https://example.com/" + ExeName))!;
+ Assert.Null(elsewhere.Exe);
+ Assert.False(elsewhere.CanInstall);
+ }
+
+ [Fact]
+ public void Reads_sha256sum_lines()
+ {
+ var hash = new string('a', 64);
+ Assert.Equal(hash, UpdateChecker.ParseChecksum($"{hash.ToUpperInvariant()} {ExeName}\n", ExeName));
+ Assert.Equal(hash, UpdateChecker.ParseChecksum($"{hash} *{ExeName}", ExeName));
+ Assert.Equal(hash, UpdateChecker.ParseChecksum(hash, ExeName));
+ Assert.Null(UpdateChecker.ParseChecksum($"{hash} other.exe", ExeName));
+ Assert.Null(UpdateChecker.ParseChecksum("garbage", ExeName));
+ }
+
+ [Fact]
+ public async Task Newer_release_is_available()
+ {
+ var result = await Checker("1.1.0", new() { [UpdateChecker.LatestReleaseApi] = Ok(ReleaseJson()) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.Available, result.Outcome);
+ Assert.Equal("Version 1.2.0 is available. You have 1.1.0.", result.Message);
+ }
+
+ [Fact]
+ public async Task Same_or_older_release_is_up_to_date()
+ {
+ var same = await Checker("1.2.0", new() { [UpdateChecker.LatestReleaseApi] = Ok(ReleaseJson()) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.UpToDate, same.Outcome);
+ var newer = await Checker("1.3.0", new() { [UpdateChecker.LatestReleaseApi] = Ok(ReleaseJson()) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.UpToDate, newer.Outcome);
+ }
+
+ [Fact]
+ public async Task Test_build_is_offered_the_release()
+ {
+ var result = await Checker("0.1.57", new() { [UpdateChecker.LatestReleaseApi] = Ok(ReleaseJson()) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.Available, result.Outcome);
+ Assert.Contains("test build 0.1.57", result.Message);
+ }
+
+ [Fact]
+ public async Task Offline_fails_quietly_with_a_plain_message()
+ {
+ var result = await Checker("1.1.0", []).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.Failed, result.Outcome);
+ Assert.Contains("isolated AV network", result.Message);
+ }
+
+ [Fact]
+ public async Task Rate_limit_is_explained()
+ {
+ var result = await Checker("1.1.0", new() { [UpdateChecker.LatestReleaseApi] = () => new HttpResponseMessage(HttpStatusCode.Forbidden) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.Failed, result.Outcome);
+ Assert.Contains("limiting", result.Message);
+ }
+
+ [Fact]
+ public async Task Download_checks_the_sha256()
+ {
+ var body = Encoding.ASCII.GetBytes("new version");
+ var hash = Convert.ToHexStringLower(SHA256.HashData(body));
+ var release = UpdateChecker.ParseRelease(ReleaseJson())!;
+ using var dir = new TempDir();
+ var dest = Path.Combine(dir.Path, "ProDHCPServer.exe.download");
+
+ var good = Checker("1.1.0", new() { [SumUrl] = Ok($"{hash} {ExeName}\n"), [ExeUrl] = Ok(body) });
+ var progress = new List();
+ await good.DownloadAsync(release, dest, new SyncProgress(progress.Add));
+ Assert.Equal(body, File.ReadAllBytes(dest));
+ Assert.Equal(1.0, progress[^1]);
+
+ File.Delete(dest);
+ var tampered = Checker("1.1.0", new() { [SumUrl] = Ok($"{hash} {ExeName}\n"), [ExeUrl] = Ok(Encoding.ASCII.GetBytes("bad version")) });
+ var ex = await Assert.ThrowsAsync(() => tampered.DownloadAsync(release, dest));
+ Assert.Contains("didn't match", ex.Message);
+ Assert.False(File.Exists(dest));
+
+ var offline = Checker("1.1.0", []);
+ await Assert.ThrowsAsync(() => offline.DownloadAsync(release, dest));
+ Assert.False(File.Exists(dest));
+ }
+
+ [Fact]
+ public void Swap_keeps_the_old_exe_until_clean_up_and_can_roll_back()
+ {
+ using var dir = new TempDir();
+ var exe = Path.Combine(dir.Path, "ProDHCPServer.exe");
+ File.WriteAllText(exe, "old");
+ File.WriteAllText(ExeSwap.DownloadPath(exe), "new");
+
+ ExeSwap.Install(exe, ExeSwap.DownloadPath(exe));
+ Assert.Equal("new", File.ReadAllText(exe));
+ Assert.Equal("old", File.ReadAllText(ExeSwap.BackupPath(exe)));
+
+ ExeSwap.RollBack(exe);
+ Assert.Equal("old", File.ReadAllText(exe));
+ Assert.False(File.Exists(ExeSwap.BackupPath(exe)));
+
+ File.WriteAllText(ExeSwap.BackupPath(exe), "older");
+ Assert.True(ExeSwap.CleanUp(exe));
+ Assert.False(File.Exists(ExeSwap.BackupPath(exe)));
+ Assert.Equal("old", File.ReadAllText(exe));
+ }
+
+ /// Progress<T> reports on the thread pool; this one reports straight away so the test can read it.
+ private sealed class SyncProgress(Action report) : IProgress
+ {
+ public void Report(double value) => report(value);
+ }
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; } = Directory.CreateTempSubdirectory("prodhcp-update-").FullName;
+ public void Dispose() => Directory.Delete(Path, recursive: true);
+ }
+}