diff --git a/CHANGELOG.md b/CHANGELOG.md
index 486fc34..650465d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Added
+- **Automatic update checks and one-click updates.** FanaBridge now checks GitHub for new releases and offers to install them, with a restart of SimHub to finish. Can be turned off in About. ([#96](https://github.com/kelchm/FanaBridge/pull/96), closes [#95](https://github.com/kelchm/FanaBridge/issues/95))
- **A wheel's segment display can now be left to another application.** Display Mode "None" stops FanaBridge writing to it, so Fanatec's own software can drive the display while FanaBridge keeps the LEDs. The option previously existed only for the legacy page of ITM wheels. ([#97](https://github.com/kelchm/FanaBridge/pull/97))
### Fixed
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 03e98c0..54b06f9 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -13,10 +13,10 @@
-
-
+
diff --git a/FanaBridge.sln b/FanaBridge.sln
index 5a5d86a..8d62e65 100644
--- a/FanaBridge.sln
+++ b/FanaBridge.sln
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FanaBridge", "src\FanaBridg
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FanaBridge.Core", "src\FanaBridge.Core\FanaBridge.Core.csproj", "{C7D9EB13-5A46-6180-BC2D-3E4F5A6B7C8D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FanaBridge.Updater", "src\FanaBridge.Updater\FanaBridge.Updater.csproj", "{E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FanaBridge.Tests", "tests\FanaBridge.Tests\FanaBridge.Tests.csproj", "{59946302-1B57-4599-BFF0-94607AF98C6E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A5B7C9D1-3E24-4F68-9A0B-1C2D3E4F5A6B}"
@@ -47,6 +49,18 @@ Global
{C7D9EB13-5A46-6180-BC2D-3E4F5A6B7C8D}.Release|x64.Build.0 = Release|Any CPU
{C7D9EB13-5A46-6180-BC2D-3E4F5A6B7C8D}.Release|x86.ActiveCfg = Release|Any CPU
{C7D9EB13-5A46-6180-BC2D-3E4F5A6B7C8D}.Release|x86.Build.0 = Release|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Debug|x64.Build.0 = Debug|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Debug|x86.Build.0 = Debug|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Release|x64.ActiveCfg = Release|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Release|x64.Build.0 = Release|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Release|x86.ActiveCfg = Release|Any CPU
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01}.Release|x86.Build.0 = Release|Any CPU
{59946302-1B57-4599-BFF0-94607AF98C6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59946302-1B57-4599-BFF0-94607AF98C6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59946302-1B57-4599-BFF0-94607AF98C6E}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -66,6 +80,7 @@ Global
GlobalSection(NestedProjects) = preSolution
{77D40755-B5B5-4C53-BD35-1CDEF35AF422} = {A5B7C9D1-3E24-4F68-9A0B-1C2D3E4F5A6B}
{C7D9EB13-5A46-6180-BC2D-3E4F5A6B7C8D} = {A5B7C9D1-3E24-4F68-9A0B-1C2D3E4F5A6B}
+ {E8F1AC35-7D68-4A02-9E5F-6B7C8D9EAF01} = {A5B7C9D1-3E24-4F68-9A0B-1C2D3E4F5A6B}
{59946302-1B57-4599-BFF0-94607AF98C6E} = {B6C8DA02-4F35-5079-AB1C-2D3E4F5A6B7C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
diff --git a/src/FanaBridge.Updater/FanaBridge.Updater.csproj b/src/FanaBridge.Updater/FanaBridge.Updater.csproj
new file mode 100644
index 0000000..476b99d
--- /dev/null
+++ b/src/FanaBridge.Updater/FanaBridge.Updater.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net48
+ Library
+ FanaBridge.Updater
+ FanaBridge.Updater
+ FanaBridge self-updater (SimHub-free): release feed, download verification, in-place file swap
+ FanaBridge
+ Copyright (c) 2026
+ true
+ false
+ embedded
+
+
+
+
+
+
+
+
+
+
+ $(SimHubDir)Newtonsoft.Json.dll
+ false
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FanaBridge.Updater/ReleaseFeed.cs b/src/FanaBridge.Updater/ReleaseFeed.cs
new file mode 100644
index 0000000..4162667
--- /dev/null
+++ b/src/FanaBridge.Updater/ReleaseFeed.cs
@@ -0,0 +1,203 @@
+#nullable enable
+using System;
+using System.Text.RegularExpressions;
+using Newtonsoft.Json.Linq;
+
+namespace FanaBridge.Updater
+{
+ ///
+ /// Parsed GitHub release metadata used by the self-updater. A release can be
+ /// reportable to the user even when it cannot be self-installed (missing zip asset
+ /// or digest → notify-only with a manual download link).
+ ///
+ public sealed class ReleaseInfo
+ {
+ /// GitHub tag name, e.g. v0.7.0.
+ public string TagName { get; }
+
+ /// Version string with a leading v/V stripped, e.g. 0.7.0.
+ public string Version { get; }
+
+ /// HTML URL of the release page (manual download fallback).
+ public string HtmlUrl { get; }
+
+ /// Exact zip asset name when found, otherwise null.
+ public string? ZipName { get; }
+
+ /// browser_download_url of the zip asset when found.
+ public string? ZipUrl { get; }
+
+ /// Asset size in bytes from the API, or 0 when unknown.
+ public long ZipSizeBytes { get; }
+
+ ///
+ /// 64 lowercase hex characters of the asset's GitHub digest field,
+ /// without the sha256: prefix; null when missing or malformed.
+ ///
+ public string? DigestHex { get; }
+
+ /// True when zip URL and a valid digest are both present for self-install.
+ public bool CanSelfInstall { get; }
+
+ /// Human-readable reason when is false; null otherwise.
+ public string? InstallBlockedReason { get; }
+
+ /// Creates an immutable release snapshot.
+ public ReleaseInfo(
+ string tagName,
+ string version,
+ string htmlUrl,
+ string? zipName,
+ string? zipUrl,
+ long zipSizeBytes,
+ string? digestHex,
+ bool canSelfInstall,
+ string? installBlockedReason)
+ {
+ TagName = tagName ?? throw new ArgumentNullException(nameof(tagName));
+ Version = version ?? throw new ArgumentNullException(nameof(version));
+ HtmlUrl = htmlUrl ?? throw new ArgumentNullException(nameof(htmlUrl));
+ ZipName = zipName;
+ ZipUrl = zipUrl;
+ ZipSizeBytes = zipSizeBytes;
+ DigestHex = digestHex;
+ CanSelfInstall = canSelfInstall;
+ InstallBlockedReason = installBlockedReason;
+ }
+ }
+
+ ///
+ /// Parses GitHub Releases API JSON into .
+ /// Note: GET /repos/{owner}/{repo}/releases/latest excludes drafts and prereleases
+ /// by GitHub semantics — that is intentional for the self-updater feed.
+ ///
+ public static class ReleaseFeed
+ {
+ // GitHub asset digests are "sha256:" + 64 hex digits (immutable upload-time hash).
+ private static readonly Regex DigestPattern =
+ new Regex(@"^sha256:([0-9a-fA-F]{64})$", RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ ///
+ /// Parses a GET /repos/{owner}/{repo}/releases/latest response body.
+ /// Returns null with a non-null error ONLY for structurally unusable
+ /// responses (malformed JSON, missing/unparseable tag_name, missing html_url).
+ /// A parseable release with a missing/ambiguous zip asset or a missing/
+ /// malformed digest returns a with
+ /// =false and a human-readable
+ /// (notify-only mode), NOT an error.
+ ///
+ public static ReleaseInfo? Parse(string json, out string? error)
+ {
+ error = null;
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ error = "Release feed response is empty.";
+ return null;
+ }
+
+ JObject root;
+ try
+ {
+ root = JObject.Parse(json);
+ }
+ catch (Exception ex)
+ {
+ error = "Release feed JSON is malformed: " + ex.Message;
+ return null;
+ }
+
+ string? tagName = root.Value("tag_name");
+ if (string.IsNullOrWhiteSpace(tagName))
+ {
+ error = "Release feed is missing tag_name.";
+ return null;
+ }
+
+ // Normalize before deriving anything: stray whitespace would poison
+ // the asset-name match and the UI version string, while TryParse
+ // (which trims internally) would still succeed.
+ tagName = tagName!.Trim();
+
+ // Version string for the UI/asset name: strip a single leading v/V only.
+ string version = tagName;
+ if (version.Length > 0 && (version[0] == 'v' || version[0] == 'V'))
+ version = version.Substring(1);
+
+ if (!UpdateVersion.TryParse(tagName, out _))
+ {
+ error = "Release feed tag_name is not a parseable version: " + tagName;
+ return null;
+ }
+
+ string? htmlUrl = root.Value("html_url");
+ if (string.IsNullOrWhiteSpace(htmlUrl))
+ {
+ error = "Release feed is missing html_url.";
+ return null;
+ }
+
+ string expectedZip = "FanaBridge-" + version + ".zip";
+ string? zipName = null;
+ string? zipUrl = null;
+ long zipSize = 0;
+ string? digestRaw = null;
+
+ JToken? assetsToken = root["assets"];
+ if (assetsToken is JArray assets)
+ {
+ foreach (JToken asset in assets)
+ {
+ if (asset is not JObject ao)
+ continue;
+ string? name = ao.Value("name");
+ // Exact asset name — GitHub enforces unique names per release.
+ if (!string.Equals(name, expectedZip, StringComparison.Ordinal))
+ continue;
+
+ zipName = name;
+ zipUrl = ao.Value("browser_download_url");
+ // Defensive: a non-numeric "size" must degrade to unknown, not
+ // throw out of the parse contract.
+ JToken? sizeToken = ao["size"];
+ zipSize = sizeToken != null && sizeToken.Type == JTokenType.Integer
+ ? (long)sizeToken
+ : 0;
+ digestRaw = ao.Value("digest");
+ break;
+ }
+ }
+
+ string? digestHex = null;
+ string? blocked = null;
+
+ if (zipName == null || string.IsNullOrWhiteSpace(zipUrl))
+ {
+ blocked = "Release asset '" + expectedZip + "' was not found; open the release page to install manually.";
+ }
+ else
+ {
+ Match m = DigestPattern.Match(digestRaw ?? string.Empty);
+ if (!m.Success)
+ {
+ blocked = "Release asset digest is missing or malformed; open the release page to install manually.";
+ }
+ else
+ {
+ digestHex = m.Groups[1].Value.ToLowerInvariant();
+ }
+ }
+
+ bool canInstall = blocked == null && digestHex != null && !string.IsNullOrWhiteSpace(zipUrl);
+ return new ReleaseInfo(
+ tagName: tagName,
+ version: version,
+ htmlUrl: htmlUrl!,
+ zipName: zipName,
+ zipUrl: zipUrl,
+ zipSizeBytes: zipSize,
+ digestHex: digestHex,
+ canSelfInstall: canInstall,
+ installBlockedReason: canInstall ? null : blocked);
+ }
+ }
+}
diff --git a/src/FanaBridge.Updater/UpdateFileSwapper.cs b/src/FanaBridge.Updater/UpdateFileSwapper.cs
new file mode 100644
index 0000000..44da417
--- /dev/null
+++ b/src/FanaBridge.Updater/UpdateFileSwapper.cs
@@ -0,0 +1,366 @@
+#nullable enable
+using System;
+using System.Diagnostics;
+using System.IO;
+
+namespace FanaBridge.Updater
+{
+ /// Outcome of an in-place DLL swap attempt.
+ public sealed class SwapResult
+ {
+ /// True when the live DLL was replaced successfully.
+ public bool Success { get; }
+
+ /// Failure detail; non-null iff is false.
+ public string? Error { get; }
+
+ ///
+ /// True when the second commit rename failed but the live DLL was restored
+ /// from .old, so the install is intact at the previous version.
+ ///
+ public bool RolledBack { get; }
+
+ ///
+ /// True when the failure was caused by
+ /// or an access-denied (HRESULT 0x80070005).
+ ///
+ public bool AccessDenied { get; }
+
+ /// Creates a swap outcome.
+ public SwapResult(bool success, string? error, bool rolledBack, bool accessDenied)
+ {
+ Success = success;
+ Error = error;
+ RolledBack = rolledBack;
+ AccessDenied = accessDenied;
+ }
+
+ /// Successful swap.
+ public static SwapResult Ok() => new SwapResult(true, null, false, false);
+
+ /// Failed swap with optional rollback / access-denied flags.
+ public static SwapResult Fail(string error, bool rolledBack = false, bool accessDenied = false)
+ => new SwapResult(false, error, rolledBack, accessDenied);
+ }
+
+ ///
+ /// Commits a staged update into the SimHub install directory using a
+ /// write-then-two-rename strategy so the crash window after the live DLL is
+ /// touched is two metadata operations. User data under
+ /// installDir\FanaBridge\ is never read or written.
+ ///
+ public sealed class UpdateFileSwapper
+ {
+ /// Suffix of the previous live DLL kept for rollback.
+ public const string OldSuffix = ".old";
+
+ /// Suffix of the fully-written staged DLL before the commit rename.
+ public const string NewSuffix = ".new";
+
+ // ERROR_ACCESS_DENIED — IOException HResult on net48 for ACL/share denial.
+ private const int HResultAccessDenied = unchecked((int)0x80070005);
+
+ private readonly Action _move;
+ private readonly Action _copyOverwrite;
+ private readonly Action _delete;
+ private readonly Func _exists;
+ private readonly Func _readFileVersion;
+ private readonly Action _logWarn;
+
+ ///
+ /// All seams optional; defaults use System.IO
+ /// (,
+ /// overwrite, , ,
+ /// FileVersion).
+ ///
+ public UpdateFileSwapper(
+ Action? move = null,
+ Action? copyOverwrite = null,
+ Action? delete = null,
+ Func? exists = null,
+ Func? readFileVersion = null,
+ Action? logWarn = null)
+ {
+ _move = move ?? ((src, dst) => File.Move(src, dst));
+ _copyOverwrite = copyOverwrite ?? ((src, dst) => File.Copy(src, dst, overwrite: true));
+ _delete = delete ?? File.Delete;
+ _exists = exists ?? File.Exists;
+ _readFileVersion = readFileVersion ?? DefaultReadFileVersion;
+ _logWarn = logWarn ?? (_ => { });
+ }
+
+ private static string? DefaultReadFileVersion(string path)
+ {
+ try
+ {
+ return FileVersionInfo.GetVersionInfo(path).FileVersion;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Let Apply's outer catch classify this as access-denied instead
+ // of reporting a misleading generic version mismatch.
+ throw;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Applies the staged package under into
+ /// . When is
+ /// non-null (release version like 0.7.0), the staged DLL's FileVersion
+ /// Major.Minor.Build must match before any live file is touched.
+ ///
+ public SwapResult Apply(string stagingDir, string installDir, string? expectedVersion)
+ {
+ if (string.IsNullOrWhiteSpace(stagingDir))
+ return SwapResult.Fail("Staging directory is required.");
+ if (string.IsNullOrWhiteSpace(installDir))
+ return SwapResult.Fail("Install directory is required.");
+
+ string stagedDll = Path.Combine(stagingDir, UpdatePackage.DllName);
+ string liveDll = Path.Combine(installDir, UpdatePackage.DllName);
+ string oldDll = liveDll + OldSuffix;
+ string newDll = liveDll + NewSuffix;
+
+ try
+ {
+ // 1. Staged DLL must exist.
+ if (!_exists(stagedDll))
+ return SwapResult.Fail("Staged " + UpdatePackage.DllName + " is missing.");
+
+ // 2. Clear pre-existing .old / .new; fail closed if delete fails so we
+ // never rename over an ambiguous leftover.
+ if (_exists(oldDll))
+ {
+ try { _delete(oldDll); }
+ catch (Exception ex)
+ {
+ return FailClosed("Could not remove pre-existing " + UpdatePackage.DllName + OldSuffix + ": " + ex.Message, ex);
+ }
+ if (_exists(oldDll))
+ return SwapResult.Fail("Could not remove pre-existing " + UpdatePackage.DllName + OldSuffix + ".");
+ }
+ if (_exists(newDll))
+ {
+ try { _delete(newDll); }
+ catch (Exception ex)
+ {
+ return FailClosed("Could not remove pre-existing " + UpdatePackage.DllName + NewSuffix + ": " + ex.Message, ex);
+ }
+ if (_exists(newDll))
+ return SwapResult.Fail("Could not remove pre-existing " + UpdatePackage.DllName + NewSuffix + ".");
+ }
+
+ // 3. Full write of .new before touching the live file; version sanity first.
+ _copyOverwrite(stagedDll, newDll);
+
+ if (expectedVersion != null)
+ {
+ string? fileVer = _readFileVersion(newDll);
+ if (!VersionsMatch(fileVer, expectedVersion))
+ {
+ TryDelete(newDll);
+ return SwapResult.Fail(
+ "Staged DLL FileVersion '" + (fileVer ?? "") +
+ "' does not match expected release version '" + expectedVersion + "'.");
+ }
+ }
+
+ // 4. Commit rename 1: live → .old (rename-while-loaded on NTFS).
+ _move(liveDll, oldDll);
+
+ // 5. Commit rename 2: .new → live; restore from .old on failure.
+ try
+ {
+ _move(newDll, liveDll);
+ }
+ catch (Exception commitEx)
+ {
+ bool restored = false;
+ try
+ {
+ _move(oldDll, liveDll);
+ restored = true;
+ }
+ catch (Exception restoreEx)
+ {
+ _logWarn(
+ "CRITICAL: update commit failed and rollback failed. The plugin DLL is currently named '" +
+ UpdatePackage.DllName + OldSuffix + "' and must be renamed back to '" +
+ UpdatePackage.DllName + "' manually. Commit error: " + commitEx.Message +
+ "; restore error: " + restoreEx.Message);
+ return SwapResult.Fail(
+ "Update commit failed and rollback failed. The plugin DLL is currently named '" +
+ UpdatePackage.DllName + OldSuffix + "' and must be renamed back to '" +
+ UpdatePackage.DllName + "' manually. " + commitEx.Message,
+ rolledBack: false,
+ accessDenied: IsAccessDenied(commitEx) || IsAccessDenied(restoreEx));
+ }
+
+ return SwapResult.Fail(
+ "Update commit failed after renaming the live DLL; install restored from " +
+ UpdatePackage.DllName + OldSuffix + ". " + commitEx.Message,
+ rolledBack: restored,
+ accessDenied: IsAccessDenied(commitEx));
+ }
+
+ // 6. Logos are cosmetic — per-file failure is warn-only, not fatal.
+ CopyLogosBestEffort(stagingDir, installDir);
+
+ // 7. Only extractor-staged files were touched; installDir\FanaBridge\ is never used.
+ return SwapResult.Ok();
+ }
+ catch (Exception ex)
+ {
+ // Don't leave a half-written .new behind (a retry would have to
+ // delete it anyway, and next launch would sweep it regardless).
+ TryDelete(newDll);
+ return FailClosed(ex.Message, ex);
+ }
+ }
+
+ ///
+ /// Best-effort deletion of FanaBridge.dll.old / FanaBridge.dll.new in
+ /// and stale FanaBridge-update-* dirs under
+ /// (skipped when tempRoot is null). Never throws.
+ ///
+ public static void CleanupStaleArtifacts(string installDir, string? tempRoot, Action? logWarn)
+ {
+ Action warn = logWarn ?? (_ => { });
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(installDir))
+ {
+ TryDeleteQuiet(Path.Combine(installDir, UpdatePackage.DllName + OldSuffix), warn);
+ TryDeleteQuiet(Path.Combine(installDir, UpdatePackage.DllName + NewSuffix), warn);
+ }
+
+ if (string.IsNullOrWhiteSpace(tempRoot) || !Directory.Exists(tempRoot))
+ return;
+
+ foreach (string dir in Directory.GetDirectories(tempRoot, "FanaBridge-update-*"))
+ {
+ try
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ catch (Exception ex)
+ {
+ warn("Could not remove stale update staging dir '" + dir + "': " + ex.Message);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ warn("CleanupStaleArtifacts: " + ex.Message);
+ }
+ }
+
+ // Runs strictly AFTER the DLL commit, so nothing in here may surface as a
+ // swap failure: a Failed state on a live new DLL would let a retry delete
+ // the .old rollback copy. Fully non-throwing, including the warn delegate.
+ private void CopyLogosBestEffort(string stagingDir, string installDir)
+ {
+ try
+ {
+ string stagedLogos = Path.Combine(stagingDir, UpdatePackage.LogosDirName);
+ if (!Directory.Exists(stagedLogos))
+ return;
+
+ string destLogos = Path.Combine(installDir, UpdatePackage.LogosDirName);
+ try
+ {
+ Directory.CreateDirectory(destLogos);
+ }
+ catch (Exception ex)
+ {
+ WarnQuiet("Could not create DevicesLogos directory: " + ex.Message);
+ return;
+ }
+
+ foreach (string src in Directory.GetFiles(stagedLogos, "*.png"))
+ {
+ string dest = Path.Combine(destLogos, Path.GetFileName(src));
+ try
+ {
+ _copyOverwrite(src, dest);
+ }
+ catch (Exception ex)
+ {
+ WarnQuiet("Could not copy logo '" + Path.GetFileName(src) + "': " + ex.Message);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ WarnQuiet("Logo copy skipped: " + ex.Message);
+ }
+ }
+
+ private void WarnQuiet(string message)
+ {
+ try { _logWarn(message); } catch { /* cosmetic step must never throw */ }
+ }
+
+ private SwapResult FailClosed(string message, Exception ex)
+ => SwapResult.Fail(message, rolledBack: false, accessDenied: IsAccessDenied(ex));
+
+ private void TryDelete(string path)
+ {
+ try
+ {
+ if (_exists(path))
+ _delete(path);
+ }
+ catch (Exception ex)
+ {
+ _logWarn("Best-effort delete failed for '" + path + "': " + ex.Message);
+ }
+ }
+
+ private static void TryDeleteQuiet(string path, Action warn)
+ {
+ try
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ catch (Exception ex)
+ {
+ warn("Could not delete '" + path + "': " + ex.Message);
+ }
+ }
+
+ ///
+ /// Compares FileVersion (often four-part) to a release version string by
+ /// Major.Minor.Build, treating missing Build as 0.
+ ///
+ private static bool VersionsMatch(string? fileVersion, string expectedVersion)
+ {
+ if (string.IsNullOrWhiteSpace(fileVersion))
+ return false;
+ if (!Version.TryParse(fileVersion, out Version? fileVer) || fileVer == null)
+ return false;
+ if (!Version.TryParse(expectedVersion, out Version? expected) || expected == null)
+ return false;
+
+ int fileBuild = fileVer.Build < 0 ? 0 : fileVer.Build;
+ int expBuild = expected.Build < 0 ? 0 : expected.Build;
+ return fileVer.Major == expected.Major
+ && fileVer.Minor == expected.Minor
+ && fileBuild == expBuild;
+ }
+
+ private static bool IsAccessDenied(Exception ex)
+ {
+ if (ex is UnauthorizedAccessException)
+ return true;
+ if (ex is IOException io && io.HResult == HResultAccessDenied)
+ return true;
+ return false;
+ }
+ }
+}
diff --git a/src/FanaBridge.Updater/UpdatePackage.cs b/src/FanaBridge.Updater/UpdatePackage.cs
new file mode 100644
index 0000000..39c8622
--- /dev/null
+++ b/src/FanaBridge.Updater/UpdatePackage.cs
@@ -0,0 +1,189 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace FanaBridge.Updater
+{
+ ///
+ /// Integrity check and whitelist extraction for a FanaBridge release zip.
+ /// Only the merged plugin DLL and top-level DevicesLogos PNGs are ever written;
+ /// everything else is ignored, which also defeats zip-slip path traversal.
+ ///
+ public static class UpdatePackage
+ {
+ /// Root plugin assembly name inside the release zip and install dir.
+ public const string DllName = "FanaBridge.dll";
+
+ /// Cosmetic device-logo directory name (sibling of the plugin DLL).
+ public const string LogosDirName = "DevicesLogos";
+
+ // Caps are hard-coded: release zips are tiny (one DLL + a handful of PNGs).
+ // Sizes are enforced while streaming so zip headers cannot under-report.
+ private const int MaxArchiveEntries = 512;
+ private const long MaxBytesPerEntry = 20L * 1024 * 1024;
+ private const long MaxTotalExtractedBytes = 50L * 1024 * 1024;
+
+ ///
+ /// Returns true when 's SHA-256 matches
+ /// (case-insensitive hex, no prefix).
+ ///
+ public static bool VerifySha256(byte[] data, string expectedHex)
+ {
+ if (data == null)
+ throw new ArgumentNullException(nameof(data));
+ if (string.IsNullOrEmpty(expectedHex) || expectedHex.Length != 64)
+ return false;
+
+ byte[] hash;
+ using (var sha = SHA256.Create())
+ hash = sha.ComputeHash(data);
+
+ string actual = ToHex(hash);
+ return string.Equals(actual, expectedHex, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Extracts ONLY whitelisted entries into (created if
+ /// needed; caller passes a fresh private dir). Returns relative paths written
+ /// (e.g. FanaBridge.dll, DevicesLogos\x.png).
+ /// Throws (message = reason) on: no root
+ /// FanaBridge.dll entry, duplicate whitelisted entries, caps exceeded, unreadable zip.
+ ///
+ public static IReadOnlyList ExtractToStaging(byte[] zipBytes, string stagingDir)
+ {
+ if (zipBytes == null)
+ throw new ArgumentNullException(nameof(zipBytes));
+ if (string.IsNullOrWhiteSpace(stagingDir))
+ throw new ArgumentException("Staging directory is required.", nameof(stagingDir));
+
+ Directory.CreateDirectory(stagingDir);
+
+ var written = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ bool sawDll = false;
+ long totalBytes = 0;
+
+ try
+ {
+ using var ms = new MemoryStream(zipBytes, writable: false);
+ using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
+
+ if (zip.Entries.Count > MaxArchiveEntries)
+ throw new InvalidDataException(
+ "Update package has too many entries (max " + MaxArchiveEntries + ").");
+
+ foreach (ZipArchiveEntry entry in zip.Entries)
+ {
+ if (!TryMapWhitelist(entry.FullName, out string? relativePath) || relativePath == null)
+ continue;
+
+ if (!seen.Add(relativePath))
+ throw new InvalidDataException(
+ "Update package contains duplicate entry '" + relativePath + "'.");
+
+ string destPath = Path.Combine(stagingDir, relativePath);
+ string? destDir = Path.GetDirectoryName(destPath);
+ if (!string.IsNullOrEmpty(destDir))
+ Directory.CreateDirectory(destDir);
+
+ long entryBytes = 0;
+ using (Stream src = entry.Open())
+ using (var dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None))
+ {
+ var buffer = new byte[81920];
+ int read;
+ while ((read = src.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ entryBytes += read;
+ totalBytes += read;
+ if (entryBytes > MaxBytesPerEntry)
+ throw new InvalidDataException(
+ "Update package entry exceeds the " + MaxBytesPerEntry + " byte limit.");
+ if (totalBytes > MaxTotalExtractedBytes)
+ throw new InvalidDataException(
+ "Update package exceeds the " + MaxTotalExtractedBytes + " byte extracted total.");
+ dst.Write(buffer, 0, read);
+ }
+ }
+
+ written.Add(relativePath);
+ if (string.Equals(relativePath, DllName, StringComparison.OrdinalIgnoreCase))
+ sawDll = true;
+ }
+ }
+ catch (InvalidDataException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidDataException("Update package could not be read: " + ex.Message, ex);
+ }
+
+ if (!sawDll)
+ throw new InvalidDataException(
+ "Update package is missing root entry '" + DllName + "'.");
+
+ return written;
+ }
+
+ ///
+ /// Maps a zip entry full name to a relative install path, or returns false to
+ /// ignore the entry. Only exact FanaBridge.dll and single-level
+ /// DevicesLogos/<file>.png are accepted.
+ ///
+ private static bool TryMapWhitelist(string fullName, out string? relativePath)
+ {
+ relativePath = null;
+ if (string.IsNullOrEmpty(fullName))
+ return false;
+
+ // Normalize separators for matching; zip tools on Windows may use '\'.
+ string name = fullName.Replace('\\', '/');
+
+ // Skip bare directory markers.
+ if (name.EndsWith("/", StringComparison.Ordinal))
+ return false;
+
+ if (string.Equals(name, DllName, StringComparison.Ordinal))
+ {
+ relativePath = DllName;
+ return true;
+ }
+
+ string prefix = LogosDirName + "/";
+ if (name.StartsWith(prefix, StringComparison.Ordinal)
+ && name.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
+ {
+ string file = name.Substring(prefix.Length);
+ // Single level only — reject empty, nested, or traversal segments.
+ if (file.Length == 0 || file.IndexOf('/') >= 0 || file.IndexOf('\\') >= 0)
+ return false;
+ if (file == "." || file == "..")
+ return false;
+ // Names that aren't valid Windows file names (':' in particular
+ // has drive-qualifier semantics in legacy path handling) are not
+ // logos — ignore rather than rely on FileStream rejecting them.
+ if (file.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
+ return false;
+
+ relativePath = LogosDirName + "\\" + file;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static string ToHex(byte[] bytes)
+ {
+ var sb = new StringBuilder(bytes.Length * 2);
+ for (int i = 0; i < bytes.Length; i++)
+ sb.Append(bytes[i].ToString("x2"));
+ return sb.ToString();
+ }
+ }
+}
diff --git a/src/FanaBridge.Updater/UpdateService.cs b/src/FanaBridge.Updater/UpdateService.cs
new file mode 100644
index 0000000..beb1f5b
--- /dev/null
+++ b/src/FanaBridge.Updater/UpdateService.cs
@@ -0,0 +1,383 @@
+#nullable enable
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace FanaBridge.Updater
+{
+ /// UI-facing phase of the self-updater state machine.
+ public enum UpdatePhase
+ {
+ /// No check has run yet.
+ Idle,
+
+ /// Fetching / parsing the release feed.
+ Checking,
+
+ /// Feed is at or below the running version.
+ UpToDate,
+
+ /// A newer release is available (may be notify-only).
+ UpdateAvailable,
+
+ /// Check failed (network/parse); see .
+ CheckFailed,
+
+ /// Downloading and verifying the release zip.
+ Downloading,
+
+ /// Extracting and swapping files into the install directory.
+ Applying,
+
+ /// Swap succeeded; terminal until process restart.
+ ReadyToRestart,
+
+ /// Download/apply failed; see .
+ Failed
+ }
+
+ ///
+ /// Immutable snapshot of updater state published to the UI. Every phase transition
+ /// replaces the whole object so readers never observe torn fields.
+ ///
+ public sealed class UpdateSnapshot
+ {
+ /// Current phase.
+ public UpdatePhase Phase { get; }
+
+ /// Non-null from onward when a release was parsed.
+ public ReleaseInfo? Release { get; }
+
+ /// Non-null for / .
+ public string? FailureDetail { get; }
+
+ /// True when a Failed apply was permission-denied.
+ public bool AccessDenied { get; }
+
+ /// Creates an immutable snapshot.
+ public UpdateSnapshot(UpdatePhase phase, ReleaseInfo? release, string? failureDetail, bool accessDenied)
+ {
+ Phase = phase;
+ Release = release;
+ FailureDetail = failureDetail;
+ AccessDenied = accessDenied;
+ }
+ }
+
+ ///
+ /// Serialized self-updater orchestration: check feed, download+verify, extract, swap.
+ /// Logging is via injected delegates only; no concurrent check/apply (double-clicks are no-ops).
+ ///
+ public sealed class UpdateService
+ {
+ private static readonly TimeSpan CheckDebounce = TimeSpan.FromSeconds(30);
+
+ private readonly string _currentVersion;
+ private readonly string _installDir;
+ private readonly Func> _fetchText;
+ private readonly Func> _fetchBytes;
+ private readonly string _releaseFeedUrl;
+ private readonly Action _logInfo;
+ private readonly Action _logWarn;
+ private readonly UpdateFileSwapper _swapper;
+ private readonly Func _stagingDirFactory;
+ private readonly Func _utcNow;
+
+ private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
+ private volatile UpdateSnapshot _snapshot =
+ new UpdateSnapshot(UpdatePhase.Idle, null, null, false);
+
+ private DateTime? _lastCheckCompletedUtc;
+ private bool _loggedUnparseableCurrent;
+
+ ///
+ /// Creates the service. , ,
+ /// and are optional seams for tests.
+ ///
+ public UpdateService(
+ string currentVersion,
+ string installDir,
+ Func> fetchText,
+ Func> fetchBytes,
+ string releaseFeedUrl,
+ Action logInfo,
+ Action logWarn,
+ UpdateFileSwapper? swapper = null,
+ Func? stagingDirFactory = null,
+ Func? utcNow = null)
+ {
+ _currentVersion = currentVersion ?? throw new ArgumentNullException(nameof(currentVersion));
+ _installDir = installDir ?? throw new ArgumentNullException(nameof(installDir));
+ _fetchText = fetchText ?? throw new ArgumentNullException(nameof(fetchText));
+ _fetchBytes = fetchBytes ?? throw new ArgumentNullException(nameof(fetchBytes));
+ _releaseFeedUrl = releaseFeedUrl ?? throw new ArgumentNullException(nameof(releaseFeedUrl));
+ _logInfo = logInfo ?? throw new ArgumentNullException(nameof(logInfo));
+ _logWarn = logWarn ?? throw new ArgumentNullException(nameof(logWarn));
+ _swapper = swapper ?? new UpdateFileSwapper(logWarn: logWarn);
+ _stagingDirFactory = stagingDirFactory
+ ?? (() => Path.Combine(Path.GetTempPath(), "FanaBridge-update-" + Path.GetRandomFileName()));
+ _utcNow = utcNow ?? (() => DateTime.UtcNow);
+ }
+
+ /// Current immutable snapshot; never null. Starts at .
+ public UpdateSnapshot Snapshot => _snapshot;
+
+ ///
+ /// Raised after each phase transition (any thread). Subscriber exceptions are
+ /// caught and logged so one bad handler cannot break transitions.
+ ///
+ public event Action? Changed;
+
+ ///
+ /// Fetches and evaluates the latest release. Serialized, debounced (30 s after a
+ /// completed check), and a permanent no-op once .
+ ///
+ public async Task CheckAsync(CancellationToken ct = default)
+ {
+ if (!await _gate.WaitAsync(0).ConfigureAwait(false))
+ return;
+
+ try
+ {
+ if (_snapshot.Phase == UpdatePhase.ReadyToRestart)
+ return;
+
+ DateTime now = _utcNow();
+ if (_lastCheckCompletedUtc.HasValue
+ && now - _lastCheckCompletedUtc.Value < CheckDebounce)
+ return;
+
+ UpdateSnapshot previous = _snapshot;
+ Publish(new UpdateSnapshot(UpdatePhase.Checking, previous.Release, null, false));
+
+ try
+ {
+ string json = await _fetchText(_releaseFeedUrl, ct).ConfigureAwait(false);
+ ReleaseInfo? release = ReleaseFeed.Parse(json, out string? parseError);
+ if (release == null)
+ {
+ string detail = parseError ?? "Unknown release feed parse error.";
+ _logWarn("Update check failed: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.CheckFailed, null, detail, false));
+ _lastCheckCompletedUtc = _utcNow();
+ return;
+ }
+
+ if (IsNewerThanCurrent(release))
+ {
+ _logInfo("Update available: " + release.TagName
+ + (release.CanSelfInstall ? "" : " (notify-only: " + release.InstallBlockedReason + ")"));
+ Publish(new UpdateSnapshot(UpdatePhase.UpdateAvailable, release, null, false));
+ }
+ else
+ {
+ Publish(new UpdateSnapshot(UpdatePhase.UpToDate, release, null, false));
+ }
+
+ _lastCheckCompletedUtc = _utcNow();
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // Cancellation nobody asked for is an HttpClient timeout —
+ // report it, or a manual check would appear to do nothing.
+ const string detail = "request timed out.";
+ _logWarn("Update check failed: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.CheckFailed, null, detail, false));
+ _lastCheckCompletedUtc = _utcNow();
+ }
+ catch (OperationCanceledException)
+ {
+ // Caller-requested cancellation is not a failure — restore
+ // the pre-check non-busy phase.
+ Publish(previous);
+ }
+ catch (Exception ex)
+ {
+ string detail = ex.Message;
+ _logWarn("Update check failed: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.CheckFailed, null, detail, false));
+ _lastCheckCompletedUtc = _utcNow();
+ }
+ }
+ catch (Exception ex)
+ {
+ // Outer safety net: nothing but bugs should escape the command.
+ string detail = ex.Message;
+ _logWarn("Update check unexpected failure: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.CheckFailed, null, detail, false));
+ _lastCheckCompletedUtc = _utcNow();
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ ///
+ /// Downloads, verifies, extracts, and applies the current
+ /// release when it is self-installable.
+ /// Permanent no-op from ; otherwise a no-op
+ /// unless phase is UpdateAvailable with .
+ ///
+ public async Task DownloadAndApplyAsync(CancellationToken ct = default)
+ {
+ if (!await _gate.WaitAsync(0).ConfigureAwait(false))
+ return;
+
+ try
+ {
+ if (_snapshot.Phase == UpdatePhase.ReadyToRestart)
+ return;
+
+ UpdateSnapshot start = _snapshot;
+ ReleaseInfo? release = start.Release;
+ if (start.Phase != UpdatePhase.UpdateAvailable
+ || release == null
+ || !release.CanSelfInstall
+ || string.IsNullOrWhiteSpace(release.ZipUrl)
+ || string.IsNullOrWhiteSpace(release.DigestHex))
+ return;
+
+ Publish(new UpdateSnapshot(UpdatePhase.Downloading, release, null, false));
+
+ string? staging = null;
+ try
+ {
+ byte[] bytes = await _fetchBytes(release.ZipUrl!, ct).ConfigureAwait(false);
+
+ if (!UpdatePackage.VerifySha256(bytes, release.DigestHex!))
+ {
+ const string detail = "checksum mismatch: downloaded package does not match the release digest.";
+ _logWarn("Update apply failed: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.Failed, release, detail, false));
+ return;
+ }
+
+ ct.ThrowIfCancellationRequested();
+
+ staging = _stagingDirFactory();
+ try
+ {
+ UpdatePackage.ExtractToStaging(bytes, staging);
+ }
+ catch (InvalidDataException ex)
+ {
+ _logWarn("Update apply failed: " + ex.Message);
+ Publish(new UpdateSnapshot(UpdatePhase.Failed, release, ex.Message, false));
+ TryDeleteDir(staging);
+ return;
+ }
+
+ // Last cancellation point: beyond this the swap must run to
+ // completion (partial renames must finish or roll back).
+ ct.ThrowIfCancellationRequested();
+
+ Publish(new UpdateSnapshot(UpdatePhase.Applying, release, null, false));
+
+ SwapResult result = _swapper.Apply(staging, _installDir, release.Version);
+ if (result.Success)
+ {
+ TryDeleteDir(staging);
+ _logInfo("Update applied: " + release.TagName + " — restart required.");
+ Publish(new UpdateSnapshot(UpdatePhase.ReadyToRestart, release, null, false));
+ }
+ else
+ {
+ string detail = result.Error ?? "Update apply failed.";
+ _logWarn("Update apply failed: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.Failed, release, detail, result.AccessDenied));
+ TryDeleteDir(staging);
+ }
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // Timeout, not a user cancel — report it as a failure.
+ if (staging != null)
+ TryDeleteDir(staging);
+ const string detail = "download timed out.";
+ _logWarn("Update apply failed: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.Failed, release, detail, false));
+ }
+ catch (OperationCanceledException)
+ {
+ // Restore to UpdateAvailable so the user can retry; do not mark Failed.
+ if (staging != null)
+ TryDeleteDir(staging);
+ Publish(new UpdateSnapshot(UpdatePhase.UpdateAvailable, release, null, false));
+ }
+ catch (Exception ex)
+ {
+ if (staging != null)
+ TryDeleteDir(staging);
+ string detail = ex.Message;
+ _logWarn("Update apply failed: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.Failed, release, detail, false));
+ }
+ }
+ catch (Exception ex)
+ {
+ string detail = ex.Message;
+ _logWarn("Update apply unexpected failure: " + detail);
+ Publish(new UpdateSnapshot(UpdatePhase.Failed, _snapshot.Release, detail, false));
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private bool IsNewerThanCurrent(ReleaseInfo release)
+ {
+ if (!UpdateVersion.TryParse(_currentVersion, out UpdateVersion current))
+ {
+ if (!_loggedUnparseableCurrent)
+ {
+ _logWarn("Current version '" + _currentVersion
+ + "' is unparseable; treating feed releases as not newer.");
+ _loggedUnparseableCurrent = true;
+ }
+ // Never offer downgrades/sidegrades on a broken local version.
+ return false;
+ }
+
+ if (!UpdateVersion.TryParse(release.Version, out UpdateVersion remote))
+ return false;
+
+ return remote.CompareTo(current) > 0;
+ }
+
+ private void Publish(UpdateSnapshot snapshot)
+ {
+ _snapshot = snapshot;
+ Action? handlers = Changed;
+ if (handlers == null)
+ return;
+
+ foreach (Delegate d in handlers.GetInvocationList())
+ {
+ try
+ {
+ ((Action)d)(snapshot);
+ }
+ catch (Exception ex)
+ {
+ _logWarn("UpdateService Changed subscriber threw: " + ex.Message);
+ }
+ }
+ }
+
+ private void TryDeleteDir(string dir)
+ {
+ try
+ {
+ if (Directory.Exists(dir))
+ Directory.Delete(dir, recursive: true);
+ }
+ catch (Exception ex)
+ {
+ _logWarn("Could not remove update staging dir '" + dir + "': " + ex.Message);
+ }
+ }
+ }
+}
diff --git a/src/FanaBridge.Updater/UpdateVersion.cs b/src/FanaBridge.Updater/UpdateVersion.cs
new file mode 100644
index 0000000..c9f80b1
--- /dev/null
+++ b/src/FanaBridge.Updater/UpdateVersion.cs
@@ -0,0 +1,135 @@
+#nullable enable
+using System;
+
+namespace FanaBridge.Updater
+{
+ ///
+ /// Product version used by the self-updater: a numeric plus an
+ /// optional prerelease suffix (e.g. "preview"). Release tags may carry a leading
+ /// v/V; comparison treats a no-suffix release as newer than any suffix
+ /// with the same numeric part so CI previews never outrank a matching release.
+ ///
+ public readonly struct UpdateVersion : IComparable, IEquatable
+ {
+ /// Numeric components; never null for a successfully parsed value.
+ public Version Numeric { get; }
+
+ /// Prerelease label after the first -, or null when absent.
+ public string? Suffix { get; }
+
+ private UpdateVersion(Version numeric, string? suffix)
+ {
+ Numeric = numeric;
+ Suffix = suffix;
+ }
+
+ ///
+ /// Parses into an . Accepts an
+ /// optional leading v/V, requires at least Major.Minor, and splits the
+ /// first - into numeric + suffix. Returns false for null/empty/garbage,
+ /// single-component versions, or negative components.
+ ///
+ public static bool TryParse(string? text, out UpdateVersion version)
+ {
+ version = default;
+ if (string.IsNullOrWhiteSpace(text))
+ return false;
+
+ string s = text!.Trim();
+ if (s.Length > 0 && (s[0] == 'v' || s[0] == 'V'))
+ s = s.Substring(1);
+ if (s.Length == 0)
+ return false;
+
+ string numericPart;
+ string? suffix;
+ int dash = s.IndexOf('-');
+ if (dash < 0)
+ {
+ numericPart = s;
+ suffix = null;
+ }
+ else
+ {
+ numericPart = s.Substring(0, dash);
+ suffix = s.Substring(dash + 1);
+ // Empty suffix after a trailing dash is not a useful version label.
+ if (suffix.Length == 0)
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(numericPart))
+ return false;
+
+ // System.Version accepts a bare major ("1"); we require ≥2 components so
+ // product versions stay Major.Minor[.Build[.Revision]].
+ if (!Version.TryParse(numericPart, out Version? parsed) || parsed == null)
+ return false;
+ if (parsed.Minor < 0)
+ return false;
+
+ version = new UpdateVersion(parsed, suffix);
+ return true;
+ }
+
+ ///
+ /// Orders by numeric components first (missing Build/Revision treated as 0 so
+ /// 1.2 == 1.2.0); when equal, no-suffix beats any suffix; two suffixes
+ /// compare ordinal-ignore-case.
+ ///
+ public int CompareTo(UpdateVersion other)
+ {
+ int n = Normalize(Numeric).CompareTo(Normalize(other.Numeric));
+ if (n != 0)
+ return n;
+
+ bool aHas = Suffix != null;
+ bool bHas = other.Suffix != null;
+ if (!aHas && !bHas)
+ return 0;
+ // Release (no suffix) is newer than any prerelease of the same number.
+ if (!aHas)
+ return 1;
+ if (!bHas)
+ return -1;
+ return string.Compare(Suffix, other.Suffix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ public bool Equals(UpdateVersion other) => CompareTo(other) == 0;
+
+ ///
+ public override bool Equals(object? obj) => obj is UpdateVersion other && Equals(other);
+
+ ///
+ public override int GetHashCode()
+ {
+ Version n = Normalize(Numeric);
+ int h = n.GetHashCode();
+ if (Suffix != null)
+ h = (h * 397) ^ StringComparer.OrdinalIgnoreCase.GetHashCode(Suffix);
+ return h;
+ }
+
+ /// Formats as 0.6.0 or 0.6.0-preview.
+ public override string ToString()
+ {
+ Version n = Numeric ?? new Version(0, 0);
+ return Suffix == null ? n.ToString() : n + "-" + Suffix;
+ }
+
+ ///
+ /// Missing Version components are -1; treat them as 0 for equality/order.
+ /// A null input (default-constructed struct — Numeric is a reference
+ /// type) normalizes to 0.0.0.0 so comparisons never throw.
+ ///
+ private static Version Normalize(Version? v)
+ {
+ if (v == null)
+ return new Version(0, 0, 0, 0);
+ int build = v.Build < 0 ? 0 : v.Build;
+ int rev = v.Revision < 0 ? 0 : v.Revision;
+ return new Version(v.Major, v.Minor, build, rev);
+ }
+ }
+}
diff --git a/src/FanaBridge/FanaBridge.csproj b/src/FanaBridge/FanaBridge.csproj
index 7b5c3bf..12dd777 100644
--- a/src/FanaBridge/FanaBridge.csproj
+++ b/src/FanaBridge/FanaBridge.csproj
@@ -19,11 +19,12 @@
-
+
+
@@ -35,6 +36,8 @@
+
+
$(SimHubDir)GameReaderCommon.dll
@@ -111,20 +114,20 @@
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Release notes
+
+
+
+
+ Update
+
+
+
+
+
+
+
+
+ github.com/kelchm/FanaBridge
+
+
+
+
+
+
+
+ Check for updates now
+
+
+
+
+
+
+
+
+
+
+
@@ -210,8 +308,8 @@
-
-
+
+
-
-
-
-
-
-
-
- github.com/kelchm/FanaBridge
-
-
-
-
-
diff --git a/src/FanaBridge/UI/SettingsControl.xaml.cs b/src/FanaBridge/UI/SettingsControl.xaml.cs
index 10a38d5..c4f7bd8 100644
--- a/src/FanaBridge/UI/SettingsControl.xaml.cs
+++ b/src/FanaBridge/UI/SettingsControl.xaml.cs
@@ -13,6 +13,7 @@
using FanaBridge.Profiles;
using FanaBridge.Protocol;
using FanaBridge.Transport;
+using FanaBridge.Updater;
using SimHub.Plugins.Devices;
using Timer = System.Timers.Timer;
@@ -275,6 +276,7 @@ private static string ChainModuleText(FanatecWheelbase wb)
private void OnLoaded(object sender, RoutedEventArgs e)
{
Plugin.StateChanged += OnPluginStateChanged;
+ Plugin.UpdateStateChanged += OnUpdateStateChanged;
// Capture the capabilities the LED module was built from at startup.
// Only set once — tab reloads must not clobber the baseline.
@@ -294,6 +296,11 @@ private void OnLoaded(object sender, RoutedEventArgs e)
_itmStatusTimer.Start();
UpdateStatus();
+
+ // Events only cover FUTURE transitions — the startup update check
+ // normally finishes long before this page is first opened, so render
+ // the current snapshot immediately.
+ UpdateUpdateBanner();
}
private void OnUnloaded(object sender, RoutedEventArgs e)
@@ -303,6 +310,7 @@ private void OnUnloaded(object sender, RoutedEventArgs e)
UnwatchSimHubDevices();
_itmStatusTimer?.Stop();
Plugin.StateChanged -= OnPluginStateChanged;
+ Plugin.UpdateStateChanged -= OnUpdateStateChanged;
}
private System.Windows.Threading.DispatcherTimer _itmStatusTimer;
@@ -1052,6 +1060,259 @@ private void ChkEnableTuning_Changed(object sender, RoutedEventArgs e)
Plugin?.SaveSettings();
}
+ private void ChkEnableUpdateCheck_Changed(object sender, RoutedEventArgs e)
+ {
+ // Live: the daily re-check timer re-reads the setting on each fire;
+ // the manual link works regardless.
+ Plugin?.SaveSettings();
+ }
+
+ // =====================================================================
+ // SELF-UPDATER (banner + About affordances)
+ //
+ // Pure view over UpdateService's immutable snapshots: every render
+ // derives the whole banner + About line from the current snapshot, so
+ // events and the on-load render can never disagree. The service owns
+ // all sequencing (terminal ReadyToRestart, no re-entrancy) — this code
+ // never decides what is allowed, it only displays and forwards clicks.
+ // =====================================================================
+
+ private static readonly Brush UpdateBlueBg = HexBrush("#1A4488CC");
+ private static readonly Brush UpdateBlueBorder = HexBrush("#4488CC");
+ private static readonly Brush UpdateBlueText = HexBrush("#AADDFF");
+ private static readonly Brush UpdateAmberBg = HexBrush("#1AFFCC00");
+ private static readonly Brush UpdateAmberBorder = HexBrush("#FFCC00");
+ private static readonly Brush UpdateAmberText = HexBrush("#FFEEBB");
+
+ private static Brush HexBrush(string hex)
+ {
+ var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
+ brush.Freeze();
+ return brush;
+ }
+
+ private void OnUpdateStateChanged()
+ {
+ // May fire from the update check's background thread; the control
+ // can also unload before the queued render runs — hence the guard.
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (IsLoaded)
+ UpdateUpdateBanner();
+ }));
+ }
+
+ private void UpdateUpdateBanner()
+ {
+ var snapshot = Plugin?.Updates?.Snapshot;
+ if (snapshot == null)
+ {
+ borderUpdateAlert.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ var release = snapshot.Release;
+ switch (snapshot.Phase)
+ {
+ case UpdatePhase.Idle:
+ txtUpdateCheckResult.Text = "";
+ borderUpdateAlert.Visibility = Visibility.Collapsed;
+ break;
+
+ case UpdatePhase.Checking:
+ txtUpdateCheckResult.Text = "Checking…";
+ borderUpdateAlert.Visibility = Visibility.Collapsed;
+ break;
+
+ case UpdatePhase.UpToDate:
+ txtUpdateCheckResult.Text = "You're up to date (" + BuildIdentity.Version + ").";
+ borderUpdateAlert.Visibility = Visibility.Collapsed;
+ break;
+
+ case UpdatePhase.CheckFailed:
+ txtUpdateCheckResult.Text = "Check failed: " + (snapshot.FailureDetail ?? "unknown error");
+ borderUpdateAlert.Visibility = Visibility.Collapsed;
+ break;
+
+ case UpdatePhase.UpdateAvailable:
+ // The banner sits directly above the manual-check line, so
+ // it IS the outcome display — no text needed here.
+ txtUpdateCheckResult.Text = "";
+ StyleUpdateBanner(failed: false);
+ runUpdateHeadline.Text = "Update available: FanaBridge " + release.Version;
+ if (release.CanSelfInstall)
+ {
+ SetUpdateDetail(null);
+ btnUpdateNow.Content = "Update";
+ }
+ else
+ {
+ SetUpdateDetail("One-click update isn't available for this release ("
+ + release.InstallBlockedReason + ") — install it manually from the release page.");
+ btnUpdateNow.Content = "Open release page";
+ }
+ btnUpdateNow.IsEnabled = true;
+ borderUpdateAlert.Visibility = Visibility.Visible;
+ break;
+
+ case UpdatePhase.Downloading:
+ case UpdatePhase.Applying:
+ // These states (and the two below) are outcomes of banner
+ // interaction, not of the manual check link — the banner
+ // owns their messaging, the About line stays quiet.
+ txtUpdateCheckResult.Text = "";
+ StyleUpdateBanner(failed: false);
+ runUpdateHeadline.Text = "Updating to FanaBridge " + release?.Version;
+ SetUpdateDetail("Downloading and installing…");
+ btnUpdateNow.Content = "Update";
+ btnUpdateNow.IsEnabled = false;
+ borderUpdateAlert.Visibility = Visibility.Visible;
+ break;
+
+ case UpdatePhase.ReadyToRestart:
+ txtUpdateCheckResult.Text = "";
+ StyleUpdateBanner(failed: false);
+ runUpdateHeadline.Text = "Update installed — restart SimHub to finish updating to FanaBridge "
+ + release?.Version + ".";
+ SetUpdateDetail(null);
+ btnUpdateNow.Content = "Restart SimHub";
+ btnUpdateNow.IsEnabled = true;
+ borderUpdateAlert.Visibility = Visibility.Visible;
+ OfferUpdateRestartOnce();
+ break;
+
+ case UpdatePhase.Failed:
+ txtUpdateCheckResult.Text = "";
+ StyleUpdateBanner(failed: true);
+ runUpdateHeadline.Text = "Automatic update failed";
+ SetUpdateDetail(ComposeUpdateFailureDetail(snapshot));
+ btnUpdateNow.Content = "Open release page";
+ btnUpdateNow.IsEnabled = true;
+ borderUpdateAlert.Visibility = Visibility.Visible;
+ break;
+ }
+ }
+
+ private void SetUpdateDetail(string detail)
+ {
+ txtUpdateDetail.Text = detail ?? "";
+ txtUpdateDetail.Visibility = string.IsNullOrEmpty(detail)
+ ? Visibility.Collapsed
+ : Visibility.Visible;
+ }
+
+ private void StyleUpdateBanner(bool failed)
+ {
+ borderUpdateAlert.Background = failed ? UpdateAmberBg : UpdateBlueBg;
+ borderUpdateAlert.BorderBrush = failed ? UpdateAmberBorder : UpdateBlueBorder;
+ txtUpdateTitle.Foreground = failed ? UpdateAmberText : UpdateBlueText;
+ txtUpdateDetail.Foreground = failed ? UpdateAmberText : UpdateBlueText;
+ }
+
+ private static string ComposeUpdateFailureDetail(UpdateSnapshot snapshot)
+ {
+ string detail = snapshot.FailureDetail ?? "Unknown error.";
+ if (snapshot.AccessDenied)
+ return detail + " SimHub's folder isn't writable by your user account — download the "
+ + "release zip and copy FanaBridge.dll (and the DevicesLogos images) next to "
+ + "SimHub.exe manually.";
+ return detail + " You can install manually: download the release zip and copy its files "
+ + "next to SimHub.exe.";
+ }
+
+ private void OfferUpdateRestartOnce()
+ {
+ // The banner keeps its Restart button, so declining here isn't
+ // final — this just avoids re-prompting on every render. The flag
+ // lives on the plugin: SimHub creates a fresh control per page open
+ // while ReadyToRestart persists for the whole process.
+ if (Plugin.UpdateRestartPromptShown)
+ return;
+ Plugin.UpdateRestartPromptShown = true;
+
+ var result = MessageBox.Show(
+ "FanaBridge has been updated. Restart SimHub now to load the new version?",
+ "Update Installed",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ Plugin.PluginManager?.RequestApplicationExit(restart: true);
+ }
+
+ private void BtnUpdateNow_Click(object sender, RoutedEventArgs e)
+ {
+ var updates = Plugin?.Updates;
+ var snapshot = updates?.Snapshot;
+ if (snapshot == null)
+ return;
+
+ switch (snapshot.Phase)
+ {
+ case UpdatePhase.UpdateAvailable when snapshot.Release?.CanSelfInstall == true:
+ // Fire-and-forget: phase transitions drive the UI, and the
+ // service ignores re-entrant calls, so a double-click is safe.
+ // Routed via the plugin so FinalizePlugin's cancel covers it.
+ _ = Plugin.ApplyUpdateAsync();
+ break;
+
+ case UpdatePhase.UpdateAvailable:
+ case UpdatePhase.Failed:
+ OpenReleasePage(snapshot);
+ break;
+
+ case UpdatePhase.ReadyToRestart:
+ Plugin.PluginManager?.RequestApplicationExit(restart: true);
+ break;
+ }
+ }
+
+ private void LnkReleaseNotes_Click(object sender, RoutedEventArgs e)
+ {
+ OpenReleasePage(Plugin?.Updates?.Snapshot);
+ }
+
+ private void OpenReleasePage(UpdateSnapshot snapshot)
+ {
+ const string fallback = "https://github.com/kelchm/FanaBridge/releases";
+
+ // The feed's html_url is remote data handed to the shell — only
+ // launch real web URLs, never other schemes.
+ string url = snapshot?.Release?.HtmlUrl;
+ if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri)
+ || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ url = fallback;
+
+ try
+ {
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ SimHub.Logging.Current.Warn("FanaBridge: Failed to open release page: " + ex.Message);
+ }
+ }
+
+ private async void LnkCheckForUpdates_Click(object sender, RoutedEventArgs e)
+ {
+ var updates = Plugin?.Updates;
+ if (updates == null)
+ {
+ txtUpdateCheckResult.Text = "Updater unavailable.";
+ return;
+ }
+
+ txtUpdateCheckResult.Text = "Checking…";
+ // Routed via the plugin so FinalizePlugin's cancel covers it.
+ try { await Plugin.CheckForUpdatesAsync(); }
+ catch { /* CheckAsync converts failures to states */ }
+
+ // A debounced/no-op check fires no Changed event, which would leave
+ // "Checking…" on screen — re-render from the snapshot regardless.
+ if (IsLoaded)
+ UpdateUpdateBanner();
+ }
+
private void ChkEnableControlMapperIntegration_Changed(object sender, RoutedEventArgs e)
{
// Persist the flag; FanatecPlugin.DataUpdate reconciles the Control
diff --git a/src/FanaBridge/Updates/GitHubHttpClient.cs b/src/FanaBridge/Updates/GitHubHttpClient.cs
new file mode 100644
index 0000000..50f6bf0
--- /dev/null
+++ b/src/FanaBridge/Updates/GitHubHttpClient.cs
@@ -0,0 +1,61 @@
+#nullable enable
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace FanaBridge.Updates
+{
+ ///
+ /// HTTP seams for the self-updater: the two fetch delegates
+ /// needs, backed by one lazily built
+ /// process-wide .
+ ///
+ /// Deliberately does NOT touch ServicePointManager.SecurityProtocol:
+ /// net48 defaults to OS-selected TLS (SystemDefault), and OR-ing in Tls12
+ /// would pin the whole SimHub process to TLS 1.2. Release asset downloads
+ /// redirect from api.github.com to objects.githubusercontent.com;
+ /// HttpClient follows that automatically and no auth headers are in play.
+ ///
+ internal static class GitHubHttpClient
+ {
+ /// Latest published (non-draft, non-prerelease) release.
+ public const string LatestReleaseUrl =
+ "https://api.github.com/repos/kelchm/FanaBridge/releases/latest";
+
+ // Well above any plausible release zip (~1 MB today); a response bigger
+ // than this is wrong regardless of what the feed claimed.
+ private const long MaxResponseBytes = 50L * 1024 * 1024;
+
+ private static readonly Lazy Client = new Lazy(() =>
+ {
+ var client = new HttpClient
+ {
+ Timeout = TimeSpan.FromSeconds(30),
+ MaxResponseContentBufferSize = MaxResponseBytes,
+ };
+ // GitHub's API rejects requests without a User-Agent.
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("FanaBridge/" + BuildIdentity.Version);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
+ return client;
+ });
+
+ public static async Task GetStringAsync(string url, CancellationToken ct)
+ {
+ using (var response = await Client.Value.GetAsync(url, ct).ConfigureAwait(false))
+ {
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ }
+ }
+
+ public static async Task GetBytesAsync(string url, CancellationToken ct)
+ {
+ using (var response = await Client.Value.GetAsync(url, ct).ConfigureAwait(false))
+ {
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/tests/FanaBridge.Tests/FanaBridge.Tests.csproj b/tests/FanaBridge.Tests/FanaBridge.Tests.csproj
index 947abce..797f0cd 100644
--- a/tests/FanaBridge.Tests/FanaBridge.Tests.csproj
+++ b/tests/FanaBridge.Tests/FanaBridge.Tests.csproj
@@ -18,6 +18,7 @@
+
@@ -35,6 +36,8 @@
$(SimHubDir)SimHub.Plugins.dll
+
+
diff --git a/tests/FanaBridge.Tests/Updater/ReleaseFeedTests.cs b/tests/FanaBridge.Tests/Updater/ReleaseFeedTests.cs
new file mode 100644
index 0000000..67d1f09
--- /dev/null
+++ b/tests/FanaBridge.Tests/Updater/ReleaseFeedTests.cs
@@ -0,0 +1,129 @@
+using FanaBridge.Updater;
+using Xunit;
+
+namespace FanaBridge.Tests.Updater
+{
+ public class ReleaseFeedTests
+ {
+ private const string HappyJson = @"{
+ ""tag_name"": ""v0.7.0"",
+ ""html_url"": ""https://github.com/example/FanaBridge/releases/tag/v0.7.0"",
+ ""assets"": [
+ {
+ ""name"": ""notes.txt"",
+ ""browser_download_url"": ""https://example.com/notes.txt"",
+ ""size"": 12,
+ ""digest"": ""sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa""
+ },
+ {
+ ""name"": ""FanaBridge-0.7.0.zip"",
+ ""browser_download_url"": ""https://github.com/example/FanaBridge/releases/download/v0.7.0/FanaBridge-0.7.0.zip"",
+ ""size"": 12345,
+ ""digest"": ""sha256:9715EFCE0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01234567""
+ }
+ ]
+}";
+
+ [Fact]
+ public void Parse_HappyPath_ExtractsAssetAndLowercasesDigest()
+ {
+ ReleaseInfo? info = ReleaseFeed.Parse(HappyJson, out string? error);
+ Assert.Null(error);
+ Assert.NotNull(info);
+ Assert.Equal("v0.7.0", info!.TagName);
+ Assert.Equal("0.7.0", info.Version);
+ Assert.Equal("https://github.com/example/FanaBridge/releases/tag/v0.7.0", info.HtmlUrl);
+ Assert.Equal("FanaBridge-0.7.0.zip", info.ZipName);
+ Assert.Equal(
+ "https://github.com/example/FanaBridge/releases/download/v0.7.0/FanaBridge-0.7.0.zip",
+ info.ZipUrl);
+ Assert.Equal(12345, info.ZipSizeBytes);
+ Assert.Equal("9715efce0123456789abcdef0123456789abcdef0123456789abcdef01234567", info.DigestHex);
+ Assert.True(info.CanSelfInstall);
+ Assert.Null(info.InstallBlockedReason);
+ }
+
+ [Fact]
+ public void Parse_WrongAssetName_NotifyOnly()
+ {
+ string json = @"{
+ ""tag_name"": ""v0.7.0"",
+ ""html_url"": ""https://example.com/r"",
+ ""assets"": [{
+ ""name"": ""FanaBridge-0.7.0-win.zip"",
+ ""browser_download_url"": ""https://example.com/z.zip"",
+ ""size"": 1,
+ ""digest"": ""sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa""
+ }]
+}";
+ ReleaseInfo? info = ReleaseFeed.Parse(json, out string? error);
+ Assert.Null(error);
+ Assert.NotNull(info);
+ Assert.False(info!.CanSelfInstall);
+ Assert.NotNull(info.InstallBlockedReason);
+ Assert.Contains("FanaBridge-0.7.0.zip", info.InstallBlockedReason);
+ Assert.Null(info.ZipName);
+ }
+
+ [Fact]
+ public void Parse_MissingDigest_NotifyOnly()
+ {
+ string json = @"{
+ ""tag_name"": ""v0.7.0"",
+ ""html_url"": ""https://example.com/r"",
+ ""assets"": [{
+ ""name"": ""FanaBridge-0.7.0.zip"",
+ ""browser_download_url"": ""https://example.com/z.zip"",
+ ""size"": 1
+ }]
+}";
+ ReleaseInfo? info = ReleaseFeed.Parse(json, out string? error);
+ Assert.Null(error);
+ Assert.NotNull(info);
+ Assert.False(info!.CanSelfInstall);
+ Assert.Contains("digest", info.InstallBlockedReason, System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Theory]
+ [InlineData("sha256:xyz")]
+ [InlineData("sha256:abcd")]
+ [InlineData("md5:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
+ [InlineData("sha256:9715efce0123456789abcdef0123456789abcdef0123456789abcdef012345")] // 62 hex
+ public void Parse_MalformedDigest_NotifyOnly(string digest)
+ {
+ string json = @"{
+ ""tag_name"": ""v0.7.0"",
+ ""html_url"": ""https://example.com/r"",
+ ""assets"": [{
+ ""name"": ""FanaBridge-0.7.0.zip"",
+ ""browser_download_url"": ""https://example.com/z.zip"",
+ ""size"": 1,
+ ""digest"": """ + digest + @"""
+ }]
+}";
+ ReleaseInfo? info = ReleaseFeed.Parse(json, out string? error);
+ Assert.Null(error);
+ Assert.NotNull(info);
+ Assert.False(info!.CanSelfInstall);
+ Assert.Null(info.DigestHex);
+ }
+
+ [Fact]
+ public void Parse_MalformedJson_ReturnsError()
+ {
+ ReleaseInfo? info = ReleaseFeed.Parse("{ not json", out string? error);
+ Assert.Null(info);
+ Assert.NotNull(error);
+ }
+
+ [Fact]
+ public void Parse_MissingTagName_ReturnsError()
+ {
+ string json = @"{ ""html_url"": ""https://example.com/r"", ""assets"": [] }";
+ ReleaseInfo? info = ReleaseFeed.Parse(json, out string? error);
+ Assert.Null(info);
+ Assert.NotNull(error);
+ Assert.Contains("tag_name", error, System.StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs b/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs
new file mode 100644
index 0000000..de99524
--- /dev/null
+++ b/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs
@@ -0,0 +1,396 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using FanaBridge.Updater;
+using Xunit;
+
+namespace FanaBridge.Tests.Updater
+{
+ public class UpdateFileSwapperTests
+ {
+ [Fact]
+ public void Apply_HappyPath_SwapsDll_CopiesLogos_LeavesUserData()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("OLD"));
+ string userData = Path.Combine(install, "FanaBridge", "Profiles");
+ Directory.CreateDirectory(userData);
+ string profile = Path.Combine(userData, "user.json");
+ File.WriteAllText(profile, "{\"ok\":true}");
+
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+ string stagedLogos = Path.Combine(staging, UpdatePackage.LogosDirName);
+ Directory.CreateDirectory(stagedLogos);
+ File.WriteAllBytes(Path.Combine(stagedLogos, "wheel.png"), new byte[] { 1, 2, 3 });
+
+ var swapper = new UpdateFileSwapper(readFileVersion: _ => "0.7.0.0");
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.True(result.Success, result.Error);
+ Assert.Equal("NEW", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName)));
+ Assert.Equal("OLD", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.OldSuffix)));
+ Assert.False(File.Exists(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.NewSuffix)));
+ Assert.True(File.Exists(Path.Combine(install, UpdatePackage.LogosDirName, "wheel.png")));
+ Assert.Equal("{\"ok\":true}", File.ReadAllText(profile));
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_RenameWhileOpen_SucceedsWithShareDelete()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ string live = Path.Combine(install, UpdatePackage.DllName);
+ File.WriteAllBytes(live, Encoding.UTF8.GetBytes("OLD"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+
+ using (var hold = new FileStream(live, FileMode.Open, FileAccess.Read,
+ FileShare.Read | FileShare.Delete))
+ {
+ var swapper = new UpdateFileSwapper(readFileVersion: _ => "0.7.0.0");
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+ Assert.True(result.Success, result.Error);
+ }
+
+ Assert.Equal("NEW", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName)));
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_MoveAccessDeniedIoException_ClassifiedAccessDenied()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("OLD"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+
+ var swapper = new UpdateFileSwapper(
+ move: (src, dst) => throw new IOException(
+ "access denied", unchecked((int)0x80070005)),
+ readFileVersion: _ => "0.7.0.0");
+
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.False(result.Success);
+ Assert.True(result.AccessDenied);
+ // Live DLL untouched: only rename 1 was attempted and it failed atomically.
+ Assert.Equal("OLD", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName)));
+ // The half-staged .new is swept on the way out, not left behind.
+ Assert.False(File.Exists(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.NewSuffix)));
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_LogoDestinationBlocked_StillSucceeds()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("OLD"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+ string stagedLogos = Path.Combine(staging, UpdatePackage.LogosDirName);
+ Directory.CreateDirectory(stagedLogos);
+ File.WriteAllBytes(Path.Combine(stagedLogos, "wheel.png"), new byte[] { 1 });
+
+ // Occupy the destination logos path with a FILE so the cosmetic
+ // step fails after the DLL commit — the swap must still succeed
+ // (a Failed state on a live new DLL would let a retry delete the
+ // .old rollback copy).
+ File.WriteAllText(Path.Combine(install, UpdatePackage.LogosDirName), "in the way");
+
+ var warns = new List();
+ var swapper = new UpdateFileSwapper(
+ readFileVersion: _ => "0.7.0.0",
+ logWarn: warns.Add);
+
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.True(result.Success, result.Error);
+ Assert.Equal("NEW", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName)));
+ Assert.NotEmpty(warns);
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_CommitRename2Fails_RollsBack()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("OLD"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+
+ int moveCount = 0;
+ var swapper = new UpdateFileSwapper(
+ move: (src, dst) =>
+ {
+ moveCount++;
+ if (moveCount == 2)
+ throw new IOException("simulated commit failure");
+ File.Move(src, dst);
+ },
+ readFileVersion: _ => "0.7.0.0");
+
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.False(result.Success);
+ Assert.True(result.RolledBack);
+ Assert.Equal("OLD", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName)));
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_CommitAndRestoreFail_ErrorMentionsOld_NoThrow()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("OLD"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+
+ int moveCount = 0;
+ var warns = new List();
+ var swapper = new UpdateFileSwapper(
+ move: (src, dst) =>
+ {
+ moveCount++;
+ if (moveCount == 1)
+ {
+ File.Move(src, dst); // live → .old
+ return;
+ }
+ // commit and restore both fail
+ throw new IOException("blocked");
+ },
+ readFileVersion: _ => "0.7.0.0",
+ logWarn: warns.Add);
+
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.False(result.Success);
+ Assert.False(result.RolledBack);
+ Assert.Contains(".old", result.Error, StringComparison.OrdinalIgnoreCase);
+ Assert.NotEmpty(warns);
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_UndeletablePreexistingOld_FailClosed()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("LIVE"));
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.OldSuffix),
+ Encoding.UTF8.GetBytes("STALE"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+
+ var swapper = new UpdateFileSwapper(
+ delete: path => throw new IOException("locked"),
+ readFileVersion: _ => "0.7.0.0");
+
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.False(result.Success);
+ Assert.Equal("LIVE", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName)));
+ Assert.False(File.Exists(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.NewSuffix)));
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_VersionMismatch_FailClosedBeforeRename()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("LIVE"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+
+ bool moved = false;
+ var swapper = new UpdateFileSwapper(
+ move: (src, dst) =>
+ {
+ moved = true;
+ File.Move(src, dst);
+ },
+ readFileVersion: _ => "0.1.0.0");
+
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.False(result.Success);
+ Assert.False(moved);
+ Assert.Equal("LIVE", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName)));
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_UnauthorizedAccess_SetsAccessDenied()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("LIVE"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+
+ var swapper = new UpdateFileSwapper(
+ move: (src, dst) => throw new UnauthorizedAccessException("denied"),
+ readFileVersion: _ => "0.7.0.0");
+
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.False(result.Success);
+ Assert.True(result.AccessDenied);
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void Apply_LogoCopyFailure_StillSuccess_Warns()
+ {
+ string install = MakeTempDir("install");
+ string staging = MakeTempDir("staging");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("OLD"));
+ File.WriteAllBytes(Path.Combine(staging, UpdatePackage.DllName), Encoding.UTF8.GetBytes("NEW"));
+ string stagedLogos = Path.Combine(staging, UpdatePackage.LogosDirName);
+ Directory.CreateDirectory(stagedLogos);
+ File.WriteAllBytes(Path.Combine(stagedLogos, "x.png"), new byte[] { 1 });
+
+ var warns = new List();
+ var swapper = new UpdateFileSwapper(
+ copyOverwrite: (src, dst) =>
+ {
+ if (dst.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
+ throw new IOException("logo blocked");
+ File.Copy(src, dst, overwrite: true);
+ },
+ readFileVersion: _ => "0.7.0.0",
+ logWarn: warns.Add);
+
+ SwapResult result = swapper.Apply(staging, install, "0.7.0");
+
+ Assert.True(result.Success, result.Error);
+ Assert.Contains(warns, w => w.IndexOf("logo", StringComparison.OrdinalIgnoreCase) >= 0);
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(staging);
+ }
+ }
+
+ [Fact]
+ public void CleanupStaleArtifacts_RemovesOldNewAndTempDirs_NeverThrows()
+ {
+ string install = MakeTempDir("install");
+ string tempRoot = MakeTempDir("temp");
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.OldSuffix), new byte[] { 1 });
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.NewSuffix), new byte[] { 2 });
+ string stale = Path.Combine(tempRoot, "FanaBridge-update-abc");
+ Directory.CreateDirectory(stale);
+ File.WriteAllText(Path.Combine(stale, "x.bin"), "x");
+
+ UpdateFileSwapper.CleanupStaleArtifacts(install, tempRoot, null);
+
+ Assert.False(File.Exists(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.OldSuffix)));
+ Assert.False(File.Exists(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.NewSuffix)));
+ Assert.False(Directory.Exists(stale));
+
+ // Locked file: open .old then cleanup should not throw.
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.OldSuffix), new byte[] { 3 });
+ using (var hold = new FileStream(
+ Path.Combine(install, UpdatePackage.DllName + UpdateFileSwapper.OldSuffix),
+ FileMode.Open, FileAccess.Read, FileShare.None))
+ {
+ UpdateFileSwapper.CleanupStaleArtifacts(install, tempRoot, _ => { });
+ }
+ }
+ finally
+ {
+ DeleteQuiet(install);
+ DeleteQuiet(tempRoot);
+ }
+ }
+
+ private static string MakeTempDir(string label)
+ {
+ string path = Path.Combine(Path.GetTempPath(), "FanaBridge-swap-" + label + "-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static void DeleteQuiet(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ Directory.Delete(path, true);
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+ }
+}
diff --git a/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs b/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs
new file mode 100644
index 0000000..fd4036f
--- /dev/null
+++ b/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using FanaBridge.Updater;
+using Xunit;
+
+namespace FanaBridge.Tests.Updater
+{
+ public class UpdatePackageTests
+ {
+ [Fact]
+ public void VerifySha256_Match_CaseInsensitive()
+ {
+ byte[] data = Encoding.UTF8.GetBytes("hello");
+ string hex;
+ using (var sha = SHA256.Create())
+ hex = BitConverter.ToString(sha.ComputeHash(data)).Replace("-", "");
+
+ Assert.True(UpdatePackage.VerifySha256(data, hex.ToLowerInvariant()));
+ Assert.True(UpdatePackage.VerifySha256(data, hex.ToUpperInvariant()));
+ Assert.False(UpdatePackage.VerifySha256(data, new string('0', 64)));
+ }
+
+ [Fact]
+ public void ExtractToStaging_WhitelistOnly_IgnoresTraversalAndExtras()
+ {
+ string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N"));
+ string outsideProbe = Path.Combine(Path.GetTempPath(), "FanaBridge-evil-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(staging);
+ byte[] zip = BuildZip(entries =>
+ {
+ WriteEntry(entries, "FanaBridge.dll", new byte[] { 1, 2, 3 });
+ WriteEntry(entries, "DevicesLogos/a.png", new byte[] { 4, 5 });
+ WriteEntry(entries, "DevicesLogos/sub/b.png", new byte[] { 6 });
+ WriteEntry(entries, "other.txt", Encoding.UTF8.GetBytes("nope"));
+ WriteEntry(entries, "../evil.dll", new byte[] { 9 });
+ WriteEntry(entries, "..\\evil.dll", new byte[] { 9 });
+ WriteEntry(entries, "DevicesLogos/../../evil.png", new byte[] { 9 });
+ // Bare directory marker — ignored.
+ var dir = entries.CreateEntry("DevicesLogos/");
+ _ = dir;
+ });
+
+ IReadOnlyList written = UpdatePackage.ExtractToStaging(zip, staging);
+
+ Assert.Equal(2, written.Count);
+ Assert.Contains("FanaBridge.dll", written);
+ Assert.Contains("DevicesLogos\\a.png", written);
+
+ // Exact staging contents: only the two allowed files (plus DevicesLogos dir).
+ string stagingRoot = Path.GetFullPath(staging)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ + Path.DirectorySeparatorChar;
+ string[] files = Directory.GetFiles(staging, "*", SearchOption.AllDirectories)
+ .Select(p =>
+ {
+ string full = Path.GetFullPath(p);
+ Assert.StartsWith(stagingRoot, full, StringComparison.OrdinalIgnoreCase);
+ return full.Substring(stagingRoot.Length).Replace('/', '\\');
+ })
+ .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ Assert.Equal(new[] { "DevicesLogos\\a.png", "FanaBridge.dll" }, files);
+
+ // Nothing outside staging from traversal names.
+ Assert.False(File.Exists(Path.Combine(Path.GetDirectoryName(staging)!, "evil.dll")));
+ Assert.False(Directory.Exists(outsideProbe));
+ }
+ finally
+ {
+ try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch { /* ignore */ }
+ }
+ }
+
+ [Fact]
+ public void ExtractToStaging_MissingRootDll_Throws()
+ {
+ string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ byte[] zip = BuildZip(entries =>
+ {
+ WriteEntry(entries, "DevicesLogos/a.png", new byte[] { 1 });
+ });
+ var ex = Assert.Throws(() => UpdatePackage.ExtractToStaging(zip, staging));
+ Assert.Contains("FanaBridge.dll", ex.Message);
+ }
+ finally
+ {
+ try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch { /* ignore */ }
+ }
+ }
+
+ [Fact]
+ public void ExtractToStaging_DuplicateLogoViaSeparators_Throws()
+ {
+ string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ // "DevicesLogos/a.png" and "DevicesLogos\a.png" map to the same
+ // relative path after separator normalization.
+ byte[] zip = BuildZip(entries =>
+ {
+ WriteEntry(entries, "FanaBridge.dll", new byte[] { 1 });
+ WriteEntry(entries, "DevicesLogos/a.png", new byte[] { 2 });
+ WriteEntry(entries, "DevicesLogos\\a.png", new byte[] { 3 });
+ });
+
+ var ex = Assert.Throws(() => UpdatePackage.ExtractToStaging(zip, staging));
+ Assert.Contains("duplicate", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch { /* ignore */ }
+ }
+ }
+
+ [Fact]
+ public void ExtractToStaging_DuplicateDll_Throws()
+ {
+ string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ // ZipArchive.CreateEntry does NOT reject duplicate names — a
+ // hand-crafted archive can carry two FanaBridge.dll entries.
+ byte[] zip = BuildZip(entries =>
+ {
+ WriteEntry(entries, "FanaBridge.dll", new byte[] { 1 });
+ WriteEntry(entries, "FanaBridge.dll", new byte[] { 2 });
+ });
+
+ var ex = Assert.Throws(() => UpdatePackage.ExtractToStaging(zip, staging));
+ Assert.Contains("duplicate", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch { /* ignore */ }
+ }
+ }
+
+ [Fact]
+ [Trait("Category", "Slow")]
+ public void ExtractToStaging_TotalSizeCap_Throws()
+ {
+ string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ // Three whitelisted entries of exactly 20 MB each (per-entry cap
+ // boundary): the cumulative 60 MB crosses the 50 MB total cap
+ // during the third entry's streaming copy.
+ byte[] twentyMb = new byte[20 * 1024 * 1024];
+ byte[] zip = BuildZip(entries =>
+ {
+ WriteEntry(entries, "FanaBridge.dll", twentyMb);
+ WriteEntry(entries, "DevicesLogos/a.png", twentyMb);
+ WriteEntry(entries, "DevicesLogos/b.png", twentyMb);
+ });
+
+ var ex = Assert.Throws(() => UpdatePackage.ExtractToStaging(zip, staging));
+ Assert.Contains("total", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch { /* ignore */ }
+ }
+ }
+
+ [Fact]
+ public void ExtractToStaging_EntryCountCap_Throws()
+ {
+ string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ byte[] zip = BuildZip(entries =>
+ {
+ WriteEntry(entries, "FanaBridge.dll", new byte[] { 1 });
+ for (int i = 0; i < 512; i++)
+ WriteEntry(entries, "pad" + i + ".bin", new byte[] { 0 });
+ });
+ // 1 + 512 = 513 entries > 512
+ var ex = Assert.Throws(() => UpdatePackage.ExtractToStaging(zip, staging));
+ Assert.Contains("too many entries", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch { /* ignore */ }
+ }
+ }
+
+ [Fact]
+ [Trait("Category", "Slow")]
+ public void ExtractToStaging_PerEntrySizeCap_Throws()
+ {
+ string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ // >20 MB of zeros compresses well in the zip but expands past the cap while streaming.
+ var huge = new byte[20 * 1024 * 1024 + 1];
+ byte[] zip = BuildZip(entries =>
+ {
+ WriteEntry(entries, "FanaBridge.dll", huge);
+ });
+
+ var ex = Assert.Throws(() => UpdatePackage.ExtractToStaging(zip, staging));
+ Assert.Contains("byte limit", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch { /* ignore */ }
+ }
+ }
+
+ private static byte[] BuildZip(Action populate)
+ {
+ using var ms = new MemoryStream();
+ using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ populate(zip);
+ return ms.ToArray();
+ }
+
+ private static void WriteEntry(ZipArchive zip, string name, byte[] content)
+ {
+ ZipArchiveEntry e = zip.CreateEntry(name, CompressionLevel.Optimal);
+ using Stream s = e.Open();
+ s.Write(content, 0, content.Length);
+ }
+ }
+}
diff --git a/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs b/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs
new file mode 100644
index 0000000..a86ffc5
--- /dev/null
+++ b/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs
@@ -0,0 +1,359 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using FanaBridge.Updater;
+using Xunit;
+
+namespace FanaBridge.Tests.Updater
+{
+ public class UpdateServiceTests
+ {
+ [Fact]
+ public async Task CheckAsync_NewerRelease_UpdateAvailable_FiresPhases()
+ {
+ var phases = new List();
+ var clock = new Clock(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: GoodDigestHex())),
+ utcNow: clock.Now);
+ svc.Changed += s => phases.Add(s.Phase);
+
+ await svc.CheckAsync();
+
+ Assert.Equal(UpdatePhase.UpdateAvailable, svc.Snapshot.Phase);
+ Assert.NotNull(svc.Snapshot.Release);
+ Assert.Equal("0.7.0", svc.Snapshot.Release!.Version);
+ Assert.Equal(new[] { UpdatePhase.Checking, UpdatePhase.UpdateAvailable }, phases);
+ }
+
+ [Fact]
+ public async Task CheckAsync_SameOrOlder_UpToDate()
+ {
+ var svc = CreateService(
+ currentVersion: "0.7.0",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: GoodDigestHex())));
+
+ await svc.CheckAsync();
+ Assert.Equal(UpdatePhase.UpToDate, svc.Snapshot.Phase);
+
+ var svc2 = CreateService(
+ currentVersion: "0.8.0",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: GoodDigestHex())));
+ await svc2.CheckAsync();
+ Assert.Equal(UpdatePhase.UpToDate, svc2.Snapshot.Phase);
+ }
+
+ [Fact]
+ public async Task CheckAsync_FetchThrows_CheckFailed_NoEscape()
+ {
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: (_, __) => throw new IOException("network down"));
+
+ await svc.CheckAsync();
+ Assert.Equal(UpdatePhase.CheckFailed, svc.Snapshot.Phase);
+ Assert.Contains("network", svc.Snapshot.FailureDetail, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task DownloadAndApply_NotifyOnly_IsNoOp()
+ {
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: null)));
+
+ await svc.CheckAsync();
+ Assert.Equal(UpdatePhase.UpdateAvailable, svc.Snapshot.Phase);
+ Assert.False(svc.Snapshot.Release!.CanSelfInstall);
+
+ int bytesCalls = 0;
+ // Rebuild with fetchBytes counter — DownloadAndApply should not call it.
+ var clock = new Clock(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ var svc2 = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: null)),
+ fetchBytes: (_, __) =>
+ {
+ bytesCalls++;
+ return Task.FromResult(Array.Empty());
+ },
+ utcNow: clock.Now);
+ await svc2.CheckAsync();
+ await svc2.DownloadAndApplyAsync();
+ Assert.Equal(0, bytesCalls);
+ Assert.Equal(UpdatePhase.UpdateAvailable, svc2.Snapshot.Phase);
+ }
+
+ [Fact]
+ public async Task DownloadAndApply_DigestMismatch_Failed_SwapperNotInvoked()
+ {
+ bool swapperCalled = false;
+ var swapper = new UpdateFileSwapper(
+ move: (a, b) => { swapperCalled = true; File.Move(a, b); },
+ copyOverwrite: (a, b) => { swapperCalled = true; File.Copy(a, b, true); },
+ readFileVersion: _ => "0.7.0.0");
+
+ string digest = new string('a', 64);
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: digest)),
+ fetchBytes: (_, __) => Task.FromResult(Encoding.UTF8.GetBytes("not matching")),
+ swapper: swapper);
+
+ await svc.CheckAsync();
+ await svc.DownloadAndApplyAsync();
+
+ Assert.Equal(UpdatePhase.Failed, svc.Snapshot.Phase);
+ Assert.Contains("checksum", svc.Snapshot.FailureDetail, StringComparison.OrdinalIgnoreCase);
+ Assert.False(swapperCalled);
+ }
+
+ [Fact]
+ public async Task DownloadAndApply_HappyPath_ReadyToRestart()
+ {
+ string install = Path.Combine(Path.GetTempPath(), "FanaBridge-svc-install-" + Guid.NewGuid().ToString("N"));
+ string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-svc-stage-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(install);
+ try
+ {
+ File.WriteAllBytes(Path.Combine(install, UpdatePackage.DllName), Encoding.UTF8.GetBytes("OLD"));
+
+ byte[] zipBytes = BuildReleaseZip(Encoding.UTF8.GetBytes("NEW-DLL"));
+ string hex = Sha256Hex(zipBytes);
+
+ var phases = new List();
+ var swapper = new UpdateFileSwapper(readFileVersion: _ => "0.7.0.0");
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ installDir: install,
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: hex)),
+ fetchBytes: (_, __) => Task.FromResult(zipBytes),
+ swapper: swapper,
+ stagingDirFactory: () =>
+ {
+ Directory.CreateDirectory(staging);
+ return staging;
+ });
+ svc.Changed += s => phases.Add(s.Phase);
+
+ await svc.CheckAsync();
+ await svc.DownloadAndApplyAsync();
+
+ Assert.Equal(UpdatePhase.ReadyToRestart, svc.Snapshot.Phase);
+ Assert.Contains(UpdatePhase.Downloading, phases);
+ Assert.Contains(UpdatePhase.Applying, phases);
+ Assert.Equal(UpdatePhase.ReadyToRestart, phases[phases.Count - 1]);
+ Assert.Equal("NEW-DLL", File.ReadAllText(Path.Combine(install, UpdatePackage.DllName)));
+
+ // Terminal: both commands no-op.
+ int before = phases.Count;
+ await svc.CheckAsync();
+ await svc.DownloadAndApplyAsync();
+ Assert.Equal(before, phases.Count);
+ Assert.Equal(UpdatePhase.ReadyToRestart, svc.Snapshot.Phase);
+ }
+ finally
+ {
+ try { if (Directory.Exists(install)) Directory.Delete(install, true); } catch { /* ignore */ }
+ try { if (Directory.Exists(staging)) Directory.Delete(staging, true); } catch { /* ignore */ }
+ }
+ }
+
+ [Fact]
+ public async Task CheckAsync_Debounce_SecondWithin30sNoOp_After40sRuns()
+ {
+ var clock = new Clock(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ int fetches = 0;
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: (_, __) =>
+ {
+ fetches++;
+ return Task.FromResult(FeedJson("0.7.0", digest: GoodDigestHex()));
+ },
+ utcNow: clock.Now);
+
+ await svc.CheckAsync();
+ Assert.Equal(1, fetches);
+
+ clock.Utc = clock.Utc.AddSeconds(10);
+ await svc.CheckAsync();
+ Assert.Equal(1, fetches);
+
+ clock.Utc = clock.Utc.AddSeconds(30); // total +40 from first completion time base
+ await svc.CheckAsync();
+ Assert.Equal(2, fetches);
+ }
+
+ [Fact]
+ public async Task CheckAsync_ConcurrentSecondCall_IsNoOp()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ int fetches = 0;
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: async (_, ct) =>
+ {
+ Interlocked.Increment(ref fetches);
+ entered.TrySetResult(true);
+ return await tcs.Task.ConfigureAwait(false);
+ });
+
+ Task first = svc.CheckAsync();
+ // Deterministic: wait until the first call is INSIDE the fetch, so
+ // the second call is guaranteed to hit the busy gate.
+ await entered.Task;
+ Task second = svc.CheckAsync();
+ await second;
+
+ Assert.Equal(1, fetches);
+ tcs.SetResult(FeedJson("0.7.0", digest: GoodDigestHex()));
+ await first;
+ Assert.Equal(UpdatePhase.UpdateAvailable, svc.Snapshot.Phase);
+ }
+
+ [Fact]
+ public async Task CheckAsync_ThrowingSubscriber_DoesNotBreakTransition()
+ {
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: GoodDigestHex())));
+ svc.Changed += _ => throw new InvalidOperationException("boom");
+ var saw = new List();
+ svc.Changed += s => saw.Add(s.Phase);
+
+ await svc.CheckAsync();
+ Assert.Equal(UpdatePhase.UpdateAvailable, svc.Snapshot.Phase);
+ Assert.Contains(UpdatePhase.UpdateAvailable, saw);
+ }
+
+ [Fact]
+ public async Task CheckAsync_UnparseableCurrentVersion_UpToDateEvenIfFeedNewer()
+ {
+ var warns = new List();
+ var svc = CreateService(
+ currentVersion: "not-a-version",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: GoodDigestHex())),
+ logWarn: warns.Add);
+
+ await svc.CheckAsync();
+ Assert.Equal(UpdatePhase.UpToDate, svc.Snapshot.Phase);
+ Assert.Contains(warns, w => w.IndexOf("unparseable", StringComparison.OrdinalIgnoreCase) >= 0);
+ }
+
+ [Fact]
+ public async Task DownloadAndApply_CanceledAfterExtraction_RestoresUpdateAvailable_CleansStaging()
+ {
+ byte[] zip = BuildReleaseZip(new byte[] { 1, 2, 3 });
+ string digest = Sha256Hex(zip);
+
+ bool swapperCalled = false;
+ var swapper = new UpdateFileSwapper(
+ move: (_, __) => swapperCalled = true,
+ copyOverwrite: (_, __) => swapperCalled = true,
+ readFileVersion: _ => "0.7.0.0");
+
+ string staging = Path.Combine(
+ Path.GetTempPath(), "FanaBridge-update-cancel-" + Guid.NewGuid().ToString("N"));
+ using var cts = new CancellationTokenSource();
+ var svc = CreateService(
+ currentVersion: "0.6.0",
+ fetchText: (_, __) => Task.FromResult(FeedJson("0.7.0", digest: digest)),
+ fetchBytes: (_, __) => Task.FromResult(zip),
+ swapper: swapper,
+ // Cancel after the pre-staging token check but before the
+ // post-extraction one — extraction runs, then the last
+ // cancellation point must clean up and restore.
+ stagingDirFactory: () => { cts.Cancel(); return staging; });
+
+ await svc.CheckAsync();
+ Assert.Equal(UpdatePhase.UpdateAvailable, svc.Snapshot.Phase);
+
+ await svc.DownloadAndApplyAsync(cts.Token);
+
+ Assert.Equal(UpdatePhase.UpdateAvailable, svc.Snapshot.Phase);
+ Assert.False(swapperCalled);
+ Assert.False(Directory.Exists(staging));
+ }
+
+ private static UpdateService CreateService(
+ string currentVersion,
+ Func>? fetchText = null,
+ Func>? fetchBytes = null,
+ string? installDir = null,
+ UpdateFileSwapper? swapper = null,
+ Func? stagingDirFactory = null,
+ Func? utcNow = null,
+ Action? logWarn = null)
+ {
+ return new UpdateService(
+ currentVersion: currentVersion,
+ installDir: installDir ?? Path.GetTempPath(),
+ fetchText: fetchText ?? ((_, __) => Task.FromResult("{}")),
+ fetchBytes: fetchBytes ?? ((_, __) => Task.FromResult(Array.Empty())),
+ releaseFeedUrl: "https://example.com/releases/latest",
+ logInfo: _ => { },
+ logWarn: logWarn ?? (_ => { }),
+ swapper: swapper,
+ stagingDirFactory: stagingDirFactory,
+ utcNow: utcNow);
+ }
+
+ private static string FeedJson(string version, string? digest)
+ {
+ string tag = "v" + version;
+ string asset = "FanaBridge-" + version + ".zip";
+ string digestJson = digest == null
+ ? ""
+ : @", ""digest"": ""sha256:" + digest + @"""";
+ return @"{
+ ""tag_name"": """ + tag + @""",
+ ""html_url"": ""https://example.com/r/" + tag + @""",
+ ""assets"": [{
+ ""name"": """ + asset + @""",
+ ""browser_download_url"": ""https://example.com/" + asset + @""",
+ ""size"": 100
+ " + digestJson + @"
+ }]
+}";
+ }
+
+ private static string GoodDigestHex() => new string('b', 64);
+
+ private static byte[] BuildReleaseZip(byte[] dllBytes)
+ {
+ using var ms = new MemoryStream();
+ using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ ZipArchiveEntry e = zip.CreateEntry(UpdatePackage.DllName);
+ using Stream s = e.Open();
+ s.Write(dllBytes, 0, dllBytes.Length);
+ }
+ return ms.ToArray();
+ }
+
+ private static string Sha256Hex(byte[] data)
+ {
+ using var sha = SHA256.Create();
+ byte[] hash = sha.ComputeHash(data);
+ var sb = new StringBuilder(hash.Length * 2);
+ foreach (byte b in hash)
+ sb.Append(b.ToString("x2"));
+ return sb.ToString();
+ }
+
+ private sealed class Clock
+ {
+ public DateTime Utc;
+ public Clock(DateTime utc) => Utc = utc;
+ public DateTime Now() => Utc;
+ }
+ }
+}
diff --git a/tests/FanaBridge.Tests/Updater/UpdateVersionTests.cs b/tests/FanaBridge.Tests/Updater/UpdateVersionTests.cs
new file mode 100644
index 0000000..976d8ff
--- /dev/null
+++ b/tests/FanaBridge.Tests/Updater/UpdateVersionTests.cs
@@ -0,0 +1,67 @@
+using System;
+using FanaBridge.Updater;
+using Xunit;
+
+namespace FanaBridge.Tests.Updater
+{
+ public class UpdateVersionTests
+ {
+ [Theory]
+ [InlineData("0.6.0", "0.6.0", null)]
+ [InlineData("v0.7.0", "0.7.0", null)]
+ [InlineData("V0.7.0", "0.7.0", null)]
+ [InlineData("0.6.0-preview", "0.6.0", "preview")]
+ [InlineData("1.2", "1.2", null)]
+ public void TryParse_AcceptsWellFormed(string text, string numeric, string? suffix)
+ {
+ Assert.True(UpdateVersion.TryParse(text, out UpdateVersion v));
+ Assert.Equal(numeric, v.Numeric.ToString());
+ Assert.Equal(suffix, v.Suffix);
+ Assert.Equal(suffix == null ? numeric : numeric + "-" + suffix, v.ToString());
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("1")]
+ [InlineData("abc")]
+ [InlineData("1.-2.0")]
+ [InlineData("v")]
+ [InlineData("-preview")]
+ [InlineData("0.6.0-")]
+ public void TryParse_RejectsGarbage(string? text)
+ {
+ Assert.False(UpdateVersion.TryParse(text, out _));
+ }
+
+ [Fact]
+ public void OneTwo_Equals_OneTwoZero()
+ {
+ Assert.True(UpdateVersion.TryParse("1.2", out UpdateVersion a));
+ Assert.True(UpdateVersion.TryParse("1.2.0", out UpdateVersion b));
+ Assert.Equal(0, a.CompareTo(b));
+ Assert.True(a.Equals(b));
+ }
+
+ [Fact]
+ public void Ordering_NumericAndSuffix()
+ {
+ Assert.True(UpdateVersion.TryParse("0.10.0", out UpdateVersion v10));
+ Assert.True(UpdateVersion.TryParse("0.9.0", out UpdateVersion v9));
+ Assert.True(v10.CompareTo(v9) > 0);
+
+ Assert.True(UpdateVersion.TryParse("0.6.1", out UpdateVersion a));
+ Assert.True(UpdateVersion.TryParse("0.6.0", out UpdateVersion b));
+ Assert.True(a.CompareTo(b) > 0);
+
+ Assert.True(UpdateVersion.TryParse("0.6.0", out UpdateVersion rel));
+ Assert.True(UpdateVersion.TryParse("0.6.0-preview", out UpdateVersion prev));
+ Assert.True(rel.CompareTo(prev) > 0);
+
+ Assert.True(UpdateVersion.TryParse("0.6.0-preview", out UpdateVersion p1));
+ Assert.True(UpdateVersion.TryParse("0.6.0-PREVIEW", out UpdateVersion p2));
+ Assert.Equal(0, p1.CompareTo(p2));
+ }
+ }
+}