From ae7d9637985810e339d85dafa0c068d09c1c0090 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Thu, 6 Aug 2026 22:58:43 -0400 Subject: [PATCH 01/10] Add self-updater: release check, digest-verified one-click update (#95) New FanaBridge.Updater project (merged into the single shipped DLL): release feed parsing with availability/installability split, whitelist zip extraction, crash-safe two-rename swap with rollback, serialized state machine with terminal ReadyToRestart. Plugin wiring runs one startup check (opt-out setting) and the settings UI offers the update banner with notify-only and access-denied fallbacks. --- Directory.Build.targets | 6 +- FanaBridge.sln | 15 + .../FanaBridge.Updater.csproj | 40 ++ src/FanaBridge.Updater/ReleaseFeed.cs | 193 ++++++++++ src/FanaBridge.Updater/UpdateFileSwapper.cs | 342 +++++++++++++++++ src/FanaBridge.Updater/UpdatePackage.cs | 184 +++++++++ src/FanaBridge.Updater/UpdateService.cs | 357 ++++++++++++++++++ src/FanaBridge.Updater/UpdateVersion.cs | 126 +++++++ src/FanaBridge/FanaBridge.csproj | 29 +- src/FanaBridge/FanatecPlugin.cs | 90 +++++ src/FanaBridge/FanatecPluginSettings.cs | 9 + src/FanaBridge/UI/SettingsControl.xaml | 53 +++ src/FanaBridge/UI/SettingsControl.xaml.cs | 236 ++++++++++++ src/FanaBridge/Updates/GitHubHttpClient.cs | 61 +++ .../FanaBridge.Tests/FanaBridge.Tests.csproj | 3 + .../Updater/ReleaseFeedTests.cs | 129 +++++++ .../Updater/UpdateFileSwapperTests.cs | 328 ++++++++++++++++ .../Updater/UpdatePackageTests.cs | 193 ++++++++++ .../Updater/UpdateServiceTests.cs | 321 ++++++++++++++++ .../Updater/UpdateVersionTests.cs | 67 ++++ 20 files changed, 2766 insertions(+), 16 deletions(-) create mode 100644 src/FanaBridge.Updater/FanaBridge.Updater.csproj create mode 100644 src/FanaBridge.Updater/ReleaseFeed.cs create mode 100644 src/FanaBridge.Updater/UpdateFileSwapper.cs create mode 100644 src/FanaBridge.Updater/UpdatePackage.cs create mode 100644 src/FanaBridge.Updater/UpdateService.cs create mode 100644 src/FanaBridge.Updater/UpdateVersion.cs create mode 100644 src/FanaBridge/Updates/GitHubHttpClient.cs create mode 100644 tests/FanaBridge.Tests/Updater/ReleaseFeedTests.cs create mode 100644 tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs create mode 100644 tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs create mode 100644 tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs create mode 100644 tests/FanaBridge.Tests/Updater/UpdateVersionTests.cs diff --git a/Directory.Build.targets b/Directory.Build.targets index 03e98c08..54b06f95 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -13,10 +13,10 @@ - - + diff --git a/FanaBridge.sln b/FanaBridge.sln index 5a5d86a0..8d62e653 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 00000000..476b99df --- /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 00000000..23243d92 --- /dev/null +++ b/src/FanaBridge.Updater/ReleaseFeed.cs @@ -0,0 +1,193 @@ +#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; + } + + // 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"); + zipSize = ao.Value("size") ?? 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: canInstall ? 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 00000000..4bc4c8e2 --- /dev/null +++ b/src/FanaBridge.Updater/UpdateFileSwapper.cs @@ -0,0 +1,342 @@ +#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 + { + 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) + { + 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); + } + } + + private void CopyLogosBestEffort(string stagingDir, string installDir) + { + 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) + { + _logWarn("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) + { + _logWarn("Could not copy logo '" + Path.GetFileName(src) + "': " + ex.Message); + } + } + } + + 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 00000000..a54eb266 --- /dev/null +++ b/src/FanaBridge.Updater/UpdatePackage.cs @@ -0,0 +1,184 @@ +#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; + + 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 00000000..f6503d6b --- /dev/null +++ b/src/FanaBridge.Updater/UpdateService.cs @@ -0,0 +1,357 @@ +#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) + { + // 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)); + + 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; + } + + // Cancellation is honored up to Apply; the file swap runs to completion. + ct.ThrowIfCancellationRequested(); + + string 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; + } + + Publish(new UpdateSnapshot(UpdatePhase.Applying, release, null, false)); + + // Apply itself is not cancellable — partial renames must finish or roll back. + 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) + { + // Restore to UpdateAvailable so the user can retry; do not mark Failed. + Publish(new UpdateSnapshot(UpdatePhase.UpdateAvailable, release, null, false)); + } + catch (Exception ex) + { + 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 00000000..a136529b --- /dev/null +++ b/src/FanaBridge.Updater/UpdateVersion.cs @@ -0,0 +1,126 @@ +#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() + => Suffix == null ? Numeric.ToString() : Numeric.ToString() + "-" + Suffix; + + /// Missing Version components are -1; treat them as 0 for equality/order. + private static Version Normalize(Version v) + { + 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 7b5c3bfa..12dd777b 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 + + + + @@ -244,6 +282,21 @@ github.com/kelchm/FanaBridge + + + + + Check for updates now + + + + diff --git a/src/FanaBridge/UI/SettingsControl.xaml.cs b/src/FanaBridge/UI/SettingsControl.xaml.cs index 10a38d53..aa486f2f 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,234 @@ private void ChkEnableTuning_Changed(object sender, RoutedEventArgs e) Plugin?.SaveSettings(); } + private void ChkEnableUpdateCheck_Changed(object sender, RoutedEventArgs e) + { + // Takes effect on the next SimHub launch (the startup check runs + // once per process); 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 bool _updateRestartOffered; + + 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: + txtUpdateCheckResult.Text = "Update available."; + StyleUpdateBanner(failed: false); + txtUpdateTitle.Text = "Update available: FanaBridge " + release.Version; + if (release.CanSelfInstall) + { + txtUpdateDetail.Text = "You have " + BuildIdentity.Version + "."; + btnUpdateNow.Content = "Update"; + } + else + { + txtUpdateDetail.Text = "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: + StyleUpdateBanner(failed: false); + txtUpdateTitle.Text = "Updating to FanaBridge " + release?.Version; + txtUpdateDetail.Text = "Downloading and installing…"; + btnUpdateNow.IsEnabled = false; + borderUpdateAlert.Visibility = Visibility.Visible; + break; + + case UpdatePhase.ReadyToRestart: + StyleUpdateBanner(failed: false); + txtUpdateTitle.Text = "Update installed"; + txtUpdateDetail.Text = "Restart SimHub to finish updating to FanaBridge " + + release?.Version + "."; + btnUpdateNow.Content = "Restart SimHub"; + btnUpdateNow.IsEnabled = true; + borderUpdateAlert.Visibility = Visibility.Visible; + OfferUpdateRestartOnce(); + break; + + case UpdatePhase.Failed: + StyleUpdateBanner(failed: true); + txtUpdateTitle.Text = "Automatic update failed"; + txtUpdateDetail.Text = ComposeUpdateFailureDetail(snapshot); + btnUpdateNow.Content = "Open release page"; + btnUpdateNow.IsEnabled = true; + borderUpdateAlert.Visibility = Visibility.Visible; + break; + } + } + + private void StyleUpdateBanner(bool failed) + { + borderUpdateAlert.Background = failed ? UpdateAmberBg : UpdateBlueBg; + borderUpdateAlert.BorderBrush = failed ? UpdateAmberBorder : UpdateBlueBorder; + txtUpdateGlyph.Text = failed ? "⚠" : "⬆"; + txtUpdateGlyph.Foreground = 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. + if (_updateRestartOffered) + return; + _updateRestartOffered = 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. + _ = updates.DownloadAndApplyAsync(); + 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) + { + string url = snapshot?.Release?.HtmlUrl ?? "https://github.com/kelchm/FanaBridge/releases"; + 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…"; + try { await updates.CheckAsync(); } + 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 00000000..50f6bf0f --- /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 6fbb7d96..1d822d71 100644 --- a/tests/FanaBridge.Tests/FanaBridge.Tests.csproj +++ b/tests/FanaBridge.Tests/FanaBridge.Tests.csproj @@ -13,6 +13,7 @@ + @@ -30,6 +31,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 00000000..67d1f090 --- /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 00000000..7a8cfc7b --- /dev/null +++ b/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs @@ -0,0 +1,328 @@ +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_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 00000000..3d083094 --- /dev/null +++ b/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs @@ -0,0 +1,193 @@ +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_DuplicateDll_Throws() + { + string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N")); + try + { + // ZipArchive API doesn't allow two entries with the same name easily via + // CreateEntry twice — build raw-ish by writing two CreateEntry with same name + // which throws. Use different casing? Whitelist is ordinal for DLL exact name. + // Build zip with two identical names by manipulating: CreateEntry then another + // with a name that maps to the same relative path isn't possible for DLL. + // Use ZipArchive with Update mode after creating first entry — still unique names. + // Instead: create via raw zip by writing entry named "FanaBridge.dll" twice + // through a custom approach — ZipArchive.CreateEntry throws on duplicate. + // Workaround: extract path uses ordinal for DLL name "FanaBridge.dll" only. + // For logos, "DevicesLogos/a.png" and "DevicesLogos\\a.png" map to same relative. + 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_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 00000000..286083bd --- /dev/null +++ b/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs @@ -0,0 +1,321 @@ +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 tcs = new TaskCompletionSource(); + int fetches = 0; + var svc = CreateService( + currentVersion: "0.6.0", + fetchText: async (_, ct) => + { + Interlocked.Increment(ref fetches); + return await tcs.Task.ConfigureAwait(false); + }); + + Task first = svc.CheckAsync(); + // Allow first to enter fetch. + await Task.Delay(50); + 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); + } + + 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 00000000..976d8ffc --- /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)); + } + } +} From a7898fb9591c0d35dfebc785cd9edd46a1b7a97e Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Thu, 6 Aug 2026 23:13:12 -0400 Subject: [PATCH 02/10] Harden self-updater per adversarial review - Honor cancellation up to the commit point: re-check the token after extraction and clean the staging dir when a cancel lands mid-download. - Never report a committed swap as failed: the cosmetic logo step after rename 2 is now fully non-throwing (a Failed state on a live new DLL would let a retry destroy the .old rollback copy). - One updater-lifetime CTS covering manual checks/applies from the UI, not just the startup check, so FinalizePlugin cancels those too. - Move the post-update restart-prompt guard to the plugin (a fresh SettingsControl is created per page open). - Classify access-denied from the staged-DLL version read; parse asset "size" defensively (non-numeric JSON no longer throws). - Tests: real duplicate-DLL zip entries, 50 MB total-cap enforcement, IOException HRESULT access-denied classification, cancel-after- extraction, and post-commit logo failure still succeeding. --- src/FanaBridge.Updater/ReleaseFeed.cs | 9 ++- src/FanaBridge.Updater/UpdateFileSwapper.cs | 55 +++++++++++----- src/FanaBridge.Updater/UpdateService.cs | 11 +++- src/FanaBridge/FanatecPlugin.cs | 44 +++++++++++-- src/FanaBridge/UI/SettingsControl.xaml.cs | 16 +++-- .../Updater/UpdateFileSwapperTests.cs | 66 +++++++++++++++++++ .../Updater/UpdatePackageTests.cs | 63 ++++++++++++++---- .../Updater/UpdateServiceTests.cs | 35 ++++++++++ 8 files changed, 252 insertions(+), 47 deletions(-) diff --git a/src/FanaBridge.Updater/ReleaseFeed.cs b/src/FanaBridge.Updater/ReleaseFeed.cs index 23243d92..dd7f6e19 100644 --- a/src/FanaBridge.Updater/ReleaseFeed.cs +++ b/src/FanaBridge.Updater/ReleaseFeed.cs @@ -151,7 +151,12 @@ public static class ReleaseFeed zipName = name; zipUrl = ao.Value("browser_download_url"); - zipSize = ao.Value("size") ?? 0; + // 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; } @@ -183,7 +188,7 @@ public static class ReleaseFeed version: version, htmlUrl: htmlUrl!, zipName: zipName, - zipUrl: canInstall ? zipUrl : zipUrl, + zipUrl: zipUrl, zipSizeBytes: zipSize, digestHex: digestHex, canSelfInstall: canInstall, diff --git a/src/FanaBridge.Updater/UpdateFileSwapper.cs b/src/FanaBridge.Updater/UpdateFileSwapper.cs index 4bc4c8e2..39f388a6 100644 --- a/src/FanaBridge.Updater/UpdateFileSwapper.cs +++ b/src/FanaBridge.Updater/UpdateFileSwapper.cs @@ -95,6 +95,12 @@ public UpdateFileSwapper( { 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; @@ -250,35 +256,50 @@ public static void CleanupStaleArtifacts(string installDir, string? tempRoot, Ac } } + // 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) { - 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) - { - _logWarn("Could not create DevicesLogos directory: " + ex.Message); - return; - } + string stagedLogos = Path.Combine(stagingDir, UpdatePackage.LogosDirName); + if (!Directory.Exists(stagedLogos)) + return; - foreach (string src in Directory.GetFiles(stagedLogos, "*.png")) - { - string dest = Path.Combine(destLogos, Path.GetFileName(src)); + string destLogos = Path.Combine(installDir, UpdatePackage.LogosDirName); try { - _copyOverwrite(src, dest); + Directory.CreateDirectory(destLogos); } catch (Exception ex) { - _logWarn("Could not copy logo '" + Path.GetFileName(src) + "': " + ex.Message); + 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) diff --git a/src/FanaBridge.Updater/UpdateService.cs b/src/FanaBridge.Updater/UpdateService.cs index f6503d6b..5ff9ae90 100644 --- a/src/FanaBridge.Updater/UpdateService.cs +++ b/src/FanaBridge.Updater/UpdateService.cs @@ -231,6 +231,7 @@ public async Task DownloadAndApplyAsync(CancellationToken ct = default) Publish(new UpdateSnapshot(UpdatePhase.Downloading, release, null, false)); + string? staging = null; try { byte[] bytes = await _fetchBytes(release.ZipUrl!, ct).ConfigureAwait(false); @@ -243,10 +244,9 @@ public async Task DownloadAndApplyAsync(CancellationToken ct = default) return; } - // Cancellation is honored up to Apply; the file swap runs to completion. ct.ThrowIfCancellationRequested(); - string staging = _stagingDirFactory(); + staging = _stagingDirFactory(); try { UpdatePackage.ExtractToStaging(bytes, staging); @@ -259,9 +259,12 @@ public async Task DownloadAndApplyAsync(CancellationToken ct = default) 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)); - // Apply itself is not cancellable — partial renames must finish or roll back. SwapResult result = _swapper.Apply(staging, _installDir, release.Version); if (result.Success) { @@ -280,6 +283,8 @@ public async Task DownloadAndApplyAsync(CancellationToken ct = default) 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) diff --git a/src/FanaBridge/FanatecPlugin.cs b/src/FanaBridge/FanatecPlugin.cs index 425755e8..8f116158 100644 --- a/src/FanaBridge/FanatecPlugin.cs +++ b/src/FanaBridge/FanatecPlugin.cs @@ -92,8 +92,33 @@ public class FanatecPlugin : IPlugin, IDataPlugin, IWPFSettingsV2, IReusable /// public UpdateService Updates => _updateService; + /// + /// True once the settings UI has offered the post-update restart prompt. + /// Held here (not on the control) because SimHub creates a fresh + /// SettingsControl per page open while the updater — and its + /// ReadyToRestart state — lives for the whole process. + /// + public bool UpdateRestartPromptShown { get; set; } + + /// + /// Manual "check for updates" entry point. Routes through the + /// updater-lifetime cancellation token so FinalizePlugin can stop it. + /// + public Task CheckForUpdatesAsync() + => _updateService?.CheckAsync(_updaterCts?.Token ?? CancellationToken.None) + ?? Task.CompletedTask; + + /// + /// One-click update entry point (download + verify + swap). Cancellable + /// via the updater-lifetime token up to the commit point only; the swap + /// itself always runs to completion. + /// + public Task ApplyUpdateAsync() + => _updateService?.DownloadAndApplyAsync(_updaterCts?.Token ?? CancellationToken.None) + ?? Task.CompletedTask; + private UpdateService _updateService; - private CancellationTokenSource _updateCheckCts; + private CancellationTokenSource _updaterCts; /// /// When true, device instances skip all LED and display output so the @@ -516,10 +541,14 @@ private void InitializeUpdater() msg => SimHub.Logging.Current.Warn(msg)); _updateService.Changed += _ => UpdateStateChanged?.Invoke(); + // One CTS for the updater's whole lifetime — it also covers + // MANUAL checks/applies started from the settings UI, so + // FinalizePlugin can stop those too, not just the startup check. + _updaterCts = new CancellationTokenSource(); + if (Settings.EnableUpdateCheck) { - _updateCheckCts = new CancellationTokenSource(); - var token = _updateCheckCts.Token; + var token = _updaterCts.Token; Task.Run(async () => { // CheckAsync converts failures to states; this catch is @@ -636,10 +665,11 @@ public void FinalizePlugin() { SimHub.Logging.Current.Info("FanaBridge: FinalizePlugin (final teardown)"); - // Stop a still-running startup update check. An apply that already - // reached its commit point runs to completion (UpdateService passes - // CancellationToken.None past the point of no return). - try { _updateCheckCts?.Cancel(); } catch { /* best-effort */ } + // Stop any in-flight update work — the startup check AND manual + // checks/applies from the settings UI share this token. A swap that + // already reached its commit point still runs to completion + // (UpdateService stops honoring the token past the last safe point). + try { _updaterCts?.Cancel(); } catch { /* best-effort */ } // Unpublish FIRST: device DataUpdate frames can still be in flight // (the host doesn't join them on a manager restart), and they must diff --git a/src/FanaBridge/UI/SettingsControl.xaml.cs b/src/FanaBridge/UI/SettingsControl.xaml.cs index aa486f2f..24c3fa7f 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml.cs +++ b/src/FanaBridge/UI/SettingsControl.xaml.cs @@ -1077,8 +1077,6 @@ private void ChkEnableUpdateCheck_Changed(object sender, RoutedEventArgs e) // never decides what is allowed, it only displays and forwards clicks. // ===================================================================== - private bool _updateRestartOffered; - private static readonly Brush UpdateBlueBg = HexBrush("#1A4488CC"); private static readonly Brush UpdateBlueBorder = HexBrush("#4488CC"); private static readonly Brush UpdateBlueText = HexBrush("#AADDFF"); @@ -1210,10 +1208,12 @@ private static string ComposeUpdateFailureDetail(UpdateSnapshot snapshot) private void OfferUpdateRestartOnce() { // The banner keeps its Restart button, so declining here isn't - // final — this just avoids re-prompting on every render. - if (_updateRestartOffered) + // 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; - _updateRestartOffered = true; + Plugin.UpdateRestartPromptShown = true; var result = MessageBox.Show( "FanaBridge has been updated. Restart SimHub now to load the new version?", @@ -1237,7 +1237,8 @@ private void BtnUpdateNow_Click(object sender, RoutedEventArgs e) 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. - _ = updates.DownloadAndApplyAsync(); + // Routed via the plugin so FinalizePlugin's cancel covers it. + _ = Plugin.ApplyUpdateAsync(); break; case UpdatePhase.UpdateAvailable: @@ -1279,7 +1280,8 @@ private async void LnkCheckForUpdates_Click(object sender, RoutedEventArgs e) } txtUpdateCheckResult.Text = "Checking…"; - try { await updates.CheckAsync(); } + // 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 diff --git a/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs b/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs index 7a8cfc7b..0a0c8201 100644 --- a/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs +++ b/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs @@ -72,6 +72,72 @@ public void Apply_RenameWhileOpen_SucceedsWithShareDelete() } } + [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))); + } + 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() { diff --git a/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs b/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs index 3d083094..ecbc7ed8 100644 --- a/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs +++ b/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs @@ -99,21 +99,13 @@ public void ExtractToStaging_MissingRootDll_Throws() } [Fact] - public void ExtractToStaging_DuplicateDll_Throws() + public void ExtractToStaging_DuplicateLogoViaSeparators_Throws() { string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N")); try { - // ZipArchive API doesn't allow two entries with the same name easily via - // CreateEntry twice — build raw-ish by writing two CreateEntry with same name - // which throws. Use different casing? Whitelist is ordinal for DLL exact name. - // Build zip with two identical names by manipulating: CreateEntry then another - // with a name that maps to the same relative path isn't possible for DLL. - // Use ZipArchive with Update mode after creating first entry — still unique names. - // Instead: create via raw zip by writing entry named "FanaBridge.dll" twice - // through a custom approach — ZipArchive.CreateEntry throws on duplicate. - // Workaround: extract path uses ordinal for DLL name "FanaBridge.dll" only. - // For logos, "DevicesLogos/a.png" and "DevicesLogos\\a.png" map to same relative. + // "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 }); @@ -130,6 +122,55 @@ public void ExtractToStaging_DuplicateDll_Throws() } } + [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] + 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() { diff --git a/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs b/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs index 286083bd..618ed904 100644 --- a/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs +++ b/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs @@ -245,6 +245,41 @@ public async Task CheckAsync_UnparseableCurrentVersion_UpToDateEvenIfFeedNewer() 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, From 954cda8f8c10227bb22f40e29c618038e813d185 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Fri, 7 Aug 2026 17:35:53 -0400 Subject: [PATCH 03/10] Address PR review feedback - Distinguish HttpClient timeouts from caller cancellation in both UpdateService command handlers (a timed-out manual check now reports CheckFailed instead of silently restoring the previous phase). - Clean the staging dir in the generic apply catch, matching the other failure paths; sweep the half-staged .new in the swapper outer catch. - Trim tag_name before deriving the version/asset name; null-safe UpdateVersion for default-constructed instances. - Only shell-launch http(s) release URLs; fall back to the releases page. - Reject invalid Windows filename characters in logo entries explicitly. - Tests: deterministic concurrency test (entry signal instead of delay), Slow trait on the total-cap test, .new-absent assertion. --- src/FanaBridge.Updater/ReleaseFeed.cs | 9 ++++++-- src/FanaBridge.Updater/UpdateFileSwapper.cs | 3 +++ src/FanaBridge.Updater/UpdatePackage.cs | 5 ++++ src/FanaBridge.Updater/UpdateService.cs | 23 ++++++++++++++++++- src/FanaBridge.Updater/UpdateVersion.cs | 15 +++++++++--- src/FanaBridge/UI/SettingsControl.xaml.cs | 10 +++++++- .../Updater/UpdateFileSwapperTests.cs | 2 ++ .../Updater/UpdatePackageTests.cs | 1 + .../Updater/UpdateServiceTests.cs | 9 +++++--- 9 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/FanaBridge.Updater/ReleaseFeed.cs b/src/FanaBridge.Updater/ReleaseFeed.cs index dd7f6e19..41626677 100644 --- a/src/FanaBridge.Updater/ReleaseFeed.cs +++ b/src/FanaBridge.Updater/ReleaseFeed.cs @@ -113,8 +113,13 @@ public static class ReleaseFeed 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!; + string version = tagName; if (version.Length > 0 && (version[0] == 'v' || version[0] == 'V')) version = version.Substring(1); @@ -184,7 +189,7 @@ public static class ReleaseFeed bool canInstall = blocked == null && digestHex != null && !string.IsNullOrWhiteSpace(zipUrl); return new ReleaseInfo( - tagName: tagName!, + tagName: tagName, version: version, htmlUrl: htmlUrl!, zipName: zipName, diff --git a/src/FanaBridge.Updater/UpdateFileSwapper.cs b/src/FanaBridge.Updater/UpdateFileSwapper.cs index 39f388a6..44da417c 100644 --- a/src/FanaBridge.Updater/UpdateFileSwapper.cs +++ b/src/FanaBridge.Updater/UpdateFileSwapper.cs @@ -215,6 +215,9 @@ public SwapResult Apply(string stagingDir, string installDir, string? expectedVe } 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); } } diff --git a/src/FanaBridge.Updater/UpdatePackage.cs b/src/FanaBridge.Updater/UpdatePackage.cs index a54eb266..39c8622f 100644 --- a/src/FanaBridge.Updater/UpdatePackage.cs +++ b/src/FanaBridge.Updater/UpdatePackage.cs @@ -165,6 +165,11 @@ private static bool TryMapWhitelist(string fullName, out string? relativePath) 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; diff --git a/src/FanaBridge.Updater/UpdateService.cs b/src/FanaBridge.Updater/UpdateService.cs index 5ff9ae90..beb1f5bb 100644 --- a/src/FanaBridge.Updater/UpdateService.cs +++ b/src/FanaBridge.Updater/UpdateService.cs @@ -177,9 +177,19 @@ public async Task CheckAsync(CancellationToken ct = default) _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) { - // Cancellation is not a failure — restore the pre-check non-busy phase. + // Caller-requested cancellation is not a failure — restore + // the pre-check non-busy phase. Publish(previous); } catch (Exception ex) @@ -280,6 +290,15 @@ public async Task DownloadAndApplyAsync(CancellationToken ct = default) 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. @@ -289,6 +308,8 @@ public async Task DownloadAndApplyAsync(CancellationToken ct = default) } 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)); diff --git a/src/FanaBridge.Updater/UpdateVersion.cs b/src/FanaBridge.Updater/UpdateVersion.cs index a136529b..c9f80b18 100644 --- a/src/FanaBridge.Updater/UpdateVersion.cs +++ b/src/FanaBridge.Updater/UpdateVersion.cs @@ -113,11 +113,20 @@ public override int GetHashCode() /// Formats as 0.6.0 or 0.6.0-preview. public override string ToString() - => Suffix == null ? Numeric.ToString() : Numeric.ToString() + "-" + Suffix; + { + 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. - private static Version Normalize(Version v) + /// + /// 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/UI/SettingsControl.xaml.cs b/src/FanaBridge/UI/SettingsControl.xaml.cs index 24c3fa7f..26b6bff9 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml.cs +++ b/src/FanaBridge/UI/SettingsControl.xaml.cs @@ -1259,7 +1259,15 @@ private void LnkReleaseNotes_Click(object sender, RoutedEventArgs e) private void OpenReleasePage(UpdateSnapshot snapshot) { - string url = snapshot?.Release?.HtmlUrl ?? "https://github.com/kelchm/FanaBridge/releases"; + 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 }); diff --git a/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs b/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs index 0a0c8201..de99524f 100644 --- a/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs +++ b/tests/FanaBridge.Tests/Updater/UpdateFileSwapperTests.cs @@ -93,6 +93,8 @@ public void Apply_MoveAccessDeniedIoException_ClassifiedAccessDenied() 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 { diff --git a/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs b/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs index ecbc7ed8..fd4036f7 100644 --- a/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs +++ b/tests/FanaBridge.Tests/Updater/UpdatePackageTests.cs @@ -146,6 +146,7 @@ public void ExtractToStaging_DuplicateDll_Throws() } [Fact] + [Trait("Category", "Slow")] public void ExtractToStaging_TotalSizeCap_Throws() { string staging = Path.Combine(Path.GetTempPath(), "FanaBridge-pkg-" + Guid.NewGuid().ToString("N")); diff --git a/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs b/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs index 618ed904..a86ffc5f 100644 --- a/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs +++ b/tests/FanaBridge.Tests/Updater/UpdateServiceTests.cs @@ -194,19 +194,22 @@ public async Task CheckAsync_Debounce_SecondWithin30sNoOp_After40sRuns() [Fact] public async Task CheckAsync_ConcurrentSecondCall_IsNoOp() { - var tcs = new TaskCompletionSource(); + 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(); - // Allow first to enter fetch. - await Task.Delay(50); + // 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; From bb46dcc7940844e7c2bfb67db337adb07f439031 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 12:00:51 -0400 Subject: [PATCH 04/10] Address PR review feedback (round 2) - Defer updater init (and the .old rollback-copy sweep) from the end of InitializeCore to the end of the first full Init, so the rollback copy outlives the per-manager registrations that could still fail a fresh build''s load. - Keep the About-section status line in step with the banner for the Downloading/Applying, ReadyToRestart, and Failed phases. --- src/FanaBridge/FanatecPlugin.cs | 20 ++++++++++++++------ src/FanaBridge/UI/SettingsControl.xaml.cs | 3 +++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/FanaBridge/FanatecPlugin.cs b/src/FanaBridge/FanatecPlugin.cs index 4ce8f822..42a6c95e 100644 --- a/src/FanaBridge/FanatecPlugin.cs +++ b/src/FanaBridge/FanatecPlugin.cs @@ -119,6 +119,7 @@ public Task ApplyUpdateAsync() private UpdateService _updateService; private CancellationTokenSource _updaterCts; + private bool _updaterInitialized; /// /// When true, device instances skip all LED and display output so the @@ -415,6 +416,16 @@ public void Init(PluginManager pluginManager) SimHub.Logging.Current.Info( $"FanaBridge: Init complete, connected={_connectionMonitor.IsConnected}"); + + // Very last, after ALL first-Init work (core + the registrations + // above): a FanaBridge.dll.old left by a self-update survives as a + // manual rollback copy until this (new) build has proven it can + // complete a full Init — only then does the updater sweep it. + if (!_updaterInitialized) + { + _updaterInitialized = true; + InitializeUpdater(); + } } /// @@ -500,17 +511,14 @@ private void InitializeCore() // Attempt initial connection _connectionMonitor.TryInitialConnect(); - - // Last step on purpose: a FanaBridge.dll.old left by a self-update - // survives as a manual rollback copy until this (new) build has - // proven it can construct its core. - InitializeUpdater(); } /// /// Builds the self-updater, sweeps stale swap artifacts, and kicks the /// once-per-process background check (opt-out via - /// ). Strictly + /// ). Runs once, + /// at the END of the first , so the rollback copy + /// outlives every step that could fail a fresh build's load. Strictly /// best-effort: no failure in here may affect plugin init. /// private void InitializeUpdater() diff --git a/src/FanaBridge/UI/SettingsControl.xaml.cs b/src/FanaBridge/UI/SettingsControl.xaml.cs index 26b6bff9..32bcf8c4 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml.cs +++ b/src/FanaBridge/UI/SettingsControl.xaml.cs @@ -1155,6 +1155,7 @@ private void UpdateUpdateBanner() case UpdatePhase.Downloading: case UpdatePhase.Applying: + txtUpdateCheckResult.Text = "Installing update…"; StyleUpdateBanner(failed: false); txtUpdateTitle.Text = "Updating to FanaBridge " + release?.Version; txtUpdateDetail.Text = "Downloading and installing…"; @@ -1163,6 +1164,7 @@ private void UpdateUpdateBanner() break; case UpdatePhase.ReadyToRestart: + txtUpdateCheckResult.Text = "Update installed — restart SimHub."; StyleUpdateBanner(failed: false); txtUpdateTitle.Text = "Update installed"; txtUpdateDetail.Text = "Restart SimHub to finish updating to FanaBridge " @@ -1174,6 +1176,7 @@ private void UpdateUpdateBanner() break; case UpdatePhase.Failed: + txtUpdateCheckResult.Text = "Automatic update failed."; StyleUpdateBanner(failed: true); txtUpdateTitle.Text = "Automatic update failed"; txtUpdateDetail.Text = ComposeUpdateFailureDetail(snapshot); From 3303bbe9760228d0ebd0fad8d3aa39d3cf3f2f98 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 12:14:46 -0400 Subject: [PATCH 05/10] Rework the update banner layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the left-hugging width-capped callout card with a bar that spans the section content width: one row of headline (inline release-notes link) plus a right-aligned action button, and a muted detail line shown only for states that need one (installing, notify-only, failure). Drops the glyph column — the blue/amber border and tint carry the state. --- src/FanaBridge/UI/SettingsControl.xaml | 37 +++++++++++------------ src/FanaBridge/UI/SettingsControl.xaml.cs | 32 ++++++++++++-------- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/FanaBridge/UI/SettingsControl.xaml b/src/FanaBridge/UI/SettingsControl.xaml index 20cbb8a8..2f5b509b 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml +++ b/src/FanaBridge/UI/SettingsControl.xaml @@ -12,35 +12,34 @@ + state — palette swapped in code by StyleUpdateBanner). Spans + the section content width: one row of headline (+ inline + release-notes link) and a right-aligned action button, with a + muted detail line only for states that need one. Headline text + goes through runUpdateHeadline — setting txtUpdateTitle.Text + would wipe the inline hyperlink. Driven entirely by + UpdateUpdateBanner from UpdateService snapshots. --> + CornerRadius="4" Padding="12,8" Margin="19,4,19,12"> - - - + - - - - Release notes - + FontWeight="SemiBold" TextWrapping="Wrap"> + Release notes + - Update diff --git a/src/FanaBridge/UI/SettingsControl.xaml.cs b/src/FanaBridge/UI/SettingsControl.xaml.cs index 32bcf8c4..77e3d029 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml.cs +++ b/src/FanaBridge/UI/SettingsControl.xaml.cs @@ -1137,16 +1137,17 @@ private void UpdateUpdateBanner() case UpdatePhase.UpdateAvailable: txtUpdateCheckResult.Text = "Update available."; StyleUpdateBanner(failed: false); - txtUpdateTitle.Text = "Update available: FanaBridge " + release.Version; + runUpdateHeadline.Text = "Update available: FanaBridge " + release.Version + + " — you have " + BuildIdentity.Version + "."; if (release.CanSelfInstall) { - txtUpdateDetail.Text = "You have " + BuildIdentity.Version + "."; + SetUpdateDetail(null); btnUpdateNow.Content = "Update"; } else { - txtUpdateDetail.Text = "One-click update isn't available for this release (" - + release.InstallBlockedReason + ") — install it manually from the release page."; + 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; @@ -1157,8 +1158,9 @@ private void UpdateUpdateBanner() case UpdatePhase.Applying: txtUpdateCheckResult.Text = "Installing update…"; StyleUpdateBanner(failed: false); - txtUpdateTitle.Text = "Updating to FanaBridge " + release?.Version; - txtUpdateDetail.Text = "Downloading and installing…"; + runUpdateHeadline.Text = "Updating to FanaBridge " + release?.Version; + SetUpdateDetail("Downloading and installing…"); + btnUpdateNow.Content = "Update"; btnUpdateNow.IsEnabled = false; borderUpdateAlert.Visibility = Visibility.Visible; break; @@ -1166,9 +1168,9 @@ private void UpdateUpdateBanner() case UpdatePhase.ReadyToRestart: txtUpdateCheckResult.Text = "Update installed — restart SimHub."; StyleUpdateBanner(failed: false); - txtUpdateTitle.Text = "Update installed"; - txtUpdateDetail.Text = "Restart SimHub to finish updating to FanaBridge " + 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; @@ -1178,8 +1180,8 @@ private void UpdateUpdateBanner() case UpdatePhase.Failed: txtUpdateCheckResult.Text = "Automatic update failed."; StyleUpdateBanner(failed: true); - txtUpdateTitle.Text = "Automatic update failed"; - txtUpdateDetail.Text = ComposeUpdateFailureDetail(snapshot); + runUpdateHeadline.Text = "Automatic update failed"; + SetUpdateDetail(ComposeUpdateFailureDetail(snapshot)); btnUpdateNow.Content = "Open release page"; btnUpdateNow.IsEnabled = true; borderUpdateAlert.Visibility = Visibility.Visible; @@ -1187,12 +1189,18 @@ private void UpdateUpdateBanner() } } + 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; - txtUpdateGlyph.Text = failed ? "⚠" : "⬆"; - txtUpdateGlyph.Foreground = failed ? UpdateAmberBorder : UpdateBlueBorder; txtUpdateTitle.Foreground = failed ? UpdateAmberText : UpdateBlueText; txtUpdateDetail.Foreground = failed ? UpdateAmberText : UpdateBlueText; } From 15cece6828ababe2e983ecd5eec04183d29b4132 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 12:44:54 -0400 Subject: [PATCH 06/10] Re-check for updates daily while SimHub runs Rigs can leave SimHub running for weeks, so a launch-only check goes stale. A 24 h timer re-runs the check in-session; the handler re-reads EnableUpdateCheck so the About checkbox now takes effect live, and the timer is torn down (and its checks cancelled) in FinalizePlugin. Checkbox relabeled "Check for updates automatically". --- src/FanaBridge/FanatecPlugin.cs | 26 +++++++++++++++++++---- src/FanaBridge/FanatecPluginSettings.cs | 5 +++-- src/FanaBridge/UI/SettingsControl.xaml | 3 ++- src/FanaBridge/UI/SettingsControl.xaml.cs | 4 ++-- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/FanaBridge/FanatecPlugin.cs b/src/FanaBridge/FanatecPlugin.cs index 42a6c95e..23e29494 100644 --- a/src/FanaBridge/FanatecPlugin.cs +++ b/src/FanaBridge/FanatecPlugin.cs @@ -120,6 +120,7 @@ public Task ApplyUpdateAsync() private UpdateService _updateService; private CancellationTokenSource _updaterCts; private bool _updaterInitialized; + private System.Timers.Timer _updateRecheckTimer; /// /// When true, device instances skip all LED and display output so the @@ -570,6 +571,21 @@ private void InitializeUpdater() } }); } + + // Rigs can leave SimHub running for weeks, so a launch-only + // check goes stale — re-check daily while running. The handler + // re-reads the setting (live opt-out, no restart needed), and + // the service's serialization + terminal ReadyToRestart make a + // redundant fire a no-op. + _updateRecheckTimer = new System.Timers.Timer( + TimeSpan.FromHours(24).TotalMilliseconds) + { AutoReset = true }; + _updateRecheckTimer.Elapsed += (s, e) => + { + if (Settings?.EnableUpdateCheck == true) + _ = CheckForUpdatesAsync(); + }; + _updateRecheckTimer.Start(); } catch (Exception ex) { @@ -673,10 +689,12 @@ public void FinalizePlugin() { SimHub.Logging.Current.Info("FanaBridge: FinalizePlugin (final teardown)"); - // Stop any in-flight update work — the startup check AND manual - // checks/applies from the settings UI share this token. A swap that - // already reached its commit point still runs to completion - // (UpdateService stops honoring the token past the last safe point). + // Stop any in-flight update work — the startup check, the daily + // re-check, and manual checks/applies from the settings UI all + // share this token. A swap that already reached its commit point + // still runs to completion (UpdateService stops honoring the token + // past the last safe point). + try { _updateRecheckTimer?.Stop(); _updateRecheckTimer?.Dispose(); } catch { /* best-effort */ } try { _updaterCts?.Cancel(); } catch { /* best-effort */ } // Unpublish FIRST: device DataUpdate frames can still be in flight diff --git a/src/FanaBridge/FanatecPluginSettings.cs b/src/FanaBridge/FanatecPluginSettings.cs index ed2a0ad3..8c64ae6f 100644 --- a/src/FanaBridge/FanatecPluginSettings.cs +++ b/src/FanaBridge/FanatecPluginSettings.cs @@ -56,8 +56,9 @@ public class FanatecPluginSettings // ---- Updates ---- /// - /// Check GitHub for a newer FanaBridge release at startup (one API - /// request per SimHub launch). The manual "Check for updates" link in + /// Check GitHub for a newer FanaBridge release automatically: once at + /// startup, then every 24 h while SimHub runs (one API request per + /// check). Takes effect live. The manual "Check for updates" link in /// the settings UI works regardless. /// public bool EnableUpdateCheck { get; set; } = true; diff --git a/src/FanaBridge/UI/SettingsControl.xaml b/src/FanaBridge/UI/SettingsControl.xaml index 2f5b509b..33e7e22b 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml +++ b/src/FanaBridge/UI/SettingsControl.xaml @@ -282,7 +282,8 @@ diff --git a/src/FanaBridge/UI/SettingsControl.xaml.cs b/src/FanaBridge/UI/SettingsControl.xaml.cs index 77e3d029..39baede7 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml.cs +++ b/src/FanaBridge/UI/SettingsControl.xaml.cs @@ -1062,8 +1062,8 @@ private void ChkEnableTuning_Changed(object sender, RoutedEventArgs e) private void ChkEnableUpdateCheck_Changed(object sender, RoutedEventArgs e) { - // Takes effect on the next SimHub launch (the startup check runs - // once per process); the manual link works regardless. + // Live: the daily re-check timer re-reads the setting on each fire; + // the manual link works regardless. Plugin?.SaveSettings(); } From e782066eb5a394ca176edc50c1060da2a0b3395c Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 12:52:02 -0400 Subject: [PATCH 07/10] Tighten update UI copy Drop the redundant "you have x.y.z" from the banner headline (the installed version is already printed in About), and make the About status line report only manual-check outcomes: an available update says "see above" instead of duplicating the banner, and the in-progress / installed / failed states the banner owns leave it empty. --- src/FanaBridge/UI/SettingsControl.xaml.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/FanaBridge/UI/SettingsControl.xaml.cs b/src/FanaBridge/UI/SettingsControl.xaml.cs index 39baede7..97dbe779 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml.cs +++ b/src/FanaBridge/UI/SettingsControl.xaml.cs @@ -1135,10 +1135,13 @@ private void UpdateUpdateBanner() break; case UpdatePhase.UpdateAvailable: - txtUpdateCheckResult.Text = "Update available."; + // The About line reports the manual check's outcome and + // points at the banner — the actionable UI — rather than + // duplicating it at the bottom of the page. + txtUpdateCheckResult.Text = "FanaBridge " + release.Version + + " is available — see above."; StyleUpdateBanner(failed: false); - runUpdateHeadline.Text = "Update available: FanaBridge " + release.Version - + " — you have " + BuildIdentity.Version + "."; + runUpdateHeadline.Text = "Update available: FanaBridge " + release.Version; if (release.CanSelfInstall) { SetUpdateDetail(null); @@ -1156,7 +1159,10 @@ private void UpdateUpdateBanner() case UpdatePhase.Downloading: case UpdatePhase.Applying: - txtUpdateCheckResult.Text = "Installing update…"; + // 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…"); @@ -1166,7 +1172,7 @@ private void UpdateUpdateBanner() break; case UpdatePhase.ReadyToRestart: - txtUpdateCheckResult.Text = "Update installed — restart SimHub."; + txtUpdateCheckResult.Text = ""; StyleUpdateBanner(failed: false); runUpdateHeadline.Text = "Update installed — restart SimHub to finish updating to FanaBridge " + release?.Version + "."; @@ -1178,7 +1184,7 @@ private void UpdateUpdateBanner() break; case UpdatePhase.Failed: - txtUpdateCheckResult.Text = "Automatic update failed."; + txtUpdateCheckResult.Text = ""; StyleUpdateBanner(failed: true); runUpdateHeadline.Text = "Automatic update failed"; SetUpdateDetail(ComposeUpdateFailureDetail(snapshot)); From 1b6811e0574bef850c63ff9e9e23a5d400689819 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 12:53:47 -0400 Subject: [PATCH 08/10] Move the update banner into the About section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything update-related now shares one surface: installed version, the banner (offer / progress / restart / failure), the auto-check preference, and the manual check link. Accepted trade-off: on small windows the banner can sit below the fold. The "see above" pointer in the manual-check line is gone — the banner is adjacent. --- src/FanaBridge/UI/SettingsControl.xaml | 79 ++++++++++++----------- src/FanaBridge/UI/SettingsControl.xaml.cs | 8 +-- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/src/FanaBridge/UI/SettingsControl.xaml b/src/FanaBridge/UI/SettingsControl.xaml index 33e7e22b..c00a4a06 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml +++ b/src/FanaBridge/UI/SettingsControl.xaml @@ -9,43 +9,6 @@ - - - - - - - - - - Release notes - - - - - Update - - - - @@ -281,6 +244,48 @@ github.com/kelchm/FanaBridge + + + + + + + + + + + Release notes + + + + + Update + + + + Date: Sat, 8 Aug 2026 14:36:54 -0400 Subject: [PATCH 09/10] Two-column top row: DEVICE STATUS and ABOUT side by side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 50:50 grid split — About is short and the device chain does not need full width, and this keeps the update banner above the fold instead of at the bottom of the page. Experimental Features is now the last section, so it drops its trailing separator. Within About, the banner leads the section — directly under the heading and ahead of the version identity — so it opens with the actionable state when there is one. The row's own dividers replace the per-section separators: both sections set ShowSeparator="False", a vertical rule sits in the gutter between them, and one continuous horizontal rule runs under the whole row. Both rules copy SHSection's separator geometry (1px, 30% opacity, 20px template inset) and bind their brush to a section's SeparatorBrush so they track the SimHub theme. --- src/FanaBridge/UI/SettingsControl.xaml | 177 ++++++++++++++----------- 1 file changed, 102 insertions(+), 75 deletions(-) diff --git a/src/FanaBridge/UI/SettingsControl.xaml b/src/FanaBridge/UI/SettingsControl.xaml index c00a4a06..f59a5ab3 100644 --- a/src/FanaBridge/UI/SettingsControl.xaml +++ b/src/FanaBridge/UI/SettingsControl.xaml @@ -9,8 +9,21 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + Release notes + + + + + Update + + + + + + + + + github.com/kelchm/FanaBridge + + + + + + + + Check for updates now + + + + + + + + + + + @@ -210,8 +308,8 @@ - - + + - - - - - - - - github.com/kelchm/FanaBridge - - - - - - - - - - - - - Release notes - - - - - Update - - - - - - - - - Check for updates now - - - - - - - From a39e121bb10bd436c5c617e618d6caf5378719f3 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Sat, 8 Aug 2026 14:43:36 -0400 Subject: [PATCH 10/10] Changelog: automatic update checks and one-click updates --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 486fc345..650465d5 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