diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac0da66..49bc9c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,21 @@ All notable changes to Aperture Image Viewer are documented here. The format fol
## [Unreleased]
+## [0.8.1-beta1] - 2026-08-21
+
+### Added
+- **About + updater** on the hamburger menu — current version (installed/portable), GitHub
+ release check, Update / Update (prerelease) when a newer build is available, progress and
+ errors. Running from AppData swaps in place (Midget-style rename dance); a portable copy
+ downloads the new exe next to the current one.
+- **AppData install** — hamburger **Install Aperture on this PC…** copies to
+ `%LocalAppData%\Programs\Aperture\`, adds a Start menu shortcut (optional Desktop), and
+ **Uninstall…** reverses it. No `.md` file association — Aperture is not an editor.
+- **Start with Windows** — opt-in checkbox on the same Settings surface. Writes or deletes
+ `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Aperture`; off by default. After
+ install or update the Run value is rewritten to the AppData exe so logon does not keep a
+ Downloads path.
+
## [0.8.0-beta1] - 2026-07-14
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index 7245a98..2788bee 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -5,13 +5,17 @@ dates. Items are checked off as they ship (each lands a `CHANGELOG.md` entry whe
## Distribution & lifecycle
-- [ ] **Real installer & updater** — a proper Windows installer (Start-menu entry, clean uninstall,
- file associations where they make sense) to replace the bare framework-dependent single-file `.exe`,
- plus an in-app update check/apply so test users don't have to re-download by hand each beta.
+- [x] **AppData install + Start menu** — hamburger Install copies to
+ `%LocalAppData%\Programs\Aperture\Aperture.exe`, adds Start-menu (optional Desktop)
+ shortcuts, and Uninstall reverses it. In-app update check/apply lives on the hamburger
+ About surface: in-place swap when running from that copy, portable apply otherwise.
-- [ ] **Start with Windows + tray behavior** — an opt-in "launch at login" setting, and an option to
- close/minimize to the background / system tray instead of exiting, so Aperture can stay warm for
- instant opens. Both **off by default**.
+- [x] **Start with Windows** — opt-in "launch at login" checkbox on the hamburger Settings
+ surface (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`). Off by default. Survives
+ install/update by rewriting the Run value to the AppData exe.
+
+- [ ] **Tray behavior** — option to close/minimize to the background / system tray instead of
+ exiting, so Aperture can stay warm for instant opens. Off by default.
## Tags & metadata
diff --git a/src/Aperture.App/Aperture.App.csproj b/src/Aperture.App/Aperture.App.csproj
index 10f7884..81725b2 100644
--- a/src/Aperture.App/Aperture.App.csproj
+++ b/src/Aperture.App/Aperture.App.csproj
@@ -16,15 +16,22 @@
true
Assets\aperture.ico
Aperture
- 0.8.0-beta1
+ 0.8.1-beta1
Aperture Image Viewer
Aperture Image Viewer
A fast local image & video browser for Windows.
Funcular Labs
Funcular Labs
© 2026 Funcular Labs
+
+ false
+
+
+
+
diff --git a/src/Aperture.App/App.xaml.cs b/src/Aperture.App/App.xaml.cs
index b0b829c..f324862 100644
--- a/src/Aperture.App/App.xaml.cs
+++ b/src/Aperture.App/App.xaml.cs
@@ -2,6 +2,7 @@
using System.Windows;
using System.Windows.Threading;
using Aperture.App.Services;
+using Aperture.App.Updates;
using Aperture.App.ViewModels;
using Aperture.Core.Library;
using Aperture.Core.Storage;
@@ -30,8 +31,19 @@ protected override void OnStartup(StartupEventArgs e)
if (string.IsNullOrWhiteSpace(overrideDir))
LegacyDataMigration.Run(AperturePaths.DefaultDataDir, AperturePaths.LegacyReelDataDir);
+ // A "move" install launches the AppData copy with --finish-move
+ // so it can delete the original once that process has exited.
+ var args = e.Args;
+ for (var i = 0; i < args.Length; i++)
+ {
+ if (args[i] == "--finish-move" && i + 1 < args.Length)
+ RegistrationService.FinishMove(args[++i]);
+ }
+
_library = new LibraryService(dataDir);
var thumbnails = new ThumbnailService(_library);
+ UpdateService.CleanupOldBinaries();
+ RegistrationService.RefreshAutostartToInstalledCopy();
_viewModel = new MainViewModel(_library, thumbnails);
var window = new MainWindow { DataContext = _viewModel };
diff --git a/src/Aperture.App/MainWindow.xaml b/src/Aperture.App/MainWindow.xaml
index 792bf47..08e0a9a 100644
--- a/src/Aperture.App/MainWindow.xaml
+++ b/src/Aperture.App/MainWindow.xaml
@@ -353,23 +353,49 @@
+ StaysOpen="False" AllowsTransparency="True" HorizontalOffset="-180"
+ Opened="OnSettingsOpened">
+
-
+
+ FontWeight="SemiBold" FontSize="13" TextWrapping="Wrap" />
+
+
+
+
+
+
+
@@ -397,7 +423,11 @@
Margin="22,2,0,10" />
+ Margin="0,0,0,10" />
+
+
@@ -423,11 +453,24 @@
Foreground="{StaticResource Ink}"
ToolTip="Merge tags & notes from a JSON file (upsert; remaps folders by alias)." />
+
+
+
diff --git a/src/Aperture.App/MainWindow.xaml.cs b/src/Aperture.App/MainWindow.xaml.cs
index a61a323..0419d34 100644
--- a/src/Aperture.App/MainWindow.xaml.cs
+++ b/src/Aperture.App/MainWindow.xaml.cs
@@ -619,6 +619,9 @@ private void OnGridSelectionChanged(object sender, System.Windows.Controls.Selec
/// Closes the settings/overflow popup after Refresh runs (it opens no dialog to steal focus).
private void OnRefreshMenuClick(object sender, RoutedEventArgs e) => SettingsToggle.IsChecked = false;
+ /// Hamburger opened: refresh Start-with-Windows from the Run key and check GitHub for updates.
+ private void OnSettingsOpened(object sender, EventArgs e) => ViewModel?.OnSettingsOpened();
+
///
/// Explorer-style right-click: clicking a tile that isn't in the current selection
/// selects just it; clicking within the selection keeps it, so the context menu
diff --git a/src/Aperture.App/RegistrationService.cs b/src/Aperture.App/RegistrationService.cs
new file mode 100644
index 0000000..a3ef489
--- /dev/null
+++ b/src/Aperture.App/RegistrationService.cs
@@ -0,0 +1,225 @@
+using System.IO;
+using System.Text.Json;
+using Aperture.Core.Startup;
+
+namespace Aperture.App;
+
+///
+/// Per-user Windows integration for Aperture: copy to AppData and create Start-menu
+/// / Desktop shortcuts. No file association — Aperture is a browser, not an editor.
+///
+/// Everything writes to LocalAppData / per-user Start Menu — no admin.
+///
+internal static class RegistrationService
+{
+ private const string DisplayName = "Aperture";
+ private const string ExeCanonicalName = "Aperture.exe";
+ private const string ShortcutLinkName = "Aperture.lnk";
+ private const string InstallInfoName = "install-info.json";
+
+ public static string CurrentExePath =>
+ Environment.ProcessPath ?? throw new InvalidOperationException("Cannot determine current exe path.");
+
+ public static string AppDataInstallDir => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Programs", "Aperture");
+
+ public static string AppDataInstallExe => Path.Combine(AppDataInstallDir, ExeCanonicalName);
+
+ public static string StartMenuLinkPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.Programs), ShortcutLinkName);
+
+ public static string DesktopLinkPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), ShortcutLinkName);
+
+ private static string InstallInfoPath => Path.Combine(AppDataInstallDir, InstallInfoName);
+
+ public static bool IsInstalledToAppData() => File.Exists(AppDataInstallExe);
+ public static bool HasStartMenuShortcut() => File.Exists(StartMenuLinkPath);
+ public static bool HasDesktopShortcut() => File.Exists(DesktopLinkPath);
+
+ /// Where the exe originally came from, and whether we moved (deleted) it.
+ public sealed record InstallInfo(string? OriginalPath, bool Moved);
+
+ public static bool IsRunningFromAppDataInstall()
+ {
+ try
+ {
+ var here = Path.GetFullPath(CurrentExePath);
+ return string.Equals(here, Path.GetFullPath(AppDataInstallExe),
+ StringComparison.OrdinalIgnoreCase);
+ }
+ catch { return false; }
+ }
+
+ // ===== AppData install + Start Menu =====
+
+ /// Copy the current exe to %LocalAppData%\Programs\Aperture.
+ public static string InstallToAppData()
+ {
+ Directory.CreateDirectory(AppDataInstallDir);
+ var source = CurrentExePath;
+ // If we're already running from the target location, skip the copy —
+ // the file is locked anyway.
+ if (!string.Equals(Path.GetFullPath(source), Path.GetFullPath(AppDataInstallExe),
+ StringComparison.OrdinalIgnoreCase))
+ {
+ File.Copy(source, AppDataInstallExe, overwrite: true);
+ }
+ return AppDataInstallExe;
+ }
+
+ public static void UninstallFromAppData()
+ {
+ try
+ {
+ if (Directory.Exists(AppDataInstallDir))
+ Directory.Delete(AppDataInstallDir, recursive: true);
+ }
+ catch { /* usually file-in-use; user needs to close first */ }
+ }
+
+ private static void CreateShortcut(string linkPath, string targetExe)
+ {
+ try
+ {
+ var t = Type.GetTypeFromProgID("WScript.Shell");
+ if (t is null) return;
+ dynamic shell = Activator.CreateInstance(t)!;
+ dynamic link = shell.CreateShortcut(linkPath);
+ link.TargetPath = targetExe;
+ link.WorkingDirectory = Path.GetDirectoryName(targetExe) ?? string.Empty;
+ link.IconLocation = targetExe + ",0";
+ link.Description = "Aperture — a fast local image & video browser.";
+ link.Save();
+ }
+ catch { /* best-effort — shortcuts are a bonus, not critical */ }
+ }
+
+ public static void CreateStartMenuShortcut(string targetExe) => CreateShortcut(StartMenuLinkPath, targetExe);
+ public static void CreateDesktopShortcut(string targetExe) => CreateShortcut(DesktopLinkPath, targetExe);
+
+ public static void RemoveStartMenuShortcut()
+ {
+ try { if (File.Exists(StartMenuLinkPath)) File.Delete(StartMenuLinkPath); } catch { }
+ }
+
+ public static void RemoveDesktopShortcut()
+ {
+ try { if (File.Exists(DesktopLinkPath)) File.Delete(DesktopLinkPath); } catch { }
+ }
+
+ // ===== Original-location memorialization + move / restore =====
+
+ /// Record where the exe came from (so uninstall can offer to restore it).
+ public static void SaveInstallInfo(string? originalPath, bool moved)
+ {
+ try
+ {
+ Directory.CreateDirectory(AppDataInstallDir);
+ File.WriteAllText(InstallInfoPath,
+ JsonSerializer.Serialize(new InstallInfo(originalPath, moved)));
+ }
+ catch { /* best-effort */ }
+ }
+
+ public static InstallInfo? ReadInstallInfo()
+ {
+ try
+ {
+ if (!File.Exists(InstallInfoPath)) return null;
+ return JsonSerializer.Deserialize(File.ReadAllText(InstallInfoPath));
+ }
+ catch { return null; }
+ }
+
+ /// Copy the installed exe back to .
+ public static bool RestoreToOriginal(string destPath)
+ {
+ try
+ {
+ if (!File.Exists(AppDataInstallExe)) return false;
+ var dir = Path.GetDirectoryName(destPath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ File.Copy(AppDataInstallExe, destPath, overwrite: true);
+ return true;
+ }
+ catch { return false; }
+ }
+
+ ///
+ /// Delete the original downloaded exe after a "move" install. Called by the
+ /// freshly-launched AppData copy (passed --finish-move <path>);
+ /// the original process needs a moment to exit and release the file lock.
+ ///
+ public static void FinishMove(string originalPath)
+ {
+ try
+ {
+ // Never delete the installed copy, even if a caller passes it by mistake.
+ if (string.Equals(Path.GetFullPath(originalPath), Path.GetFullPath(AppDataInstallExe),
+ StringComparison.OrdinalIgnoreCase))
+ return;
+ }
+ catch { return; }
+
+ _ = Task.Run(async () =>
+ {
+ for (var attempt = 0; attempt < 40; attempt++) // ~10s total
+ {
+ try
+ {
+ if (!File.Exists(originalPath)) return;
+ File.Delete(originalPath);
+ return;
+ }
+ catch { await Task.Delay(250); }
+ }
+ });
+ }
+
+ ///
+ /// After copying to AppData: if Start with Windows is on (Run key points at
+ /// the download, or already at the install), rewrite it to the installed exe.
+ ///
+ public static void RetargetStartWithWindows(string fromExe, string installedExe)
+ {
+ try
+ {
+ WindowsStartup.TryRetargetIfPointsAt(new CurrentUserRunKeyStore(), fromExe, installedExe);
+ }
+ catch { /* install already landed */ }
+ }
+
+ ///
+ /// When this process IS the AppData copy and a Run value exists (any path),
+ /// rewrite it to the canonical installed exe. Covers the case where install
+ /// copied the file but died before retargeting, so logon would still fire a
+ /// Downloads path.
+ ///
+ public static void RefreshAutostartToInstalledCopy()
+ {
+ if (!IsRunningFromAppDataInstall()) return;
+ try
+ {
+ var store = new CurrentUserRunKeyStore();
+ if (WindowsStartup.HasValue(store))
+ WindowsStartup.TrySetEnabled(store, AppDataInstallExe, enabled: true);
+ }
+ catch { /* best effort */ }
+ }
+
+ ///
+ /// Drop the Run value if it still points at the AppData copy being removed.
+ ///
+ public static void ClearStartWithWindowsIfPointsAtInstall()
+ {
+ try
+ {
+ var store = new CurrentUserRunKeyStore();
+ if (WindowsStartup.IsEnabled(store, AppDataInstallExe))
+ WindowsStartup.TrySetEnabled(store, AppDataInstallExe, enabled: false);
+ }
+ catch { /* uninstall already happened */ }
+ }
+}
diff --git a/src/Aperture.App/Updates/ReleaseFeed.cs b/src/Aperture.App/Updates/ReleaseFeed.cs
new file mode 100644
index 0000000..ea6530b
--- /dev/null
+++ b/src/Aperture.App/Updates/ReleaseFeed.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+
+namespace Aperture.App.Updates;
+
+/// One published GitHub release relevant to updating.
+internal sealed record ReleaseInfo(
+ string Tag,
+ UpdateVersion Version,
+ bool Prerelease,
+ string HtmlUrl,
+ string? AssetName,
+ string? AssetUrl,
+ long AssetSize);
+
+/// What an update check found: the newest stable and the newest
+/// prerelease (either may be null when none exists or parsing failed).
+internal sealed record UpdateCheck(ReleaseInfo? Stable, ReleaseInfo? PrereleaseRelease);
+
+///
+/// Pure parsing/selection over the GitHub releases JSON — separated from the HTTP
+/// so it's unit-testable. Drafts are ignored; the newest stable and newest
+/// prerelease are chosen by VERSION order (not list order), and only releases
+/// carrying the single-file win-x64 asset the release workflow publishes count.
+///
+internal static class ReleaseFeed
+{
+ // Aperture publishes Aperture--win-x64.exe (Midget's suffix is -win-x64-net10.exe).
+ private const string AssetSuffix = "-win-x64.exe";
+
+ public static UpdateCheck Select(string releasesJson)
+ {
+ var releases = new List();
+ using var doc = JsonDocument.Parse(releasesJson);
+ foreach (var el in doc.RootElement.EnumerateArray())
+ {
+ if (el.TryGetProperty("draft", out var d) && d.GetBoolean()) continue;
+ var tag = el.TryGetProperty("tag_name", out var t) ? t.GetString() : null;
+ var version = UpdateVersion.Parse(tag);
+ if (tag is null || version is null) continue;
+ // Either signal is enough. The flag is set by hand at publish time and
+ // has been wrong before (v0.2.0-beta2 is flagged stable on GitHub), and
+ // mistaking a beta for a stable release is the one direction that
+ // actually hurts — it would be offered to everyone.
+ var pre = (el.TryGetProperty("prerelease", out var p) && p.GetBoolean())
+ || version.IsPrerelease;
+ var html = el.TryGetProperty("html_url", out var h) ? h.GetString() ?? "" : "";
+
+ string? assetName = null, assetUrl = null;
+ long assetSize = 0;
+ if (el.TryGetProperty("assets", out var assets))
+ {
+ foreach (var a in assets.EnumerateArray())
+ {
+ var name = a.TryGetProperty("name", out var n) ? n.GetString() : null;
+ if (name is null || !name.EndsWith(AssetSuffix, StringComparison.OrdinalIgnoreCase)) continue;
+ assetName = name;
+ assetUrl = a.TryGetProperty("browser_download_url", out var u) ? u.GetString() : null;
+ assetSize = a.TryGetProperty("size", out var sz) ? sz.GetInt64() : 0;
+ break;
+ }
+ }
+ if (assetUrl is null) continue; // nothing to install from this release
+ releases.Add(new ReleaseInfo(tag, version, pre, html, assetName, assetUrl, assetSize));
+ }
+
+ var stable = releases.Where(r => !r.Prerelease).OrderByDescending(r => r.Version).FirstOrDefault();
+ var prerelease = releases.Where(r => r.Prerelease).OrderByDescending(r => r.Version).FirstOrDefault();
+ return new UpdateCheck(stable, prerelease);
+ }
+}
diff --git a/src/Aperture.App/Updates/UpdateOffer.cs b/src/Aperture.App/Updates/UpdateOffer.cs
new file mode 100644
index 0000000..d8910e5
--- /dev/null
+++ b/src/Aperture.App/Updates/UpdateOffer.cs
@@ -0,0 +1,68 @@
+namespace Aperture.App.Updates;
+
+///
+/// Decides what the About surface should actually offer.
+///
+/// The releases feed keeps reporting the newest prerelease forever, which is only
+/// interesting while it leads. Once a stable release catches up — 0.6.0-beta2 next
+/// to a shipped 0.6.2 — showing it is worse than showing nothing: it reads as a
+/// newer, more adventurous build when it is in fact older code that has already
+/// been superseded. So a prerelease is offered only while it leads BOTH the
+/// running version and the newest stable.
+///
+internal static class UpdateOffer
+{
+ /// Is this prerelease still ahead of everything else?
+ public static bool ShowPrerelease(ReleaseInfo? prerelease, ReleaseInfo? stable, UpdateVersion? current)
+ {
+ if (prerelease is null) return false;
+ // Nothing to measure against at all — don't push a prerelease on a guess.
+ // (Unlike a stable release, where offering the newest is a safe default.)
+ if (current is null && stable is null) return false;
+ // Not newer than what's running — nothing to offer.
+ if (current is not null && prerelease.Version.CompareTo(current) <= 0) return false;
+ // Superseded by a stable release: taking it would be a downgrade in
+ // everything but the version label.
+ if (stable is not null && prerelease.Version.CompareTo(stable.Version) <= 0) return false;
+ return true;
+ }
+
+ ///
+ /// Is this stable release worth offering? Yes when it's newer than what's
+ /// running — and also when we can't tell what's running, because the failure
+ /// that must never happen is a real update going unoffered. Re-installing the
+ /// version you already have costs a download; being silently stranded on an old
+ /// build costs the fix you were waiting for.
+ ///
+ public static bool ShowStableUpdate(ReleaseInfo? stable, UpdateVersion? current) =>
+ stable is not null && (current is null || stable.Version.CompareTo(current) > 0);
+
+ ///
+ /// Does this instance need *restarting* rather than updating?
+ ///
+ /// Two ways to be sure, and the second is the one that's easy to miss:
+ ///
+ /// - the exe on disk already satisfies what's being offered, so there is nothing
+ /// to download or install; or
+ /// - the exe on disk is ahead of the version we are *running*, whatever is being
+ /// offered. That is the real precondition for the failure this exists to
+ /// prevent: it means another window renamed our image out from under us, so
+ /// the swap's `File.Move(target, target + ".old")` would land on a name that
+ /// already exists and is locked — by us.
+ ///
+ /// Checking only the first would miss a window left open across two releases:
+ /// disk at 0.6.4, this process still on 0.6.3, and 0.6.5 on offer. Nothing is
+ /// "already updated" from the offer's point of view, yet the swap still cannot
+ /// work.
+ ///
+ /// Unknown answers false — "let it try and report honestly" beats refusing an
+ /// update on a guess.
+ ///
+ public static bool NeedsRestartNotUpdate(
+ UpdateVersion? onDisk, UpdateVersion? wanted, UpdateVersion? running)
+ {
+ if (onDisk is null) return false;
+ if (wanted is not null && onDisk.CompareTo(wanted) >= 0) return true;
+ return running is not null && onDisk.CompareTo(running) > 0;
+ }
+}
diff --git a/src/Aperture.App/Updates/UpdateService.cs b/src/Aperture.App/Updates/UpdateService.cs
new file mode 100644
index 0000000..6485528
--- /dev/null
+++ b/src/Aperture.App/Updates/UpdateService.cs
@@ -0,0 +1,932 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using Aperture.Core.Startup;
+
+namespace Aperture.App.Updates;
+
+///
+/// Checks GitHub for newer releases, downloads the signed single-file exe, and
+/// swaps it in.
+///
+/// Two install shapes, mirroring the Windows-integration feature:
+/// - **Installed** (running from the AppData install dir): swap in place via the
+/// rename dance (a running exe can be renamed but not overwritten), refresh
+/// shortcuts and the Start-with-Windows Run key, restart. Path stays canonical, so shortcuts
+/// keep working even if the refresh fails.
+/// - **Portable** (running from anywhere else): download the versioned exe into
+/// the SAME directory the current instance runs from, launch it, exit. The old
+/// exe stays behind as a file the user can delete — nothing is modified except
+/// adding one file, which is what a portable app should do.
+///
+/// The downloaded file must carry a valid Authenticode signature whose subject is
+/// Funcular Labs before it is ever started or copied — a failed HTTPS download,
+/// a tampered asset, or a wrong file simply aborts the update.
+///
+internal static class UpdateService
+{
+ private const string ReleasesApi =
+ "https://api.github.com/repos/FuncularLabs/Aperture/releases?per_page=20";
+
+ private static readonly HttpClient Http = CreateClient();
+
+ private static HttpClient CreateClient()
+ {
+ var c = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
+ // GitHub's API requires a User-Agent.
+ c.DefaultRequestHeaders.UserAgent.ParseAdd("Aperture-Updater");
+ c.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
+ return c;
+ }
+
+ /// Newest stable + newest prerelease, or null when offline/rate-limited.
+ public static async Task CheckAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(30));
+ var json = await Http.GetStringAsync(ReleasesApi, cts.Token);
+ return ReleaseFeed.Select(json);
+ }
+ catch { return null; } // offline, rate-limited, DNS… — the caller shows "couldn't check"
+ }
+
+ /// Download a release's exe to a temp path (verified separately).
+ public static async Task DownloadAsync(ReleaseInfo release)
+ {
+ if (release.AssetUrl is null || release.AssetName is null) return null;
+ try
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "Aperture-update");
+ Directory.CreateDirectory(dir);
+ // AssetName comes from the release JSON — never let it steer the path.
+ var dest = Path.Combine(dir, Path.GetFileName(release.AssetName));
+ if (File.Exists(dest)) File.Delete(dest);
+
+ using var response = await Http.GetAsync(release.AssetUrl, HttpCompletionOption.ResponseHeadersRead);
+ response.EnsureSuccessStatusCode();
+ await using (var fs = File.Create(dest))
+ await response.Content.CopyToAsync(fs);
+
+ // A truncated download must not proceed to the signature/swap steps.
+ if (release.AssetSize > 0 && new FileInfo(dest).Length != release.AssetSize)
+ {
+ File.Delete(dest);
+ return null;
+ }
+ return dest;
+ }
+ catch { return null; }
+ }
+
+ // The identity gate. Azure Trusted Signing issues short-lived LEAF certs that
+ // rotate every release, so the leaf thumbprint can't be pinned. Two things are
+ // stable and together constitute a real identity check (not a spoofable subject
+ // string): the leaf's Organization, and the chain root — Microsoft only issues a
+ // cert under this root with O="Funcular Labs, Inc." after verifying that identity.
+ private const string ExpectedOrg = "Funcular Labs, Inc.";
+ private const string TrustedRootThumbprint = "F40042E2E5F7E8EF8189FED15519AECE42C3BFA2"; // MS Identity Verification Root CA 2020
+
+ ///
+ /// True only when the file carries a valid embedded Authenticode signature
+ /// (WinVerifyTrust: hash + trust chain), the signer's Organization is exactly
+ /// Funcular Labs, Inc., the chain roots at Microsoft's identity-verification
+ /// root, and no chain cert is known-revoked.
+ ///
+ public static bool VerifySignature(string filePath, out string signer)
+ {
+ signer = "";
+ try
+ {
+ if (WinVerifyTrustFile(filePath) != 0) return false; // invalid/untrusted/tampered
+
+ // Extract the embedded SIGNER cert. CreateFromSignedFile is flagged
+ // SYSLIB0057, but its steer (X509CertificateLoader) only loads certificate
+ // *files* — there is no non-obsolete managed API to pull a signer out of a
+ // signed PE. So use it, then re-load the raw bytes via the non-obsolete
+ // loader to hold an X509Certificate2 without the obsolete file ctor.
+#pragma warning disable SYSLIB0057
+ var signed = X509Certificate.CreateFromSignedFile(filePath);
+#pragma warning restore SYSLIB0057
+ using var leaf = X509CertificateLoader.LoadCertificate(signed.GetRawCertData());
+ signer = leaf.Subject;
+
+ // Organization must be EXACTLY ours (not a Contains — "Funcular Labs Fan
+ // Club" and the like must not pass).
+ if (!OrganizationIs(leaf, ExpectedOrg)) return false;
+
+ using var chain = new X509Chain();
+ chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
+ chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
+ // WinVerifyTrust already established trust; here we only need the chain to
+ // read the root and to surface a definitive revocation. Transient CRL/OCSP
+ // unavailability must NOT block a legitimate update, so those are ignored;
+ // a genuine "Revoked" is not.
+ chain.ChainPolicy.VerificationFlags = X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown
+ | X509VerificationFlags.IgnoreEndRevocationUnknown
+ | X509VerificationFlags.IgnoreRootRevocationUnknown;
+ chain.Build(leaf);
+
+ var rootPinned = false;
+ foreach (var el in chain.ChainElements)
+ {
+ foreach (var st in el.ChainElementStatus)
+ if (st.Status.HasFlag(X509ChainStatusFlags.Revoked)) return false;
+ if (string.Equals(el.Certificate.Thumbprint, TrustedRootThumbprint, StringComparison.OrdinalIgnoreCase))
+ rootPinned = true;
+ }
+ return rootPinned;
+ }
+ catch { return false; }
+ }
+
+ private static bool OrganizationIs(X509Certificate2 cert, string org)
+ {
+ foreach (var rdn in cert.SubjectName.EnumerateRelativeDistinguishedNames())
+ {
+ if (rdn.GetSingleElementType().FriendlyName is "O" or "Organization" ||
+ rdn.GetSingleElementType().Value == "2.5.4.10") // OID for O
+ {
+ return string.Equals(rdn.GetSingleElementValue(), org, StringComparison.Ordinal);
+ }
+ }
+ return false;
+ }
+
+ /// Where the current process's exe lives (single-file publish safe).
+ public static string CurrentExePath => Environment.ProcessPath
+ ?? Process.GetCurrentProcess().MainModule?.FileName
+ ?? throw new InvalidOperationException("Cannot determine the running exe path.");
+
+ public static bool IsInstalled()
+ {
+ try { return RegistrationService.IsRunningFromAppDataInstall(); }
+ catch { return false; }
+ }
+
+ ///
+ /// The version of the exe sitting at right now, which
+ /// is NOT necessarily the version this process is running.
+ ///
+ /// Windows lets a running exe be renamed, and the installed update flow does
+ /// exactly that — but Environment.ProcessPath and MainModule.FileName keep
+ /// reporting the original path, so after another window updates, this path
+ /// resolves to the NEW exe while we go on executing the old image from
+ /// "…exe.old". That asymmetry is what makes this check possible.
+ ///
+ public static UpdateVersion? VersionOnDisk()
+ {
+ try
+ {
+ var info = FileVersionInfo.GetVersionInfo(CurrentExePath);
+ return UpdateVersion.Parse(info.ProductVersion ?? info.FileVersion);
+ }
+ catch { return null; }
+ }
+
+ ///
+ /// True when the exe on disk already satisfies — i.e.
+ /// some other window has already applied this update and there is nothing to
+ /// download. The caller should tell the user to restart rather than attempt a
+ /// swap, which would fail with a bare "Cannot create a file when that file
+ /// already exists" (the rename target is this process's own locked image).
+ ///
+ public static bool AlreadyUpdatedOnDisk(
+ UpdateVersion wanted, UpdateVersion? running, out UpdateVersion? onDisk)
+ {
+ onDisk = VersionOnDisk();
+ return UpdateOffer.NeedsRestartNotUpdate(onDisk, wanted, running);
+ }
+
+ ///
+ /// Installed flow: in-place swap at the canonical AppData path, refresh
+ /// shortcuts and the Start-with-Windows Run key, then restart. Throws with a readable message on
+ /// failure (nothing destructive happens before the copy succeeds).
+ ///
+ /// Arguments for the restarted instance. Each is
+ /// passed via ArgumentList (no quoting bugs on paths with spaces).
+ public static void ApplyInstalledAndRestart(string verifiedNewExe, IReadOnlyList? relaunchArgs = null)
+ {
+ var target = CurrentExePath;
+ var dir = Path.GetDirectoryName(target)!;
+ // Per-process staging name: two windows updating within the same second would
+ // otherwise share one file, and the cleanup below would delete a sibling's
+ // copy out from under its own rename.
+ var staged = Path.Combine(dir, $"{StagedPrefix}-{Environment.ProcessId}.exe");
+
+ // Do the slow cross-volume copy from %TEMP% BEFORE touching the running exe,
+ // so the only window where `target` is absent spans two fast same-volume
+ // renames rather than a full copy. Clean up after ourselves if it fails
+ // partway — CleanupOldBinaries would eventually get it, but only on a launch
+ // whose process id happens not to collide, and a half-written 6.5 MB file
+ // shouldn't wait for that.
+ try
+ {
+ File.Copy(verifiedNewExe, staged, overwrite: true);
+ }
+ catch
+ {
+ try { if (File.Exists(staged)) File.Delete(staged); } catch { /* best effort */ }
+ throw;
+ }
+
+ // The chooser, not one chosen name: a sibling can park at `…exe.old` while
+ // those 6.5 MB are being written, and even after the choice moved down here
+ // two windows can still both be told the plain name is free. Re-deciding per
+ // attempt is what actually settles it.
+ SwapInPlace(target, staged, () => ChooseParkingName(target));
+
+ // Same canonical path, so these are refreshes rather than repairs — but the
+ // instruction is to point shortcuts and the Run key at the new version.
+ try
+ {
+ if (RegistrationService.HasStartMenuShortcut()) RegistrationService.CreateStartMenuShortcut(target);
+ if (RegistrationService.HasDesktopShortcut()) RegistrationService.CreateDesktopShortcut(target);
+ WindowsStartup.TryRetargetIfPointsAt(new CurrentUserRunKeyStore(), target, target);
+ }
+ catch { /* the swap succeeded; a shortcut refresh failure isn't fatal */ }
+
+ // The swap is done and the shortcuts point at it, so the update HAS happened —
+ // but a sibling parking the file in this same instant makes this throw "The
+ // system cannot find the file specified", and reporting that as a failed update
+ // would be wrong twice over: it succeeded, and the sentence names no file.
+ try
+ {
+ var psi = new ProcessStartInfo(target) { UseShellExecute = true };
+ foreach (var a in relaunchArgs ?? []) psi.ArgumentList.Add(a);
+ Process.Start(psi);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ "Aperture is updated, but this window couldn't start the new " +
+ "version — most likely another window was busy with its own update at " +
+ "that moment. Close this window and start Aperture again from " +
+ "your shortcut or the Start menu.", ex);
+ }
+ }
+
+ ///
+ /// Where to park the exe we're replacing.
+ ///
+ /// Normally `…exe.old`. But that name may already exist AND be undeletable,
+ /// because it is the running image of a window that was open during an earlier
+ /// update — and then the rename onto it fails with "Cannot create a file when
+ /// that file already exists", which is the failure this release exists to stop
+ /// showing people. That window can't be detected by version (this instance
+ /// genuinely does need the update it is asking for), so the answer is simply not
+ /// to collide: park at `…exe.old-1234` instead. The startup sweep reclaims both
+ /// shapes once their owners exit.
+ ///
+ internal static string ChooseParkingName(string target)
+ {
+ // Take the first name that is free, or that we can free. Process ids get
+ // reused, so `…old-` can be occupied by a window still executing an
+ // image an *earlier* holder of this pid parked there — assuming it's
+ // available is how the original collision comes back by another route.
+ foreach (var candidate in ParkingCandidates(target + ".old"))
+ {
+ // Directory.Exists as well as File.Exists: a *directory* sitting at the
+ // name is not free either, and File.Exists alone would call it free three
+ // times over and then blame a race that isn't happening.
+ if (!File.Exists(candidate) && !Directory.Exists(candidate)) return candidate;
+ try { File.Delete(candidate); return candidate; } // stale; now free
+ catch { /* someone is running it, or it isn't a file — try the next name */ }
+ }
+ // Everything probed is taken, which shouldn't happen. Returning the plain name
+ // here would hand back one just proven undeletable — re-delivering the exact
+ // error this release exists to remove. A timestamped name has no such history:
+ // it is very likely free, and it still matches the `.old*` sweep glob.
+ return $"{target}.old-{Environment.ProcessId}-{DateTime.UtcNow.Ticks:x}";
+ }
+
+ private static IEnumerable ParkingCandidates(string preferred)
+ {
+ yield return preferred;
+ var pid = Environment.ProcessId;
+ yield return $"{preferred}-{pid}";
+ for (var n = 1; n <= 20; n++) yield return $"{preferred}-{pid}-{n}";
+ }
+
+ ///
+ /// Rename the running exe out of the way, taking a fresh name each attempt.
+ /// Returns the name it landed on.
+ ///
+ /// Retried when waiting could plausibly change the answer: a sibling mid-swap (the
+ /// two codes below) or something holding the file open. A denied rename is caught
+ /// but not retried, only rephrased — see IsParkFailureWeExplain, which has the last
+ /// word on both questions. The two codes:
+ ///
+ /// - ERROR_ALREADY_EXISTS: a sibling took the name between the probe and the
+ /// rename. A different name works, so re-decide.
+ /// - ERROR_FILE_NOT_FOUND: `target` is momentarily absent because a sibling has
+ /// parked it and not yet moved its own copy in. This is the *more likely* of the
+ /// two in a two-window race, and the same name works once that lands.
+ ///
+ /// A locked target is retried too — see IsParkFailureWeExplain, which also has the
+ /// last word on which failures get their text replaced. A full disk and the rest
+ /// fail identically next time and keep their own, which does say something.
+ ///
+ private static string ParkTarget(string target, Func chooseOld)
+ {
+ for (var attempt = 1; ; attempt++)
+ {
+ var old = chooseOld();
+ try { if (File.Exists(old)) File.Delete(old); } catch { /* replaced below */ }
+ try
+ {
+ File.Move(target, old); // legal while running; we keep our image
+ return old;
+ }
+ catch (Exception ex) when (IsParkFailureWeExplain(ex))
+ {
+ if (ShouldRetryPark(ex, attempt)) { Thread.Sleep(RetryPause); continue; }
+ throw new InvalidOperationException(ParkFailureMessage(target, ex), ex);
+ }
+ }
+ }
+
+ ///
+ /// Failures of the first rename that we take responsibility for explaining, rather
+ /// than letting Windows' own text through.
+ ///
+ /// Both arms matter. The IOException arm is the sibling mid-swap. The other is a
+ /// denied rename, which File.Move raises as UnauthorizedAccessException — NOT an
+ /// IOException — so a filter that only named IOException would let the one message
+ /// with no path in it ("Access to the path is denied") straight through to a dialog.
+ ///
+ /// A sharing violation is in for the same reason as the denial, and I had this
+ /// wrong: its text — "The process cannot access the file because it is being used
+ /// by another process" — names no file either. File.Move's two-argument overload
+ /// passes no path to the Win32 error translator, so nothing it raises here carries
+ /// one. It is also common at THIS rename specifically, because InstallToAppData
+ /// holds the destination with neither read nor delete sharing for as long as it
+ /// takes to copy 6.5 MB.
+ ///
+ /// A full disk and the rest keep their own text, which does say something specific.
+ ///
+ internal static bool IsParkFailureWeExplain(Exception ex)
+ => ex is UnauthorizedAccessException
+ || (ex is IOException io && (IsSiblingMidSwap(io) || IsSharingViolation(io)));
+
+ ///
+ /// Whether another attempt could change the answer. A denial can't: the same call
+ /// will be denied again, and spending the full park budget on it only adds a second
+ /// of frozen UI before the same report.
+ ///
+ internal static bool ShouldRetryPark(Exception ex, int attempt)
+ => ex is IOException io && (IsSiblingMidSwap(io) || IsSharingViolation(io))
+ && attempt < ParkAttempts;
+
+ ///
+ /// What to tell someone whose update couldn't move the running program aside.
+ ///
+ /// A pure function of the failure and whether a program file is there, because the
+ /// state that produces the first case can't be staged in-process — it needs a real
+ /// ACL denial or a security product — so the routing is what gets pinned. The rule
+ /// each arm exists for: never hand over the bare Win32 text (the denial's does not
+ /// even name a path), and never blame a race that hasn't been established.
+ ///
+ internal static string ParkFailureMessage(string target, Exception ex) => ex switch
+ {
+ UnauthorizedAccessException =>
+ $"Windows refused to rename {target}, so the update can't be applied. That is " +
+ "usually a security product guarding the folder, or the folder's own " +
+ "permissions — not another Aperture window.",
+ // Before the "another window" arm: something holding the file is not the same
+ // as something racing us for a name, and the remedy is different.
+ IOException sharing when IsSharingViolation(sharing) =>
+ $"Something else has {target} open, so it couldn't be moved aside and the " +
+ "update wasn't applied. Nothing has changed. That is usually a virus scanner, " +
+ "or another copy of Aperture being installed at the same time — either " +
+ "way it passes, so trying again shortly normally works.",
+ _ when File.Exists(target) =>
+ "Couldn't move the running program out of the way — another Aperture " +
+ "window kept getting there first. Close the other windows and try the update " +
+ "again.",
+ _ =>
+ $"There is no Aperture program file at {target} to update. Another " +
+ "window may be partway through its own update — if so, wait a moment and try " +
+ "again. Otherwise reinstall from the website, or run the copy you have from " +
+ "wherever it actually lives.",
+ };
+
+ ///
+ /// Attempts at the first rename. More than the recovery below gets, deliberately:
+ /// the sibling this waits for may itself be in a recovery that holds the gap for
+ /// ~600ms, and giving up in 300ms would announce a missing program file while the
+ /// window next door is putting one back.
+ ///
+ private const int ParkAttempts = 8;
+
+ /// Attempts at each of the two recovery renames.
+ private const int RecoveryAttempts = 3;
+
+ ///
+ /// Long enough for a sibling's renames to finish, short enough not to read as a
+ /// hang. A swap that exhausts the park (7 waits) and both recovery loops (2 each)
+ /// stalls for about 1.6s, on the UI thread, on top of the synchronous 6.5 MB copy
+ /// that already runs there. Only failing updates pay any of it.
+ ///
+ private static readonly TimeSpan RetryPause = TimeSpan.FromMilliseconds(150);
+
+ /// 183 (the name is taken) or 2/3 (the target isn't there yet) — the two
+ /// shapes a sibling's in-progress swap presents, and the only two worth another
+ /// go. Everything else means waiting changes nothing.
+ internal static bool IsSiblingMidSwap(IOException ex) => (ex.HResult & 0xFFFF) is 183 or 2 or 3;
+
+ ///
+ /// A failed rename that the recovery should handle rather than let escape.
+ ///
+ /// The second type is the one that is easy to miss and expensive to get wrong:
+ /// File.Move surfaces ERROR_ACCESS_DENIED as UnauthorizedAccessException, which is
+ /// NOT an IOException — so a catch of IOException alone lets exactly the security
+ /// products that cause this (Defender ASR, Controlled Folder Access, an EDR filter
+ /// holding a just-written 6.5 MB PE) skip the retries, skip the fallback, skip the
+ /// instructions, and leave the folder with no exe.
+ ///
+ internal static bool IsRecoverableMoveFailure(Exception ex)
+ => ex is IOException or UnauthorizedAccessException;
+
+ ///
+ /// Move onto , parking the
+ /// existing target at . Two same-volume renames, so the
+ /// window in which nothing sits at is as short as the
+ /// filesystem can make it.
+ ///
+ /// Separated from the download and the restart so the one sequence here that can
+ /// leave a directory with no usable exe is testable on ordinary files.
+ ///
+ internal static void SwapInPlace(string target, string staged, string old)
+ => SwapInPlace(target, staged, () => old);
+
+ ///
+ /// As above, but re-deciding the parking name on each attempt.
+ ///
+ /// reports what was free at the instant it looked,
+ /// and nothing reserves it. The plain `…exe.old` is the one candidate two processes
+ /// can both pick — every other shape carries a process id, and two live processes
+ /// can't share one — so two windows updating at once can each be told it is free
+ /// and the second still collide. Hoisting the choice next to the rename shrank that
+ /// gap to a couple of syscalls but cannot close it; only re-choosing can.
+ ///
+ internal static void SwapInPlace(string target, string staged, Func chooseOld)
+ => SwapInPlace(target, staged, chooseOld, PutSomethingBack);
+
+ ///
+ /// As above, with the recovery step injectable. Which of its three outcomes counts
+ /// as a failure is the single decision here that the user actually sees — whether
+ /// they are told the update failed — and the states that produce the other two
+ /// can't be staged from outside without a race. So the branch gets a seam.
+ ///
+ internal static void SwapInPlace(string target, string staged, Func chooseOld,
+ Func recover)
+ {
+ // Set whenever the recovery gave up rather than returned. One of those messages
+ // tells the user to go and rename `staged` by hand, and deleting it moments
+ // later would make that a lie about the only copy of the verified download; the
+ // others don't, and keeping it costs one file that the next launch sweeps
+ // anyway. Erring towards keeping is the cheap direction.
+ var keepStaged = false;
+ try
+ {
+ // The name it actually parked at, which after a retry is not the name the
+ // first call chose — the rollback below has to move back the right file.
+ var parked = ParkTarget(target, chooseOld);
+ try
+ {
+ File.Move(staged, target); // same volume — effectively atomic
+ }
+ catch (Exception moveFailure)
+ {
+ // The dangerous instant: nothing sits at `target` at all. Getting
+ // SOMETHING back there matters more than which something — and only
+ // one of the three ways that can end is a failure worth reporting.
+ // Putting the OLD version back means the update didn't happen; the
+ // other two mean a good binary is at the canonical path, which is what
+ // a successful swap produces, so the caller should carry on and start
+ // it rather than being told about an error that changed nothing.
+ Recovery outcome;
+ try { outcome = recover(target, parked, staged); }
+ catch { keepStaged = true; throw; }
+
+ if (outcome != Recovery.OldVersionRestored) return;
+
+ // Rolled back: the install is exactly as it was. Say that, and say it
+ // without handing over a Win32 sentence that names no file — the same
+ // pathless text the portable flow already replaces, and the same shape
+ // as the report this release started from. Nothing here sends the user
+ // looking for the staged copy, so it is still litter and still swept.
+ throw new InvalidOperationException(
+ $"The update couldn't be applied: the new copy could not be moved into " +
+ $"place. Nothing has changed — {Path.GetFileName(target)} is still the " +
+ "version you were running. This is usually a virus scanner holding the " +
+ "freshly written file for a moment, so trying again shortly often works.",
+ moveFailure);
+ }
+ }
+ finally
+ {
+ // On success the staged file has been moved away and this is a no-op. On
+ // failure it is a ~6.5 MB copy nothing else would ever remove.
+ //
+ // Not when the rollback ALSO failed, though: nothing sits at `target` then
+ // and this verified copy is the best recovery artifact left. Nor when the
+ // failure was reported with a message naming it. Leaking a file is
+ // recoverable; deleting the only working binary is not.
+ try
+ {
+ if (!keepStaged && File.Exists(staged) && File.Exists(target)) File.Delete(staged);
+ }
+ catch { /* best effort */ }
+ }
+ }
+
+ /// How the recovery below ended. Only the first is a failed update; the
+ /// other two leave a good binary at the canonical path.
+ internal enum Recovery
+ {
+ /// The previous version went back. The install is as it was.
+ OldVersionRestored,
+ /// The canonical path already holds a byte-for-byte copy of the
+ /// binary this window verified, so the update is done however it got there.
+ ///
+ /// Deliberately NOT "another window beat us to it": the park is a mutual
+ /// exclusion — a process can only install after succeeding at the rename, which
+ /// needs the target to exist, and it doesn't for the whole of our gap. So no
+ /// sibling update can land here. What can is the un-synchronised File.Copy in
+ /// RegistrationService.InstallToAppData, which is why the bytes are checked
+ /// rather than assumed.
+ VerifiedCopyAlreadyThere,
+ /// The old one wouldn't go back, so the verified new one went in
+ /// instead. The update landed after all.
+ StagedInstalled,
+ }
+
+ ///
+ /// Put a working binary back at after the second rename
+ /// failed.
+ ///
+ /// This is the only place in the app that can leave a directory with no exe. The
+ /// ordinary reason for getting here — a scanner holding a freshly-renamed 6.5 MB
+ /// binary — is also a reason the rollback itself fails, and it clears on its own.
+ /// So it waits and retries, then falls back to the staged copy, which is verified,
+ /// newer, and an entirely acceptable resident. Only if neither will go does it
+ /// throw, and then it names the files to rename by hand: an install with nothing to
+ /// launch must not be reported with a sentence about processes and file handles.
+ ///
+ internal static Recovery PutSomethingBack(string target, string parked, string staged)
+ {
+ // Something refilled `target` in this same gap — which is one of the reasons
+ // the second move failed. Moving anything onto it would only fail again, so
+ // don't; judge what is there instead.
+ //
+ // Checking here rather than only at the bottom is an optimisation: the
+ // look-again reaches the same verdict, but after 600ms of renames that cannot
+ // succeed, aimed at a file somebody else just wrote.
+ if (File.Exists(target)) return JudgeWhatIsThere(target, parked, staged);
+
+ for (var attempt = 1; attempt <= RecoveryAttempts; attempt++)
+ {
+ try { File.Move(parked, target); return Recovery.OldVersionRestored; }
+ catch (Exception ex) when (IsRecoverableMoveFailure(ex))
+ {
+ if (attempt < RecoveryAttempts) Thread.Sleep(RetryPause);
+ }
+ }
+
+ // The old one won't go back. Try the new one — the same move that just failed,
+ // but whatever blocked it may well have been the transient thing above.
+ for (var attempt = 1; ; attempt++)
+ {
+ try { File.Move(staged, target); return Recovery.StagedInstalled; }
+ catch (Exception ex) when (IsRecoverableMoveFailure(ex))
+ {
+ if (attempt < RecoveryAttempts) { Thread.Sleep(RetryPause); continue; }
+
+ // Look once more before declaring a catastrophe: ~600ms have passed,
+ // and a program file appearing in that time turns this from the worst
+ // outcome into something else entirely. Saying otherwise would send the
+ // user to rename an old version over a working newer one.
+ if (File.Exists(target)) return JudgeWhatIsThere(target, parked, staged);
+
+ throw new InvalidOperationException(
+ $"The update failed partway and left no program at {target}. Nothing is " +
+ $"lost: in that folder, rename either \"{Path.GetFileName(parked)}\" (the " +
+ $"version you were running) or \"{Path.GetFileName(staged)}\" (the new one, " +
+ $"already verified) to \"{Path.GetFileName(target)}\", and Aperture " +
+ "will start again.", ex);
+ }
+ }
+ }
+
+ ///
+ /// Something is at that this window did not put there.
+ /// It is the update only if it is byte-for-byte the file we downloaded and checked
+ /// the signature of; anything else is a stranger, and reporting a stranger as a
+ /// successful update would restart the app into an unverified binary — quite
+ /// possibly an older one, since the caller goes on to repoint every shortcut at it
+ /// and the sweep then removes the copy the user was running.
+ ///
+ /// Nothing is moved or deleted in the mismatch case: both our binaries are still
+ /// beside it, and the honest report is that this update did not happen.
+ ///
+ private static Recovery JudgeWhatIsThere(string target, string parked, string staged)
+ {
+ // One verdict, used three ways. Asking twice would be a race against itself: a
+ // scanner releasing its handle between the two calls turns "couldn't read" into
+ // "same", the second answer no longer matches the branch that let it through,
+ // and control falls to the mismatch throw — which is exactly the claim the
+ // three-valued answer exists to prevent, made about two identical files.
+ var verdict = Compare(staged, target);
+
+ if (verdict == FileMatch.Same) return Recovery.VerifiedCopyAlreadyThere;
+
+ // Either side being unreadable lands here, and both are reachable: the lock that
+ // stopped the rename is an excellent reason our own copy can't be opened, and a
+ // file still being written — the shape a half-finished File.Copy has — can't be
+ // opened either. Asserting a mismatch on that would state something this window
+ // has no way to know.
+ if (verdict == FileMatch.CouldNotRead)
+ throw new InvalidOperationException(
+ $"A program file appeared at {target} while this update was being applied, " +
+ "and this window couldn't read the two files to compare them — so it has " +
+ "changed nothing and nothing is lost. Close every Aperture window " +
+ "and try the update again.");
+
+ throw new InvalidOperationException(
+ $"Something else wrote a program file to {target} while this update was being " +
+ $"applied, and it is not the version that was just downloaded and verified. " +
+ $"Nothing has been lost and nothing unverified has been started: the version " +
+ $"you were running is beside it as \"{Path.GetFileName(parked)}\", and the " +
+ $"new one as \"{Path.GetFileName(staged)}\". Close every Aperture " +
+ "window and try the update again.");
+ }
+
+ ///
+ /// Portable flow: place the (already verified) versioned exe next to the
+ /// running one and start it. Returns the new exe path.
+ ///
+ public static string ApplyPortableAndRestart(string verifiedNewExe, string assetName, IReadOnlyList? relaunchArgs = null)
+ {
+ var dir = Path.GetDirectoryName(CurrentExePath)!;
+ var safeName = Path.GetFileName(assetName); // never let the release name escape `dir`
+ var dest = Path.Combine(dir, safeName);
+ if (string.Equals(dest, CurrentExePath, StringComparison.OrdinalIgnoreCase))
+ dest = Path.Combine(dir, Path.GetFileNameWithoutExtension(safeName) + ".new.exe");
+
+ // The portable collision: another window already updated, so `dest` exists and
+ // may be the running image of the instance it started. Copying onto it throws
+ // "The process cannot access the file … because it is being used by another
+ // process" — a confusing failure for a file that is already exactly what we
+ // were about to write. Skip the copy when the bytes already match.
+ if (!SameFile(verifiedNewExe, dest))
+ {
+ try
+ {
+ File.Copy(verifiedNewExe, dest, overwrite: true);
+ }
+ catch (IOException) when (SameFile(verifiedNewExe, dest))
+ {
+ // Lost a race with a sibling writing the identical file. Nothing to do.
+ }
+ catch (IOException ex) when (File.Exists(dest) && IsSharingViolation(ex))
+ {
+ // Narrow deliberately: only a sharing/lock violation means "something
+ // else has this file open". Anything else (disk full, for instance)
+ // keeps its own message, because claiming another window is running
+ // it would send the user looking for a window that isn't there — and
+ // possibly at a half-written binary.
+ throw new InvalidOperationException(
+ $"{Path.GetFileName(dest)} is already here and in use — most likely " +
+ "another Aperture window is running it. Close that window and " +
+ "try again.", ex);
+ }
+ }
+ var psiP = new ProcessStartInfo(dest) { UseShellExecute = true };
+ foreach (var a in relaunchArgs ?? []) psiP.ArgumentList.Add(a);
+ Process.Start(psiP);
+
+ // If this exe was registered to start with Windows, point the Run key at
+ // the new file so the next logon is the version just applied.
+ try
+ {
+ WindowsStartup.TryRetargetIfPointsAt(new CurrentUserRunKeyStore(), CurrentExePath, dest);
+ }
+ catch { /* swap already landed; a Run-key refresh isn't fatal */ }
+
+ return dest;
+ }
+
+ /// Same length and same SHA-256 — cheap enough for a one-off 6 MB check,
+ /// and the only honest way to say "that file is already the one I was going to
+ /// write" when we can't open it for writing.
+ internal static bool SameFile(string a, string b) => Compare(a, b) == FileMatch.Same;
+
+ /// Whether two files are the same, or whether we couldn't tell.
+ internal enum FileMatch { Same, Different, CouldNotRead }
+
+ ///
+ /// The three-valued form. "Couldn't tell" is a distinct answer from "not the same",
+ /// and the difference decides whether it is honest to tell someone the file at the
+ /// install path isn't what they downloaded — an unreadable file on EITHER side, and
+ /// a file being written right now is unreadable on the target side, makes that a
+ /// claim we have no basis for.
+ ///
+ internal static FileMatch Compare(string a, string b)
+ {
+ try
+ {
+ if (!File.Exists(a) || !File.Exists(b)) return FileMatch.Different;
+ if (new FileInfo(a).Length != new FileInfo(b).Length) return FileMatch.Different;
+ using var sa = File.OpenRead(a);
+ using var sb = File.OpenRead(b); // read share is enough even while running
+ return System.Security.Cryptography.SHA256.HashData(sa)
+ .AsSpan().SequenceEqual(System.Security.Cryptography.SHA256.HashData(sb))
+ ? FileMatch.Same : FileMatch.Different;
+ }
+ catch { return FileMatch.CouldNotRead; }
+ }
+
+ /// Prefix of the temporary copy the installed swap stages next to the
+ /// target. Everything after it is the owning process id.
+ private const string StagedPrefix = ".aperture-update-staged";
+
+ ///
+ /// Startup cleanup: the `.old` left by a previous installed-flow update (the old
+ /// process held it; by now it has exited), plus any staged copy abandoned by a
+ /// failed one.
+ ///
+ /// Staged files are named per-process so two windows updating at once can't
+ /// delete each other's, which means nothing reclaims them implicitly any more —
+ /// a leftover would sit there forever. Includes the un-suffixed name written by
+ /// versions before 0.6.4, since anyone who hit the bug this release fixes has one.
+ ///
+ public static void CleanupOldBinaries()
+ {
+ try { CleanupTempDownloads(); } catch { /* best effort */ }
+ string exePath;
+ // Not hoisted out of a try: CurrentExePath throws when the process path can't
+ // be determined, and this runs bare at startup.
+ try { exePath = CurrentExePath; } catch { return; }
+ CleanupOldBinaries(exePath);
+ }
+
+ /// Leftover GitHub downloads in %TEMP%\Aperture-update.
+ private static void CleanupTempDownloads()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "Aperture-update");
+ if (!Directory.Exists(dir)) return;
+ foreach (var file in Directory.EnumerateFiles(dir))
+ {
+ try { File.Delete(file); } catch { /* still in use — next launch */ }
+ }
+ }
+
+ ///
+ /// As above, against an explicit exe path. Split out because the branch that
+ /// declines to sweep is the one that decides whether a broken install keeps its
+ /// last working binary, and that is worth pinning rather than reasoning about.
+ ///
+ internal static void CleanupOldBinaries(string exePath)
+ {
+ // Two independent sweeps, deliberately in separate try blocks: enumeration is
+ // lazy, so a failure partway through the first would otherwise skip the second.
+ string dir;
+ try
+ {
+ dir = Path.GetDirectoryName(exePath)!;
+
+ // With nothing at the canonical path, an `…exe.old` may be the only
+ // working binary left — a failed swap AND a failed rollback. Leaking a file
+ // is recoverable; deleting the last exe is not, so don't sweep at all.
+ if (!File.Exists(exePath)) return;
+ }
+ catch { return; }
+
+ try
+ {
+ // `…exe.old` and the `…exe.old-1234` variant used when an update had to
+ // step around a still-running window's image. Both are just files by now
+ // if their owner has exited; if it hasn't, the delete fails and the next
+ // launch tries again.
+ var exe = Path.GetFileName(exePath);
+ foreach (var file in Directory.EnumerateFiles(dir, exe + ".old*"))
+ {
+ try { File.Delete(file); } catch { /* still in use — next launch */ }
+ }
+ }
+ catch { /* best effort */ }
+
+ try
+ {
+ foreach (var file in Directory.EnumerateFiles(dir, StagedPrefix + "*.exe"))
+ {
+ if (!IsReclaimableStagedFile(file)) continue;
+ try { File.Delete(file); } catch { /* next launch */ }
+ }
+ }
+ catch { /* best effort */ }
+ }
+
+ ///
+ /// Is this abandoned staging copy safe to delete?
+ ///
+ /// ".aperture-update-staged.exe" exactly: an unsuffixed name with no process
+ /// id, so there is nothing to test and no way to tell an abandoned one from a live
+ /// one. Reclaimed unconditionally. "…-1234.exe": only once
+ /// 1234 is gone, because during the swap the owner has the file closed rather than
+ /// locked, and deleting it then would break that window's update instead of tidying
+ /// up after it. That the pid may since have been reused by some unrelated program
+ /// only makes this refuse to delete — it never deletes something live.
+ ///
+ internal static bool IsReclaimableStagedFile(string path)
+ {
+ var name = Path.GetFileNameWithoutExtension(path);
+ if (!name.StartsWith(StagedPrefix, StringComparison.Ordinal)) return false;
+ if (name.Length == StagedPrefix.Length) return true; // legacy name
+ var suffix = name[(StagedPrefix.Length + 1)..];
+ return int.TryParse(suffix, out var pid) && !IsProcessAlive(pid);
+ }
+
+ /// ERROR_SHARING_VIOLATION (32) / ERROR_LOCK_VIOLATION (33) — the file is
+ /// open somewhere else, as opposed to any other reason a write can fail.
+ internal static bool IsSharingViolation(IOException ex) => (ex.HResult & 0xFFFF) is 32 or 33;
+
+ private static bool IsProcessAlive(int pid)
+ {
+ try { using var _ = Process.GetProcessById(pid); return true; }
+ catch { return false; }
+ }
+
+ // ---- WinVerifyTrust (full Authenticode policy check) ----
+
+ private static readonly Guid ActionGenericVerifyV2 = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
+
+ private static int WinVerifyTrustFile(string filePath)
+ {
+ var fileInfo = new WINTRUST_FILE_INFO
+ {
+ cbStruct = (uint)Marshal.SizeOf(),
+ pcwszFilePath = filePath,
+ };
+ var data = new WINTRUST_DATA
+ {
+ cbStruct = (uint)Marshal.SizeOf(),
+ dwUIChoice = 2, // WTD_UI_NONE
+ fdwRevocationChecks = 0, // WTD_REVOKE_NONE here; VerifySignature does explicit revocation
+ dwUnionChoice = 1, // WTD_CHOICE_FILE
+ dwStateAction = 1, // WTD_STATEACTION_VERIFY
+ };
+ var fileInfoPtr = Marshal.AllocHGlobal(Marshal.SizeOf());
+ try
+ {
+ Marshal.StructureToPtr(fileInfo, fileInfoPtr, false);
+ data.pFile = fileInfoPtr;
+ var action = ActionGenericVerifyV2;
+ var result = WinVerifyTrust(IntPtr.Zero, ref action, ref data);
+ // Always release the provider state the VERIFY action allocated.
+ data.dwStateAction = 2; // WTD_STATEACTION_CLOSE
+ WinVerifyTrust(IntPtr.Zero, ref action, ref data);
+ return result;
+ }
+ finally { Marshal.FreeHGlobal(fileInfoPtr); }
+ }
+
+ [DllImport("wintrust.dll", CharSet = CharSet.Unicode)]
+ private static extern int WinVerifyTrust(IntPtr hwnd, ref Guid actionId, ref WINTRUST_DATA data);
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WINTRUST_FILE_INFO
+ {
+ public uint cbStruct;
+ [MarshalAs(UnmanagedType.LPWStr)] public string pcwszFilePath;
+ public IntPtr hFile;
+ public IntPtr pgKnownSubject;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct WINTRUST_DATA
+ {
+ public uint cbStruct;
+ public IntPtr pPolicyCallbackData;
+ public IntPtr pSIPClientData;
+ public uint dwUIChoice;
+ public uint fdwRevocationChecks;
+ public uint dwUnionChoice;
+ public IntPtr pFile;
+ public uint dwStateAction;
+ public IntPtr hWVTStateData;
+ public IntPtr pwszURLReference;
+ public uint dwProvFlags;
+ public uint dwUIContext;
+ public IntPtr pSignatureSettings;
+ }
+}
diff --git a/src/Aperture.App/Updates/UpdateVersion.cs b/src/Aperture.App/Updates/UpdateVersion.cs
new file mode 100644
index 0000000..839bd48
--- /dev/null
+++ b/src/Aperture.App/Updates/UpdateVersion.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Linq;
+
+namespace Aperture.App.Updates;
+
+///
+/// The app's version scheme: major.minor.patch[-prereleaseN] (tags carry a
+/// leading v). Ordering follows SemVer's core rule — numeric first, and a
+/// prerelease sorts BELOW its own stable (0.6.0-beta1 < 0.6.0) — with the
+/// simple prerelease tail this repo actually uses (beta1 < beta2 < rc1
+/// compared as label + number, labels ordinal).
+///
+internal sealed record UpdateVersion(Version Numeric, string? Prerelease) : IComparable
+{
+ public bool IsPrerelease => Prerelease is not null;
+
+ public static UpdateVersion? Parse(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ var s = text.Trim();
+ if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
+ // Drop SemVer build metadata: 0.6.2+abc1234 is the same version as 0.6.2 and
+ // must not fail to parse. An unreadable running version is what pushes the
+ // update check onto its fallback paths, so it's worth not creating one.
+ var plus = s.IndexOf('+');
+ if (plus >= 0) s = s[..plus];
+ var dash = s.IndexOf('-');
+ var numericPart = dash < 0 ? s : s[..dash];
+ var pre = dash < 0 ? null : s[(dash + 1)..];
+ if (string.IsNullOrWhiteSpace(pre)) pre = null;
+ if (!Version.TryParse(numericPart, out var v)) return null;
+ // Normalize to 3 components so 0.6 == 0.6.0.
+ v = new Version(v.Major, Math.Max(v.Minor, 0), Math.Max(v.Build, 0));
+ return new UpdateVersion(v, pre);
+ }
+
+ public int CompareTo(UpdateVersion? other)
+ {
+ if (other is null) return 1;
+ var n = Numeric.CompareTo(other.Numeric);
+ if (n != 0) return n;
+ // Same numeric: stable outranks any prerelease.
+ if (Prerelease is null && other.Prerelease is null) return 0;
+ if (Prerelease is null) return 1;
+ if (other.Prerelease is null) return -1;
+ return ComparePre(Prerelease, other.Prerelease);
+ }
+
+ // "beta1" vs "beta2" vs "rc1": label ordinal, then trailing number.
+ private static int ComparePre(string a, string b)
+ {
+ var (la, na) = SplitPre(a);
+ var (lb, nb) = SplitPre(b);
+ var l = string.Compare(la, lb, StringComparison.OrdinalIgnoreCase);
+ return l != 0 ? l : na.CompareTo(nb);
+ }
+
+ private static (string Label, int Num) SplitPre(string p)
+ {
+ var digits = new string(p.Reverse().TakeWhile(char.IsDigit).Reverse().ToArray());
+ var label = p[..(p.Length - digits.Length)];
+ return (label, digits.Length > 0 && int.TryParse(digits, out var n) ? n : 0);
+ }
+
+ public override string ToString() => Prerelease is null ? Numeric.ToString() : $"{Numeric}-{Prerelease}";
+}
diff --git a/src/Aperture.App/ViewModels/MainViewModel.cs b/src/Aperture.App/ViewModels/MainViewModel.cs
index 8ea1a7f..767d126 100644
--- a/src/Aperture.App/ViewModels/MainViewModel.cs
+++ b/src/Aperture.App/ViewModels/MainViewModel.cs
@@ -8,10 +8,12 @@
using Microsoft.Win32;
using Aperture.App.Mvvm;
using Aperture.App.Services;
+using Aperture.App.Updates;
using Aperture.Core.Annotations;
using Aperture.Core.Formatting;
using Aperture.Core.Library;
using Aperture.Core.Models;
+using Aperture.Core.Startup;
namespace Aperture.App.ViewModels;
@@ -25,6 +27,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private readonly LibraryService _library;
private readonly SynchronizationContext _ui;
+ private readonly IRunKeyStore _runKeys = new CurrentUserRunKeyStore();
+ private readonly string? _exePath = Environment.ProcessPath;
+ // Set when this process copies itself to AppData; until restart, ProcessPath
+ // is still the download, but Start-with-Windows must write the installed path.
+ private bool _installedThisSession;
+ private readonly UpdateVersion? _currentVersion;
private readonly Lock _indexLock = new();
private readonly CollectionViewSource _view = new();
private readonly Dictionary _sections = [];
@@ -80,6 +88,9 @@ public MainViewModel(LibraryService library, ThumbnailService thumbnails)
Thumbnails = thumbnails;
_ui = SynchronizationContext.Current ?? new SynchronizationContext();
_zoom = Math.Clamp(_library.Settings.Current.DefaultZoom, 0, ZoomSizes.Length - 1);
+ _currentVersion = UpdateVersion.Parse(
+ Assembly.GetExecutingAssembly()
+ .GetCustomAttribute()?.InformationalVersion);
AddRootCommand = new RelayCommand(AddRoot);
RemoveRootCommand = new RelayCommand(RemoveRoot);
@@ -116,6 +127,10 @@ public MainViewModel(LibraryService library, ThumbnailService thumbnails)
ImportAnnotationsCommand = new RelayCommand(ImportAnnotations);
ExportAnnotationsCommand = new RelayCommand(ExportAnnotations);
OpenReadmeCommand = new RelayCommand(OpenReadme);
+ UpdateStableCommand = new RelayCommand(() => _ = UpdateToAsync(_stable, prerelease: false));
+ UpdatePrereleaseCommand = new RelayCommand(() => _ = UpdateToAsync(_prerelease, prerelease: true));
+ InstallCommand = new RelayCommand(InstallToThisPc);
+ UninstallCommand = new RelayCommand(UninstallFromThisPc);
NavigateHomeCommand = new RelayCommand(() => NavigateTo(null, ""));
NavigateUpCommand = new RelayCommand(NavigateUp, () => !IsHome);
NavigateBackCommand = new RelayCommand(NavigateBack, () => _back.Count > 0);
@@ -237,7 +252,7 @@ private void SelectDefaultNode()
public ThumbnailService Thumbnails { get; }
/// "Aperture Image Viewer vX.Y.Z[-suffix]" — from InformationalVersion so a prerelease tag shows.
- public string AppVersion
+ private static string ProductTitle
{
get
{
@@ -249,6 +264,431 @@ public string AppVersion
}
}
+ public string AppVersion
+ {
+ get
+ {
+ var kind = UpdateService.IsInstalled() ? "installed" : "portable";
+ return ProductTitle + " (" + kind + ")";
+ }
+ }
+
+ private string _onDiskVersionText = "";
+ public string OnDiskVersionText
+ {
+ get => _onDiskVersionText;
+ private set => SetProperty(ref _onDiskVersionText, value);
+ }
+
+ public bool ShowOnDiskVersion => OnDiskVersionText.Length > 0;
+
+ public bool ShowInstall => !RegistrationService.IsRunningFromAppDataInstall();
+ public bool ShowUninstall =>
+ RegistrationService.IsInstalledToAppData()
+ || RegistrationService.HasStartMenuShortcut()
+ || RegistrationService.HasDesktopShortcut();
+
+ public ICommand InstallCommand { get; }
+ public ICommand UninstallCommand { get; }
+
+ ///
+ /// Path the Run key should name: the AppData copy after an in-session install,
+ /// otherwise this process's exe.
+ ///
+ private string? AutostartExePath =>
+ _installedThisSession || RegistrationService.IsRunningFromAppDataInstall()
+ ? RegistrationService.AppDataInstallExe
+ : _exePath;
+
+ // --- About / updater (hamburger header) --------------------------------
+
+ private ReleaseInfo? _stable;
+ private ReleaseInfo? _prerelease;
+ private bool _updating;
+ private bool _updateCheckInFlight;
+ private int _updateCheckGeneration;
+
+ private string _newestReleaseText = "Newest release: checking…";
+ private string _newestPrereleaseText = "";
+ private string _updateMessage = "";
+ private bool _showStableUpdate;
+ private bool _showPrereleaseUpdate;
+ private bool _showPrereleaseRow;
+ private bool _showUpdateMessage;
+ private bool _updateBusy;
+
+ public string NewestReleaseText
+ {
+ get => _newestReleaseText;
+ private set => SetProperty(ref _newestReleaseText, value);
+ }
+
+ public string NewestPrereleaseText
+ {
+ get => _newestPrereleaseText;
+ private set => SetProperty(ref _newestPrereleaseText, value);
+ }
+
+ public string UpdateMessage
+ {
+ get => _updateMessage;
+ private set => SetProperty(ref _updateMessage, value);
+ }
+
+ public bool ShowStableUpdate
+ {
+ get => _showStableUpdate;
+ private set => SetProperty(ref _showStableUpdate, value);
+ }
+
+ public bool ShowPrereleaseUpdate
+ {
+ get => _showPrereleaseUpdate;
+ private set => SetProperty(ref _showPrereleaseUpdate, value);
+ }
+
+ public bool ShowPrereleaseRow
+ {
+ get => _showPrereleaseRow;
+ private set => SetProperty(ref _showPrereleaseRow, value);
+ }
+
+ public bool ShowUpdateMessage
+ {
+ get => _showUpdateMessage;
+ private set => SetProperty(ref _showUpdateMessage, value);
+ }
+
+ public bool UpdateBusy
+ {
+ get => _updateBusy;
+ private set
+ {
+ if (SetProperty(ref _updateBusy, value))
+ OnPropertyChanged(nameof(CanApplyUpdate));
+ }
+ }
+
+ public bool CanApplyUpdate => !_updateBusy;
+
+ public ICommand UpdateStableCommand { get; }
+ public ICommand UpdatePrereleaseCommand { get; }
+
+ /// Called when the hamburger popup opens: refresh the Run-key checkbox and check GitHub.
+ public void OnSettingsOpened()
+ {
+ RefreshInstallUi();
+ OnPropertyChanged(nameof(StartWithWindows));
+ RefreshOnDiskVersion();
+ _ = CheckForUpdatesAsync();
+ }
+
+ private void RefreshInstallUi()
+ {
+ OnPropertyChanged(nameof(AppVersion));
+ OnPropertyChanged(nameof(ShowInstall));
+ OnPropertyChanged(nameof(ShowUninstall));
+ }
+
+ private void RefreshOnDiskVersion()
+ {
+ OnDiskVersionText = "";
+ if (!UpdateService.IsInstalled() || _currentVersion is null)
+ {
+ OnPropertyChanged(nameof(ShowOnDiskVersion));
+ return;
+ }
+ try
+ {
+ var onDisk = UpdateService.VersionOnDisk();
+ if (onDisk is not null && onDisk.CompareTo(_currentVersion) != 0)
+ {
+ var newer = onDisk.CompareTo(_currentVersion) > 0;
+ var action = newer
+ ? "Close and reopen Aperture to switch."
+ : "(older than this window — the installed copy was rolled back)";
+ OnDiskVersionText =
+ $"Installed on disk: {onDisk} — this window is running {_currentVersion}. {action}";
+ }
+ }
+ catch { /* unreadable file version — the line stays hidden */ }
+ OnPropertyChanged(nameof(ShowOnDiskVersion));
+ }
+
+ private async Task CheckForUpdatesAsync()
+ {
+ if (_updateCheckInFlight || _updating) return;
+ _updateCheckInFlight = true;
+ var generation = ++_updateCheckGeneration;
+ NewestReleaseText = "Newest release: checking…";
+ ShowStableUpdate = false;
+ ShowPrereleaseUpdate = false;
+ ShowPrereleaseRow = false;
+ ShowUpdateMessage = false;
+ try
+ {
+ var check = await UpdateService.CheckAsync();
+ if (generation != _updateCheckGeneration) return;
+ ApplyUpdateCheck(check);
+ }
+ finally
+ {
+ if (generation == _updateCheckGeneration)
+ _updateCheckInFlight = false;
+ }
+ }
+
+ private void ApplyUpdateCheck(UpdateCheck? check)
+ {
+ if (check is null)
+ {
+ NewestReleaseText = "Newest release: couldn't check (offline?)";
+ ShowStableUpdate = false;
+ ShowPrereleaseUpdate = false;
+ ShowPrereleaseRow = false;
+ ShowUpdateMessage = false;
+ _stable = null;
+ _prerelease = null;
+ return;
+ }
+
+ _stable = check.Stable;
+ _prerelease = check.PrereleaseRelease;
+
+ NewestReleaseText = _stable is not null
+ ? $"Newest release: {_stable.Tag}"
+ : _prerelease is not null
+ ? $"Newest release: {_prerelease.Tag}"
+ : "Newest release: none published";
+
+ var stableNewer = UpdateOffer.ShowStableUpdate(_stable, _currentVersion);
+ ShowStableUpdate = stableNewer;
+
+ var showPre = UpdateOffer.ShowPrerelease(_prerelease, _stable, _currentVersion);
+ if (showPre)
+ {
+ NewestPrereleaseText = $"Newest prerelease: {_prerelease!.Tag}";
+ ShowPrereleaseRow = _stable is not null; // already named on the main line when there is no stable
+ ShowPrereleaseUpdate = true;
+ }
+ else
+ {
+ _prerelease = null;
+ ShowPrereleaseRow = false;
+ ShowPrereleaseUpdate = false;
+ }
+
+ if (!stableNewer && !showPre)
+ {
+ UpdateMessage = "You're up to date.";
+ ShowUpdateMessage = true;
+ }
+ else if (!_updating)
+ {
+ ShowUpdateMessage = false;
+ }
+ }
+
+ private async Task UpdateToAsync(ReleaseInfo? release, bool prerelease)
+ {
+ if (release is null || _updating) return;
+
+ var installed = UpdateService.IsInstalled();
+ if (installed && UpdateService.AlreadyUpdatedOnDisk(release.Version, _currentVersion, out var onDisk))
+ {
+ var msg = $"Already at {onDisk} on disk — close and reopen Aperture to pick it up.";
+ UpdateMessage = msg;
+ ShowUpdateMessage = true;
+ ShowStableUpdate = ShowPrereleaseUpdate = false;
+ Notify(msg);
+ return;
+ }
+
+ var what = prerelease
+ ? $"{release.Tag} is a PRERELEASE — early access, may contain rough edges."
+ : $"{release.Tag} is the newest stable release.";
+ var how = installed
+ ? "The installed copy will be replaced, shortcuts refreshed, and the app restarted."
+ : "The new version will be downloaded next to the current one and started; the current exe stays behind.";
+ var owner = System.Windows.Application.Current?.MainWindow;
+ var result = owner is not null
+ ? System.Windows.MessageBox.Show(owner, $"{what}\n\n{how}\n\nUpdate now?",
+ "Aperture update", System.Windows.MessageBoxButton.YesNo,
+ prerelease ? System.Windows.MessageBoxImage.Warning : System.Windows.MessageBoxImage.Question)
+ : System.Windows.MessageBox.Show($"{what}\n\n{how}\n\nUpdate now?",
+ "Aperture update", System.Windows.MessageBoxButton.YesNo,
+ prerelease ? System.Windows.MessageBoxImage.Warning : System.Windows.MessageBoxImage.Question);
+ if (result != System.Windows.MessageBoxResult.Yes)
+ return;
+
+ _updating = true;
+ UpdateBusy = true;
+ ShowUpdateMessage = true;
+ try
+ {
+ UpdateMessage = $"Downloading {release.AssetName}…";
+ var file = await UpdateService.DownloadAsync(release);
+ if (file is null)
+ {
+ FailUpdate("Download failed — check your connection and try again.");
+ return;
+ }
+
+ UpdateMessage = "Verifying signature…";
+ var ok = await Task.Run(() => UpdateService.VerifySignature(file, out _));
+ if (!ok)
+ {
+ try { File.Delete(file); } catch { /* leftover in %TEMP%; next download overwrites */ }
+ FailUpdate("The downloaded file failed signature verification and was NOT installed.");
+ return;
+ }
+
+ UpdateMessage = "Installing…";
+ if (installed)
+ UpdateService.ApplyInstalledAndRestart(file);
+ else
+ UpdateService.ApplyPortableAndRestart(file, release.AssetName ?? "Aperture.exe");
+ System.Windows.Application.Current?.Shutdown();
+ }
+ catch (InvalidOperationException ex)
+ {
+ FailUpdate(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ FailUpdate($"Update failed: {ex.Message}");
+ }
+ }
+
+ private void FailUpdate(string message)
+ {
+ _updating = false;
+ UpdateBusy = false;
+ UpdateMessage = message;
+ ShowUpdateMessage = true;
+ // The hamburger closes when the confirm MessageBox takes focus, so an
+ // error must also land in a dialog the user can actually see.
+ Notify(message, warn: true);
+ }
+
+ private void InstallToThisPc()
+ {
+ var alreadyInstalled = RegistrationService.IsRunningFromAppDataInstall();
+ var dest = RegistrationService.AppDataInstallExe;
+
+ if (Ask("Install Aperture on this PC?\n\n" +
+ "A copy will be placed at:\n" + dest + "\n\n" +
+ "A Start menu shortcut will be added so you can launch it anytime.") != System.Windows.MessageBoxResult.Yes)
+ return;
+
+ var addDesktop = Ask("Also add a Desktop shortcut?") == System.Windows.MessageBoxResult.Yes;
+
+ var willMove = false;
+ if (!alreadyInstalled)
+ {
+ willMove = Ask("Remove the original download after installing?\n\n" +
+ "Yes = move (the Downloads copy is deleted once the installed copy starts).\n" +
+ "No = leave the downloaded file where it is.") == System.Windows.MessageBoxResult.Yes;
+ }
+
+ try
+ {
+ var download = RegistrationService.CurrentExePath;
+ var exe = alreadyInstalled
+ ? download
+ : RegistrationService.InstallToAppData();
+
+ if (!alreadyInstalled)
+ {
+ RegistrationService.SaveInstallInfo(download, moved: willMove);
+ _installedThisSession = true;
+ }
+
+ RegistrationService.CreateStartMenuShortcut(exe);
+ if (addDesktop)
+ RegistrationService.CreateDesktopShortcut(exe);
+
+ // Autostart must not keep a Downloads path after the AppData copy exists.
+ RegistrationService.RetargetStartWithWindows(download, exe);
+ RefreshInstallUi();
+ OnPropertyChanged(nameof(StartWithWindows));
+
+ if (willMove && !alreadyInstalled)
+ {
+ var psi = new ProcessStartInfo(exe) { UseShellExecute = true };
+ psi.ArgumentList.Add("--finish-move");
+ psi.ArgumentList.Add(download);
+ Process.Start(psi);
+ System.Windows.Application.Current?.Shutdown();
+ return;
+ }
+
+ var lines = new List
+ {
+ "Aperture is installed.",
+ "",
+ "Exe: " + exe,
+ "Start menu: added",
+ };
+ if (addDesktop) lines.Add("Desktop shortcut: added");
+ Notify(string.Join("\n", lines));
+ }
+ catch (Exception ex)
+ {
+ Notify("Couldn't complete install:\n\n" + ex.Message, warn: true);
+ }
+ }
+
+ private void UninstallFromThisPc()
+ {
+ var runningFromInstall = RegistrationService.IsRunningFromAppDataInstall();
+ var info = RegistrationService.ReadInstallInfo();
+
+ var body = "Uninstall Aperture from this PC?\n\n" +
+ "Removes the Start menu shortcut and Desktop shortcut." +
+ (runningFromInstall
+ ? "\n\nThe AppData copy can't be deleted while Aperture is running from it — close Aperture and uninstall again to remove that folder."
+ : "\n\nAlso removes the copy in %LocalAppData%\\Programs\\Aperture.");
+ if (Ask(body) != System.Windows.MessageBoxResult.Yes)
+ return;
+
+ try
+ {
+ var done = new List();
+ if (!string.IsNullOrEmpty(info?.OriginalPath)
+ && Ask("Restore a copy to the original location?\n\n" + info.OriginalPath) == System.Windows.MessageBoxResult.Yes)
+ {
+ var ok = RegistrationService.RestoreToOriginal(info.OriginalPath);
+ done.Add(ok ? "• Restored a copy to " + info.OriginalPath : "• Couldn't restore to " + info.OriginalPath);
+ }
+
+ RegistrationService.RemoveStartMenuShortcut();
+ done.Add("• Removed the Start-menu entry");
+ RegistrationService.RemoveDesktopShortcut();
+ done.Add("• Removed the Desktop shortcut");
+
+ if (!runningFromInstall)
+ {
+ RegistrationService.ClearStartWithWindowsIfPointsAtInstall();
+ RegistrationService.UninstallFromAppData();
+ done.Add("• Removed the installed copy from the app folder");
+ _installedThisSession = false;
+ }
+
+ RefreshInstallUi();
+ OnPropertyChanged(nameof(StartWithWindows));
+ Notify(string.Join("\n", done));
+ }
+ catch (Exception ex)
+ {
+ Notify("Couldn't complete uninstall:\n\n" + ex.Message, warn: true);
+ }
+ }
+
+ private static System.Windows.MessageBoxResult Ask(string text) =>
+ System.Windows.MessageBox.Show(text, "Aperture",
+ System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Question);
+
private string _windowTitle = "Aperture Image Viewer";
/// Window title — shows the current folder's full path.
@@ -263,11 +703,11 @@ private void UpdateWindowTitle()
if (_location.RootId is { } rootId && Roots.FirstOrDefault(r => r.Id == rootId) is { } root)
{
var full = _location.RelDir.Length == 0 ? root.Path : Path.Combine(root.Path, _location.RelDir);
- WindowTitle = $"{AppVersion} — {full}";
+ WindowTitle = $"{ProductTitle} — {full}";
}
else
{
- WindowTitle = AppVersion;
+ WindowTitle = ProductTitle;
}
}
@@ -487,6 +927,23 @@ public bool IncludeVideos
}
}
+ ///
+ /// Launch Aperture at Windows logon. Source of truth is the HKCU Run key
+ /// (checked iff a value named Aperture points at this exe). Registry failures
+ /// never throw; the checkbox re-reads the actual key.
+ ///
+ public bool StartWithWindows
+ {
+ get => WindowsStartup.IsEnabled(_runKeys, AutostartExePath);
+ set
+ {
+ if (value == WindowsStartup.IsEnabled(_runKeys, AutostartExePath))
+ return;
+ WindowsStartup.TrySetEnabled(_runKeys, AutostartExePath, value);
+ OnPropertyChanged();
+ }
+ }
+
public string StatusText
{
get => _statusText;
@@ -2152,6 +2609,7 @@ private void RunOnUi(Action action)
public void Dispose()
{
+ _updateCheckGeneration++; // in-flight GitHub checks must not write back after shutdown
_library.RootChanged -= OnRootChanged;
_library.Dispose();
}
diff --git a/src/Aperture.Core/Startup/WindowsStartup.cs b/src/Aperture.Core/Startup/WindowsStartup.cs
new file mode 100644
index 0000000..e2b53fc
--- /dev/null
+++ b/src/Aperture.Core/Startup/WindowsStartup.cs
@@ -0,0 +1,153 @@
+using Microsoft.Win32;
+
+namespace Aperture.Core.Startup;
+
+///
+/// HKCU Run-key values used by "Start with Windows". Tests inject a fake store;
+/// the live app uses .
+///
+public interface IRunKeyStore
+{
+ /// The named value's string data, or null when absent.
+ string? GetValue(string name);
+
+ void SetValue(string name, string data);
+
+ void DeleteValue(string name);
+}
+
+///
+/// HKCU\Software\Microsoft\Windows\CurrentVersion\Run. Per-user, no admin.
+///
+public sealed class CurrentUserRunKeyStore : IRunKeyStore
+{
+ public const string SubKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
+
+ public string? GetValue(string name)
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(SubKeyPath, writable: false);
+ var raw = key?.GetValue(name);
+ return raw as string ?? raw?.ToString();
+ }
+
+ public void SetValue(string name, string data)
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(SubKeyPath, writable: true)
+ ?? Registry.CurrentUser.CreateSubKey(SubKeyPath, writable: true);
+ key.SetValue(name, data, RegistryValueKind.String);
+ }
+
+ public void DeleteValue(string name)
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(SubKeyPath, writable: true);
+ key?.DeleteValue(name, throwOnMissingValue: false);
+ }
+}
+
+///
+/// Launch-at-login via the current-user Run key. The checkbox is source of truth
+/// against the registry: checked iff a value named is
+/// present and points at this exe. Failures never throw to the UI.
+///
+public static class WindowsStartup
+{
+ public const string ValueName = "Aperture";
+
+ /// Quoted full path — the standard Run-key command form.
+ public static string FormatCommand(string exePath) => $"\"{exePath}\"";
+
+ ///
+ /// The exe path encoded in a Run-key command: the quoted token if present,
+ /// otherwise the first whitespace-delimited token. Null when empty/unusable.
+ ///
+ public static string? ExePathFromCommand(string? stored)
+ {
+ if (string.IsNullOrWhiteSpace(stored)) return null;
+ var s = stored.Trim();
+ if (s.StartsWith('"'))
+ {
+ var end = s.IndexOf('"', 1);
+ if (end <= 1) return null;
+ return s[1..end];
+ }
+ var space = s.IndexOf(' ');
+ return space < 0 ? s : s[..space];
+ }
+
+ /// True when names the same file as .
+ public static bool PointsAtThisExe(string? stored, string exePath)
+ {
+ var fromStore = ExePathFromCommand(stored);
+ if (string.IsNullOrEmpty(fromStore) || string.IsNullOrWhiteSpace(exePath))
+ return false;
+ try
+ {
+ return string.Equals(
+ Path.GetFullPath(fromStore),
+ Path.GetFullPath(exePath),
+ StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public static bool IsEnabled(IRunKeyStore store, string? exePath)
+ {
+ if (string.IsNullOrWhiteSpace(exePath)) return false;
+ try { return PointsAtThisExe(store.GetValue(ValueName), exePath); }
+ catch { return false; }
+ }
+
+ ///
+ /// Writes or deletes the Run value. Returns false on registry failure
+ /// (or a missing exe path); never throws.
+ ///
+ public static bool TrySetEnabled(IRunKeyStore store, string? exePath, bool enabled)
+ {
+ if (string.IsNullOrWhiteSpace(exePath)) return false;
+ try
+ {
+ if (enabled)
+ store.SetValue(ValueName, FormatCommand(exePath));
+ else
+ store.DeleteValue(ValueName);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// True when the named Run value is present (any path). Used to decide whether
+ /// an install/update should rewrite the value rather than create one.
+ ///
+ public static bool HasValue(IRunKeyStore store)
+ {
+ try { return !string.IsNullOrWhiteSpace(store.GetValue(ValueName)); }
+ catch { return false; }
+ }
+
+ ///
+ /// If the Run value already points at (or already
+ /// at ), rewrite it to .
+ /// Does not create a Run value the user never opted into. Never throws.
+ ///
+ public static bool TryRetargetIfPointsAt(IRunKeyStore store, string? currentPath, string? newPath)
+ {
+ if (string.IsNullOrWhiteSpace(newPath)) return false;
+ try
+ {
+ if (!IsEnabled(store, currentPath) && !IsEnabled(store, newPath))
+ return false;
+ return TrySetEnabled(store, newPath, enabled: true);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/tests/Aperture.Core.Tests/Aperture.Core.Tests.csproj b/tests/Aperture.Core.Tests/Aperture.Core.Tests.csproj
index 8e66a78..7a03095 100644
--- a/tests/Aperture.Core.Tests/Aperture.Core.Tests.csproj
+++ b/tests/Aperture.Core.Tests/Aperture.Core.Tests.csproj
@@ -22,6 +22,7 @@
+
\ No newline at end of file
diff --git a/tests/Aperture.Core.Tests/RegistrationServiceTests.cs b/tests/Aperture.Core.Tests/RegistrationServiceTests.cs
new file mode 100644
index 0000000..a5741b5
--- /dev/null
+++ b/tests/Aperture.Core.Tests/RegistrationServiceTests.cs
@@ -0,0 +1,30 @@
+using Aperture.App;
+
+namespace Aperture.Core.Tests;
+
+public class RegistrationServiceTests
+{
+ [Fact]
+ public void AppDataInstallExe_IsUnderLocalProgramsAperture()
+ {
+ var expected = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Programs", "Aperture", "Aperture.exe");
+ Assert.Equal(expected, RegistrationService.AppDataInstallExe);
+ }
+
+ [Fact]
+ public void ShortcutNames_AreApertureLnk()
+ {
+ Assert.Equal("Aperture.lnk", Path.GetFileName(RegistrationService.StartMenuLinkPath));
+ Assert.Equal("Aperture.lnk", Path.GetFileName(RegistrationService.DesktopLinkPath));
+ }
+
+ [Fact]
+ public void FinishMove_RefusesToDeleteTheInstalledCopy()
+ {
+ // Safety: a mistaken --finish-move of the AppData path must be a no-op.
+ // We can't observe the skip except by it not throwing.
+ RegistrationService.FinishMove(RegistrationService.AppDataInstallExe);
+ }
+}
diff --git a/tests/Aperture.Core.Tests/UpdateFileTests.cs b/tests/Aperture.Core.Tests/UpdateFileTests.cs
new file mode 100644
index 0000000..a45d987
--- /dev/null
+++ b/tests/Aperture.Core.Tests/UpdateFileTests.cs
@@ -0,0 +1,936 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Aperture.App.Updates;
+using Xunit;
+
+namespace Aperture.Core.Tests;
+
+///
+/// The portable half of the multi-window update problem. Another window updates
+/// first, so the versioned exe is already sitting next to ours — and is very likely
+/// the running image of the instance that window started. Copying onto it throws
+/// "The process cannot access the file … because it is being used by another
+/// process", which is a confusing thing to say about a file that is already
+/// byte-for-byte what we were about to write.
+///
+public class SameFileTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(Path.GetTempPath(),
+ "aperture-samefile-" + Guid.NewGuid().ToString("N"));
+
+ public SameFileTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch { }
+ }
+
+ private string Write(string name, string content)
+ {
+ var p = Path.Combine(_dir, name);
+ File.WriteAllText(p, content);
+ return p;
+ }
+
+ [Fact]
+ public void IdenticalContent_IsRecognised_SoTheCopyIsSkipped()
+ {
+ var a = Write("a.exe", "the same bytes");
+ var b = Write("b.exe", "the same bytes");
+ Assert.True(UpdateService.SameFile(a, b));
+ }
+
+ [Fact]
+ public void DifferentContent_IsNotSkipped()
+ => Assert.False(UpdateService.SameFile(Write("a.exe", "v1"), Write("b.exe", "v2")));
+
+ [Fact]
+ public void SameLengthDifferentBytes_IsNotSkipped()
+ {
+ // Length alone is the cheap pre-check; it must not be the whole answer, or
+ // two builds of the same size would be mistaken for each other.
+ var a = Write("a.exe", "AAAA");
+ var b = Write("b.exe", "BBBB");
+ Assert.Equal(new FileInfo(a).Length, new FileInfo(b).Length);
+ Assert.False(UpdateService.SameFile(a, b));
+ }
+
+ [Fact]
+ public void MissingDestination_IsNotSkipped()
+ => Assert.False(UpdateService.SameFile(Write("a.exe", "x"), Path.Combine(_dir, "nope.exe")));
+
+ [Fact]
+ public void MissingSource_IsNotSkipped_AndDoesNotThrow()
+ => Assert.False(UpdateService.SameFile(Path.Combine(_dir, "nope.exe"), Write("b.exe", "x")));
+
+ [Fact]
+ public void DestinationHeldOpenTheWayAWindowsExecutableIs_StillCompares()
+ {
+ // The case that matters: Windows keeps a running image open with read
+ // sharing, so we can still hash it and discover there is nothing to do.
+ var a = Write("a.exe", "identical payload");
+ var b = Write("b.exe", "identical payload");
+ using var running = new FileStream(b, FileMode.Open, FileAccess.Read, FileShare.Read);
+ Assert.True(UpdateService.SameFile(a, b));
+ }
+
+ [Fact]
+ public void DestinationLockedOutright_ReportsNotSame_RatherThanThrowing()
+ {
+ // Nothing can be read and nothing can be written, so the honest answer is
+ // "not the same" — the copy is attempted and its real error is reported.
+ var a = Write("a.exe", "payload");
+ var b = Write("b.exe", "payload");
+ using var exclusive = new FileStream(b, FileMode.Open, FileAccess.Read, FileShare.None);
+ Assert.False(UpdateService.SameFile(a, b));
+ }
+
+ [Fact]
+ public void EmptyFiles_AreEqual()
+ => Assert.True(UpdateService.SameFile(Write("a.exe", ""), Write("b.exe", "")));
+
+ // ---- the in-place swap ----
+ //
+ // Two renames with a rollback. It is the only code on the update path that can
+ // leave a directory with no usable exe, so every branch is pinned here rather
+ // than reasoned about.
+
+ [Fact]
+ public void Swap_ReplacesTheTargetAndParksTheOldOne()
+ {
+ var target = Write("app.exe", "old version");
+ var staged = Write("staged.exe", "new version");
+ var old = target + ".old";
+
+ UpdateService.SwapInPlace(target, staged, old);
+
+ Assert.Equal("new version", File.ReadAllText(target));
+ Assert.Equal("old version", File.ReadAllText(old));
+ Assert.False(File.Exists(staged)); // moved, and cleaned up
+ }
+
+ [Fact]
+ public void Swap_OverwritesAnOldFileLeftByAPreviousUpdate()
+ {
+ var target = Write("app.exe", "v2");
+ var staged = Write("staged.exe", "v3");
+ var old = Write("app.exe.old", "v1 from last time");
+
+ UpdateService.SwapInPlace(target, staged, old);
+
+ Assert.Equal("v3", File.ReadAllText(target));
+ Assert.Equal("v2", File.ReadAllText(old));
+ }
+
+ [Fact]
+ public void Swap_RollsBackWhenTheSecondMoveFails_LeavingTheInstallIntact()
+ {
+ var target = Write("app.exe", "the working version");
+ var staged = Write("staged.exe", "the new version");
+ var old = target + ".old";
+
+ // Hold the staged file exclusively so its move into place fails — the window
+ // where `target` doesn't exist yet. This is the CHANGELOG's "usual reason": a
+ // scanner with the freshly written 6.5 MB copy open.
+ InvalidOperationException ex;
+ using (var _ = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.None))
+ ex = Assert.Throws(
+ () => UpdateService.SwapInPlace(target, staged, old));
+
+ // The install must be exactly as it was, and the failure must be visible.
+ Assert.True(File.Exists(target));
+ Assert.Equal("the working version", File.ReadAllText(target));
+ // And reported in words. The raw text here is "The process cannot access the
+ // file because it is being used by another process" — which names no file at
+ // all, so it can't be acted on. The portable flow already replaces it.
+ Assert.DoesNotContain("used by another process", ex.Message);
+ Assert.Contains("Nothing has changed", ex.Message);
+ Assert.IsAssignableFrom(ex.InnerException); // still diagnosable
+ }
+
+ [Fact]
+ public void Swap_KeepsTheStagedCopyWhenThereIsNoTargetToFallBackOn()
+ {
+ // Pins the `&& File.Exists(target)` guard on the cleanup: whenever nothing
+ // sits at `target` — which is what a failed rollback would leave — the
+ // verified staged copy is the last recoverable binary and must survive.
+ //
+ // It reaches that state via the FIRST move failing on a missing source, not
+ // via a failed rollback, which can't be forced from outside. The guard is
+ // what's under test, not the route to it.
+ var dir = Path.Combine(_dir, "gone");
+ Directory.CreateDirectory(dir);
+ var target = Path.Combine(dir, "app.exe"); // deliberately absent
+ var staged = Path.Combine(dir, "staged.exe");
+ File.WriteAllText(staged, "the verified new version");
+ var old = target + ".old";
+
+ var clock = Stopwatch.StartNew();
+ var ex = Assert.Throws(
+ () => UpdateService.SwapInPlace(target, staged, old));
+ clock.Stop();
+
+ // The property behind the park's larger budget: a sibling that reaches its own
+ // recovery holds the gap for about 600ms, so giving up sooner than that would
+ // announce a missing program while the window next door is putting one back.
+ Assert.True(clock.ElapsedMilliseconds > 700,
+ $"gave up after only {clock.ElapsedMilliseconds}ms");
+ Assert.True(File.Exists(staged), "the staged copy is the only binary left — keep it");
+ Assert.Equal("the verified new version", File.ReadAllText(staged));
+ // And it must not blame a race for a missing program: closing windows can't
+ // produce an exe that isn't there.
+ Assert.DoesNotContain("Close the other windows", ex.Message);
+ Assert.Contains("no Aperture program file", ex.Message);
+ // A sibling's own recovery can hold the gap for ~600ms, so this must not give
+ // up first and announce a missing program while the window next door is putting
+ // one back — and where it does give up, it says that is a possibility.
+ Assert.Contains("wait a moment and try again", ex.Message);
+ }
+
+ // ---- the instant when nothing sits at `target` ----
+ //
+ // Between the two renames the install directory has no exe at all. If the second
+ // one fails there, the recovery is the only thing standing between the user and an
+ // app that cannot be launched — so it is exercised directly rather than through the
+ // timing-dependent interleavings that reach it.
+
+ [Fact]
+ public void Recovery_PutsTheOldVersionBack()
+ {
+ var target = Path.Combine(_dir, "app.exe"); // the empty instant
+ var parked = Write("app.exe.old", "the working version");
+ var staged = Write("staged.exe", "the new version");
+
+ Assert.Equal(UpdateService.Recovery.OldVersionRestored,
+ UpdateService.PutSomethingBack(target, parked, staged));
+ Assert.Equal("the working version", File.ReadAllText(target));
+ }
+
+ [Fact]
+ public void Recovery_UsesTheStagedCopyWhenTheOldOneWontGoBack()
+ {
+ // Both moves have now failed. Which binary ends up there matters far less than
+ // there being one: the staged copy is verified and newer, so it is a perfectly
+ // good resident — and the update has in fact landed, so this reports success.
+ var target = Path.Combine(_dir, "app.exe");
+ var parked = Write("app.exe.old", "the working version");
+ var staged = Write("staged.exe", "the new version");
+
+ using var stuck = new FileStream(parked, FileMode.Open, FileAccess.Read, FileShare.None);
+
+ Assert.Equal(UpdateService.Recovery.StagedInstalled,
+ UpdateService.PutSomethingBack(target, parked, staged));
+ Assert.Equal("the new version", File.ReadAllText(target));
+ }
+
+ [Fact]
+ public void Recovery_ThatCannotPlaceAnythingSaysWhichFileToRename()
+ {
+ // The worst state the app can reach: no exe where one is expected. What must
+ // NOT be reported is a sentence about file handles — the user needs to know
+ // the install is unlaunchable and exactly how to fix it in Explorer.
+ var target = Path.Combine(_dir, "app.exe");
+ var parked = Write("app.exe.old", "the working version");
+ var staged = Write("staged.exe", "the new version");
+
+ using var stuckOld = new FileStream(parked, FileMode.Open, FileAccess.Read, FileShare.None);
+ using var stuckNew = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.None);
+
+ var ex = Assert.Throws(
+ () => UpdateService.PutSomethingBack(target, parked, staged));
+
+ Assert.Contains("app.exe.old", ex.Message);
+ Assert.Contains("staged.exe", ex.Message);
+ Assert.Contains("rename", ex.Message);
+ // Nothing was lost, which is what makes the instructions followable.
+ Assert.True(File.Exists(parked) && File.Exists(staged));
+ }
+
+ [Fact]
+ public void Recovery_AcceptsACopyThatIsExactlyWhatWasVerified()
+ {
+ // The second move can fail *because* something refilled the target in the same
+ // instant. When what's there is byte-for-byte the file whose signature we
+ // checked, the update is done however it arrived, and reporting a failure for
+ // it would be wrong.
+ var target = Write("app.exe", "the new version");
+ var parked = Write("app.exe.old", "the working version");
+ var staged = Write("staged.exe", "the new version");
+
+ var clock = Stopwatch.StartNew();
+ Assert.Equal(UpdateService.Recovery.VerifiedCopyAlreadyThere,
+ UpdateService.PutSomethingBack(target, parked, staged));
+ clock.Stop();
+
+ Assert.Equal("the new version", File.ReadAllText(target));
+ Assert.Equal("the working version", File.ReadAllText(parked));
+ // Recognised up front rather than after both retry loops have spent 600ms
+ // trying renames that cannot succeed against a sibling's fresh install. The
+ // final look-again would return the same answer, so this is the one thing that
+ // distinguishes the two — hence a clock, with a wide margin.
+ Assert.True(clock.ElapsedMilliseconds < 400, $"took {clock.ElapsedMilliseconds}ms");
+ }
+
+ [Fact]
+ public void Recovery_RefusesToCallAStrangerTheUpdate()
+ {
+ // The interleaving the "another window beat us to it" reading gets wrong. A
+ // sibling *update* cannot land here at all — the park is a mutual exclusion,
+ // and for the whole of this gap there is no target to park. What can appear is
+ // an unrelated write to the same path (RegistrationService copies a portable
+ // exe there without taking the park). Calling that the update would restart the
+ // user into an unverified, quite possibly OLDER binary, repoint every shortcut
+ // at it, and let the next sweep delete the copy they were actually running.
+ var target = Write("app.exe", "some other build entirely");
+ var parked = Write("app.exe.old", "the version they were running");
+ var staged = Write("staged.exe", "the verified new version");
+
+ var ex = Assert.Throws(
+ () => UpdateService.PutSomethingBack(target, parked, staged));
+
+ Assert.Contains("not the version that was just downloaded and verified", ex.Message);
+ // Nothing touched: no unverified binary promoted, no working one destroyed.
+ Assert.Equal("some other build entirely", File.ReadAllText(target));
+ Assert.Equal("the version they were running", File.ReadAllText(parked));
+ Assert.Equal("the verified new version", File.ReadAllText(staged));
+ }
+
+ [Fact]
+ public void Recovery_DoesNotClaimAMismatchItCouldNotCheck()
+ {
+ // The comparison has two sides, and the lock that stopped the rename is an
+ // excellent reason our OWN copy is the unreadable one. "That is not the version
+ // you downloaded" would then be an assertion this window has no way to make —
+ // about a file that may well be byte-for-byte correct.
+ var target = Write("app.exe", "quite possibly exactly what we verified");
+ var parked = Write("app.exe.old", "the version they were running");
+ var staged = Write("staged.exe", "quite possibly exactly what we verified");
+ using var scanner = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.None);
+
+ var ex = Assert.Throws(
+ () => UpdateService.PutSomethingBack(target, parked, staged));
+
+ Assert.Contains("couldn't read the two files to compare them", ex.Message);
+ Assert.DoesNotContain("not the version that was just downloaded", ex.Message);
+ }
+
+ [Fact]
+ public void Recovery_WontCallAHalfWrittenCopyAMismatchEither()
+ {
+ // The same path mid-File.Copy: unreadable, on the OTHER side of the comparison.
+ // "Couldn't read" has to reach the same verdict whichever file it was, and the
+ // bytes here are deliberately identical — so an implementation that only checks
+ // our own copy would announce a mismatch between two files that match.
+ var target = Write("app.exe", "the verified new version");
+ var parked = Write("app.exe.old", "the version they were running");
+ var staged = Write("staged.exe", "the verified new version");
+ using var beingWritten = new FileStream(target, FileMode.Open, FileAccess.Write, FileShare.None);
+
+ var ex = Assert.Throws(
+ () => UpdateService.PutSomethingBack(target, parked, staged));
+
+ Assert.Contains("couldn't read the two files", ex.Message);
+ Assert.DoesNotContain("not the version that was just downloaded", ex.Message);
+ Assert.Equal("the verified new version", File.ReadAllText(staged));
+ }
+
+ [Fact]
+ public void Recovery_DoesNotRaceItselfIntoAFalseAccusation()
+ {
+ // Asking the same question twice is not the same as asking it once. With two
+ // comparisons, a holder releasing its handle in between turns "couldn't read"
+ // into "same", the second answer no longer matches the branch that let it
+ // through, and control falls to the mismatch throw — about two files that are
+ // byte-for-byte identical.
+ //
+ // The verdict is taken once now, so with identical bytes the only outcomes are
+ // "same" and "couldn't read". This can only fail if that stops being true: the
+ // flicker makes it likely to catch a regression, and nothing makes it likely to
+ // fail spuriously.
+ var target = Write("app.exe", "the verified new version");
+ var parked = Write("app.exe.old", "the version they were running");
+ var staged = Write("staged.exe", "the verified new version");
+
+ using var stop = new CancellationTokenSource();
+ var flicker = Task.Run(() =>
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ try
+ {
+ using var _ = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.None);
+ }
+ catch { /* the comparison has it — try again */ }
+ }
+ });
+
+ try
+ {
+ for (var i = 0; i < 300; i++)
+ {
+ try
+ {
+ UpdateService.PutSomethingBack(target, parked, staged);
+ }
+ catch (InvalidOperationException ex)
+ {
+ Assert.DoesNotContain("not the version that was just downloaded", ex.Message);
+ }
+ }
+ }
+ finally { stop.Cancel(); flicker.Wait(); }
+ }
+
+ [Theory]
+ [InlineData("same", "same", "Same")]
+ [InlineData("one", "another", "Different")]
+ [InlineData("same", null, "CouldNotRead")] // null = present but locked
+ public void ComparingFilesDistinguishesDifferentFromUnreadable(string a, string? b, string expected)
+ {
+ var pa = Write("a.exe", a);
+ var pb = Write("b.exe", b ?? "same");
+ using var lockIt = b is null
+ ? new FileStream(pb, FileMode.Open, FileAccess.Read, FileShare.None)
+ : null;
+
+ Assert.Equal(Enum.Parse(expected), UpdateService.Compare(pa, pb));
+ }
+
+ [Fact]
+ public void Recovery_LooksAgainBeforeCallingItACatastrophe()
+ {
+ // Both loops together take about 600ms, and a sibling landing its install
+ // inside that turns the worst outcome into the best one. Announcing "there is
+ // no program — rename one of these" then would talk someone into overwriting a
+ // working new version with the old one.
+ var target = Path.Combine(_dir, "app.exe");
+ var parked = Write("app.exe.old", "the working version");
+ var staged = Write("staged.exe", "the new version");
+
+ // Only the old copy is held, so the rollback can't work. The staged move then
+ // fails the way it does in production — the name is taken — rather than by
+ // being locked, which would also stop us reading the bytes to compare.
+ using var stuckOld = new FileStream(parked, FileMode.Open, FileAccess.Read, FileShare.None);
+ using var sibling = new Timer(_ => File.WriteAllText(target, "the new version"),
+ null, TimeSpan.FromMilliseconds(100), Timeout.InfiniteTimeSpan);
+
+ Assert.Equal(UpdateService.Recovery.VerifiedCopyAlreadyThere,
+ UpdateService.PutSomethingBack(target, parked, staged));
+ }
+
+ [Fact]
+ public void MoveCanFailWithSomethingThatIsNotAnIOException()
+ {
+ // Load-bearing, and the reason the recovery's catches aren't `catch (IOException)`:
+ // File.Move surfaces a denied rename as UnauthorizedAccessException, which is
+ // not an IOException at all. Demonstrated here with a read-only destination,
+ // standing in for the security software that causes it in the field — the point
+ // is only that File.Move really does throw outside the IOException hierarchy.
+ var src = Write("a.exe", "x");
+ var dest = Write("b.exe", "y");
+ File.SetAttributes(dest, FileAttributes.ReadOnly);
+ try
+ {
+ var ex = Assert.ThrowsAny(() => File.Move(src, dest, overwrite: true));
+ Assert.IsNotAssignableFrom(ex);
+ Assert.True(UpdateService.IsRecoverableMoveFailure(ex),
+ "the recovery must catch it, or it skips the retries, the fallback " +
+ "and the instructions, and leaves the folder with no exe");
+ }
+ finally { File.SetAttributes(dest, FileAttributes.Normal); }
+ }
+
+ [Theory]
+ [InlineData("io", true)]
+ [InlineData("denied", true)]
+ [InlineData("other", false)]
+ public void OnlyMoveFailuresAreRecovered(string kind, bool recovered)
+ => Assert.Equal(recovered, UpdateService.IsRecoverableMoveFailure(kind switch
+ {
+ "io" => new IOException("x"),
+ "denied" => new UnauthorizedAccessException("x"),
+ _ => new InvalidOperationException("a bug in our own code — let it out"),
+ }));
+
+ // ---- and the decision SwapInPlace makes about those three outcomes, which is
+ // what determines whether the user is told the update failed ----
+
+ [Theory]
+ [InlineData(nameof(UpdateService.Recovery.OldVersionRestored), true)]
+ [InlineData(nameof(UpdateService.Recovery.VerifiedCopyAlreadyThere), false)]
+ [InlineData(nameof(UpdateService.Recovery.StagedInstalled), false)]
+ public void OnlyARolledBackSwapIsReportedAsAFailure(string outcomeName, bool reports)
+ {
+ var outcome = Enum.Parse(outcomeName);
+ // Two of the three leave a good binary at the canonical path, which is exactly
+ // what a successful swap produces — so the caller must go on to refresh the
+ // shortcuts and start it, not abort with an error that changed nothing.
+ var target = Write("app.exe", "the working version");
+ var staged = Write("staged.exe", "the new version");
+ var parked = target + ".old";
+
+ // Force the second move to fail, then answer with the outcome under test.
+ using var blocked = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.None);
+ void Swap() => UpdateService.SwapInPlace(target, staged, () => parked, (t, p, s) =>
+ {
+ File.Move(p, t); // whatever the outcome, put a binary back
+ return outcome;
+ });
+
+ if (reports) Assert.Throws(Swap);
+ else Swap();
+
+ Assert.Equal("the working version", File.ReadAllText(target));
+ }
+
+ [Fact]
+ public void Swap_KeepsTheFilesItsOwnFailureMessageTellsYouToRename()
+ {
+ // The invariant the direct PutSomethingBack tests assert, checked where it
+ // actually has to hold. `SwapInPlace`'s cleanup runs on the way out of a throw
+ // too, and it fires precisely when a file is at the target — which is the state
+ // the mismatch message is reporting. Deleting the verified download there makes
+ // the message a lie and destroys the only copy of it, and the next launch's
+ // sweep then takes the parked one as well.
+ var target = Write("app.exe", "the working version");
+ var staged = Write("staged.exe", "the verified new version");
+ var parked = target + ".old";
+
+ // Share Read, not None: the move needs delete access and so fails, but the file
+ // stays readable — which is what a scanner holding it actually looks like, and
+ // what lets the byte comparison run at all.
+ var scanner = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.Read);
+
+ var ex = Assert.Throws(() =>
+ UpdateService.SwapInPlace(target, staged, () => parked, (t, p, s) =>
+ {
+ // A stranger lands at the canonical path during the gap — the un-parked
+ // File.Copy in RegistrationService, the one writer the park can't exclude.
+ File.WriteAllText(t, "some other build entirely");
+ // And the hold clears, so nothing but the guard under test stands
+ // between the cleanup and this file.
+ scanner.Dispose();
+ return UpdateService.PutSomethingBack(t, p, s); // the real judgement
+ }));
+
+ Assert.Contains(Path.GetFileName(staged), ex.Message);
+ Assert.True(File.Exists(staged), "the message names this file — it must be there");
+ Assert.Equal("the verified new version", File.ReadAllText(staged));
+ Assert.True(File.Exists(parked), "and so is this one");
+ }
+
+ [Fact]
+ public void Swap_StillTidiesTheStagedCopyAfterAnOrdinaryRollback()
+ {
+ // The other side of that guard: a plain rolled-back update leaves a ~6.5 MB
+ // copy nothing else would ever remove, and its message doesn't send anyone
+ // looking for it. That one still goes.
+ var target = Write("app.exe", "the working version");
+ var staged = Write("staged.exe", "the new version");
+ var parked = target + ".old";
+ var scanner = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.Read);
+
+ Assert.Throws(() =>
+ UpdateService.SwapInPlace(target, staged, () => parked, (t, p, s) =>
+ {
+ scanner.Dispose(); // the transient hold clears
+ File.Move(p, t); // and the old version goes back
+ return UpdateService.Recovery.OldVersionRestored;
+ }));
+
+ Assert.Equal("the working version", File.ReadAllText(target));
+ Assert.False(File.Exists(staged), "nothing sends the user looking for this one");
+ }
+
+ // ---- the startup sweep ----
+
+ [Fact]
+ public void Sweep_ClearsWhatEarlierUpdatesLeftBehind()
+ {
+ var exe = Write("app.exe", "the installed version");
+ Write("app.exe.old", "last version");
+ Write($"app.exe.old-{Environment.ProcessId}-3", "the version before that");
+ Write(".aperture-update-staged.exe", "abandoned by a pre-0.6.4 failure");
+ Write(".aperture-update-staged-999999.exe", "abandoned by a dead process");
+
+ UpdateService.CleanupOldBinaries(exe);
+
+ Assert.Equal(new[] { exe }, Directory.GetFiles(_dir));
+ }
+
+ [Fact]
+ public void Sweep_LeavesALiveWindowsStagingCopyAlone()
+ {
+ var exe = Write("app.exe", "the installed version");
+ var mine = Write($".aperture-update-staged-{Environment.ProcessId}.exe", "mid-update right now");
+
+ UpdateService.CleanupOldBinaries(exe);
+
+ Assert.True(File.Exists(mine), "deleting it would break that window's update");
+ }
+
+ [Fact]
+ public void Sweep_DoesNothingAtAllWhenTheInstallHasNoExe()
+ {
+ // A failed swap AND a failed rollback leaves the `.old` as the only working
+ // binary in the folder. Sweeping it is the one unrecoverable mistake available
+ // here, so the sweep declines to run rather than tidying an install to death.
+ var missing = Path.Combine(_dir, "app.exe");
+ var lastHope = Write("app.exe.old", "the only binary left");
+ var staged = Write(".aperture-update-staged.exe", "the verified new one");
+
+ UpdateService.CleanupOldBinaries(missing);
+
+ Assert.True(File.Exists(lastHope));
+ Assert.True(File.Exists(staged));
+ }
+
+ [Fact]
+ public void ParkingName_TreatsADirectoryAsTaken()
+ {
+ // File.Exists is false for a directory, so on its own it would call the name
+ // free, hand it over three times, and then blame a race that isn't happening.
+ var target = Write("app.exe", "current");
+ Directory.CreateDirectory(target + ".old");
+
+ Assert.NotEqual(target + ".old", UpdateService.ChooseParkingName(target));
+ }
+
+ [Theory]
+ // Written before 0.6.4, so no process owns it — anyone who hit the bug this
+ // release fixes has one of these, and nothing else will ever reclaim it.
+ [InlineData(".aperture-update-staged.exe", true)]
+ // A dead process's leftover.
+ [InlineData(".aperture-update-staged-999999.exe", true)]
+ // Not a staging file at all.
+ [InlineData("something-else.exe", false)]
+ [InlineData("Aperture.exe", false)]
+ // Ours by prefix but with a suffix we didn't write — leave it alone rather than
+ // guess.
+ [InlineData(".aperture-update-staged-notapid.exe", false)]
+ public void StagedLeftovers_AreReclaimedOnlyWhenNobodyOwnsThem(string name, bool reclaim)
+ => Assert.Equal(reclaim, UpdateService.IsReclaimableStagedFile(Path.Combine(_dir, name)));
+
+ [Fact]
+ public void AStagedFileBelongingToALiveProcess_IsLeftAlone()
+ {
+ // The reason the name carries a process id at all: two windows updating at
+ // once must not delete each other's copy mid-rename. During the swap the
+ // owner has it closed, not locked, so nothing else protects it.
+ var mine = Path.Combine(_dir, $".aperture-update-staged-{Environment.ProcessId}.exe");
+ Assert.False(UpdateService.IsReclaimableStagedFile(mine));
+ }
+
+ [Fact]
+ public void ParkingName_IsThePlainOneWhenNothingIsInTheWay()
+ => Assert.Equal(Path.Combine(_dir, "app.exe") + ".old",
+ UpdateService.ChooseParkingName(Path.Combine(_dir, "app.exe")));
+
+ [Fact]
+ public void ParkingName_ReusesThePlainOneWhenTheOldFileIsJustStale()
+ {
+ var target = Write("app.exe", "current");
+ Write("app.exe.old", "left by a finished update");
+ Assert.Equal(target + ".old", UpdateService.ChooseParkingName(target));
+ Assert.False(File.Exists(target + ".old"), "the stale one is cleared out of the way");
+ }
+
+ [Fact]
+ public void ParkingName_StepsAsideWhenTheOldFileIsSomeonesRunningImage()
+ {
+ // The last way to hit "Cannot create a file when that file already exists":
+ // a window that was open during an earlier update is still executing
+ // `…exe.old`, and this instance genuinely does need the update it's asking
+ // for — so no version check can save it. Don't collide; park elsewhere.
+ var target = Write("app.exe", "current");
+ var stuck = Write("app.exe.old", "another window is running this");
+ using var held = new FileStream(stuck, FileMode.Open, FileAccess.Read, FileShare.Read);
+
+ var chosen = UpdateService.ChooseParkingName(target);
+
+ Assert.NotEqual(stuck, chosen);
+ Assert.StartsWith(stuck + "-", chosen);
+ Assert.True(File.Exists(stuck), "the window still running it keeps its image");
+ // And the swap it enables actually works.
+ var staged = Write("staged.exe", "the new version");
+ UpdateService.SwapInPlace(target, staged, chosen);
+ Assert.Equal("the new version", File.ReadAllText(target));
+ Assert.Equal("current", File.ReadAllText(chosen));
+ }
+
+ [Fact]
+ public void ParkingName_KeepsLookingWhenThePidVariantIsTakenToo()
+ {
+ // Process ids get reused. `…old-` can already belong to a window still
+ // running an image an earlier holder of this pid parked there — so assuming
+ // the pid variant is free brings the original collision back by another
+ // route. Both taken means take a third name, not fail.
+ var target = Write("app.exe", "current");
+ var first = Write("app.exe.old", "window A is running this");
+ var second = Write($"app.exe.old-{Environment.ProcessId}", "window B is running this");
+ using var heldA = new FileStream(first, FileMode.Open, FileAccess.Read, FileShare.Read);
+ using var heldB = new FileStream(second, FileMode.Open, FileAccess.Read, FileShare.Read);
+
+ var chosen = UpdateService.ChooseParkingName(target);
+
+ Assert.False(File.Exists(chosen), "the chosen name must actually be free");
+ Assert.NotEqual(first, chosen);
+ Assert.NotEqual(second, chosen);
+
+ // And the swap onto it works, which is the whole point.
+ var staged = Write("staged.exe", "the new version");
+ UpdateService.SwapInPlace(target, staged, chosen);
+ Assert.Equal("the new version", File.ReadAllText(target));
+ Assert.Equal("current", File.ReadAllText(chosen));
+ }
+
+ [Fact]
+ public void ParkingName_StaysWithinTheSweepsReach()
+ {
+ // Whatever name it picks has to be one CleanupOldBinaries will reclaim later,
+ // or stepping aside just trades one leak for another. Asserted by running the
+ // sweep's actual glob rather than by eyeballing the prefix — a string check
+ // passes for names the enumerator would never return.
+ var target = Write("app.exe", "current");
+ Write("app.exe.old", "taken");
+ using var held = new FileStream(target + ".old", FileMode.Open, FileAccess.Read, FileShare.Read);
+
+ var chosen = UpdateService.ChooseParkingName(target);
+ File.WriteAllText(chosen, "parked here");
+
+ Assert.Contains(chosen, Directory.EnumerateFiles(_dir, "app.exe.old*"));
+ }
+
+ [Fact]
+ public void ParkingName_FallsBackToAFreeNameWhenEveryCandidateIsTaken()
+ {
+ // Past the end of the probe list. Handing back the plain name here — a name
+ // just proven undeletable — would walk straight into the rename error this
+ // release exists to remove, which is not "failing honestly", it IS the bug.
+ var target = Write("app.exe", "current");
+ var pid = Environment.ProcessId;
+ var names = new List { target + ".old", $"{target}.old-{pid}" };
+ for (var n = 1; n <= 20; n++) names.Add($"{target}.old-{pid}-{n}");
+
+ var held = new List();
+ try
+ {
+ foreach (var n in names)
+ {
+ File.WriteAllText(n, "someone is running this");
+ held.Add(new FileStream(n, FileMode.Open, FileAccess.Read, FileShare.Read));
+ }
+
+ var chosen = UpdateService.ChooseParkingName(target);
+
+ Assert.False(File.Exists(chosen), "the fallback must not be a name already taken");
+ Assert.StartsWith(Path.Combine(_dir, "app.exe.old"), chosen); // still swept
+ // And it works, which is the only thing that matters at this point.
+ var staged = Write("staged.exe", "the new version");
+ UpdateService.SwapInPlace(target, staged, chosen);
+ Assert.Equal("the new version", File.ReadAllText(target));
+ }
+ finally { foreach (var h in held) h.Dispose(); }
+ }
+
+ // ---- losing the race between choosing a name and taking it ----
+
+ [Fact]
+ public void Swap_TakesAnotherNameWhenASiblingClaimsTheOneItChose()
+ {
+ // ChooseParkingName reports what was free when it looked; nothing reserves it.
+ // The plain `…exe.old` is the one candidate two processes can both pick (every
+ // other shape carries a process id), so two windows updating at once can each
+ // be told it is free — and the second one's rename lands on the first one's
+ // image. Re-deciding is the only thing that closes it.
+ var target = Write("app.exe", "current");
+ var staged = Write("staged.exe", "the new version");
+ var stolen = target + ".old";
+ var free = target + ".old-later";
+ FileStream? sibling = null;
+
+ var calls = 0;
+ string Chooser()
+ {
+ if (++calls > 1) return free;
+ // First answer is honestly free at this instant; a sibling takes it in the
+ // gap before the rename.
+ File.WriteAllText(stolen, "the sibling's running image");
+ sibling = new FileStream(stolen, FileMode.Open, FileAccess.Read, FileShare.Read);
+ return stolen;
+ }
+
+ try
+ {
+ UpdateService.SwapInPlace(target, staged, Chooser);
+
+ Assert.Equal(2, calls);
+ Assert.Equal("the new version", File.ReadAllText(target));
+ Assert.Equal("current", File.ReadAllText(free));
+ Assert.Equal("the sibling's running image", File.ReadAllText(stolen));
+ }
+ finally { sibling?.Dispose(); }
+ }
+
+ [Fact]
+ public void Swap_ExplainsItselfWhenItKeepsLosingTheRace()
+ {
+ // Losing the race every time over the full park budget means something another
+ // spin won't fix. What must NOT happen is the bare "Cannot create a file when
+ // that file already exists" reaching the user, since that is the report this
+ // whole release started from.
+ var target = Write("app.exe", "current");
+ var staged = Write("staged.exe", "the new version");
+ var stuck = Write("app.exe.old", "always taken");
+ using var held = new FileStream(stuck, FileMode.Open, FileAccess.Read, FileShare.Read);
+
+ var ex = Assert.Throws(
+ () => UpdateService.SwapInPlace(target, staged, () => stuck));
+
+ Assert.DoesNotContain("Cannot create a file", ex.Message);
+ Assert.Contains("Close the other windows", ex.Message);
+ // And the install is exactly as it was.
+ Assert.Equal("current", File.ReadAllText(target));
+ Assert.False(File.Exists(staged), "nothing was swapped, so the staged copy is litter");
+ }
+
+ [Fact]
+ public void Swap_RollsBackToWhereverItActuallyParked_NotWhereItFirstMeantTo()
+ {
+ // After a retry the parked file is under the SECOND name. A rollback that moved
+ // back the first name would restore nothing and lose the working exe outright.
+ var target = Write("app.exe", "the working version");
+ var staged = Write("staged.exe", "the new version");
+ var stolen = target + ".old";
+ var free = target + ".old-later";
+ FileStream? sibling = null;
+
+ var calls = 0;
+ string Chooser()
+ {
+ if (++calls > 1) return free;
+ File.WriteAllText(stolen, "the sibling's running image");
+ sibling = new FileStream(stolen, FileMode.Open, FileAccess.Read, FileShare.Read);
+ return stolen;
+ }
+
+ try
+ {
+ // Hold the staged file so its move into place fails, forcing the rollback.
+ using (var _ = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.None))
+ Assert.ThrowsAny(() => UpdateService.SwapInPlace(target, staged, Chooser));
+
+ Assert.True(File.Exists(target), "the rollback must restore the running exe");
+ Assert.Equal("the working version", File.ReadAllText(target));
+ }
+ finally { sibling?.Dispose(); }
+ }
+
+ [Fact]
+ public void ADeniedRenameIsExplainedRatherThanRetried()
+ {
+ // The two decisions a denial has to get right, and the reason they are named
+ // predicates: the state that produces one can't be staged inside a test — it
+ // needs a real ACL denial or a security product — so what is pinned is what the
+ // code does GIVEN that failure, not the route to it.
+ var denied = new UnauthorizedAccessException("Access to the path is denied.");
+ var raced = new IOException("x", unchecked((int)0x800700B7));
+
+ // Explained by us: its own message doesn't even name a path.
+ Assert.True(UpdateService.IsParkFailureWeExplain(denied));
+ Assert.True(UpdateService.IsParkFailureWeExplain(raced));
+ // A sharing violation is in for the same reason. Its text — "the process cannot
+ // access the file because it is being used by another process" — names no file
+ // either: File.Move's two-argument overload hands no path to the translator, so
+ // NOTHING it raises here carries one.
+ Assert.True(UpdateService.IsParkFailureWeExplain(
+ new IOException("in use", unchecked((int)0x80070020))));
+ // Left alone: a full disk does say something specific, and our own bugs should
+ // never be dressed up as an expected condition.
+ Assert.False(UpdateService.IsParkFailureWeExplain(
+ new IOException("full", unchecked((int)0x80070070)))); // ERROR_DISK_FULL
+ Assert.False(UpdateService.IsParkFailureWeExplain(new InvalidOperationException("our bug")));
+
+ // And a denial is not retried: the same call will be denied again, so spending
+ // the park budget on it only adds a second of frozen UI before the same report.
+ // A sharing violation is the opposite — whoever has the file open is finishing
+ // something, and waiting is exactly what helps.
+ var inUse = new IOException("in use", unchecked((int)0x80070020));
+ Assert.False(UpdateService.ShouldRetryPark(denied, 1));
+ Assert.True(UpdateService.ShouldRetryPark(raced, 1));
+ Assert.True(UpdateService.ShouldRetryPark(inUse, 1));
+ }
+
+ [Fact]
+ public void SomethingHoldingTheFileIsNotSomethingRacingForItsName()
+ {
+ // Both reach the park's exhaustion, and the remedies differ: one is "close your
+ // other windows", the other is "wait for whatever has it open". Getting this
+ // arm wrong sends people hunting for a window that isn't there.
+ var target = Write("app.exe", "held by something");
+ var msg = UpdateService.ParkFailureMessage(
+ target, new IOException("in use", unchecked((int)0x80070020)));
+
+ Assert.Contains("has " + target + " open", msg);
+ Assert.DoesNotContain("Close the other windows", msg);
+ }
+
+ [Fact]
+ public void ADeniedRenameIsNotBlamedOnOtherWindows()
+ {
+ // File.Move raises UnauthorizedAccessException for a denied rename — a security
+ // product guarding the folder, typically — and its own text is "Access to the
+ // path is denied" with no path in it. Telling someone to close their other
+ // windows would send them after a race that isn't happening.
+ //
+ // The trigger needs a real ACL denial and can't be staged from inside a test,
+ // so what's pinned here is the routing: given that failure, this is what gets
+ // said. `ParkTarget` passes the exception straight to it.
+ var target = Write("app.exe", "present, and irrelevant to this arm");
+ var msg = UpdateService.ParkFailureMessage(target, new UnauthorizedAccessException("x"));
+
+ Assert.Contains("security product", msg);
+ Assert.Contains(target, msg); // the raw one has no path at all
+ Assert.DoesNotContain("Close the other windows", msg);
+ }
+
+ [Fact]
+ public void ARaceIsBlamedOnOtherWindowsOnlyWhenAProgramIsActuallyThere()
+ {
+ var there = Write("app.exe", "still where it should be");
+ var gone = Path.Combine(_dir, "vanished.exe");
+ var raced = new IOException("x", unchecked((int)0x800700B7));
+
+ Assert.Contains("Close the other windows", UpdateService.ParkFailureMessage(there, raced));
+ // Nothing at the path: closing windows cannot conjure a program file.
+ var missing = UpdateService.ParkFailureMessage(gone, raced);
+ Assert.DoesNotContain("Close the other windows", missing);
+ Assert.Contains("wait a moment and try again", missing);
+ }
+
+ [Theory]
+ // The two shapes a sibling's in-progress swap presents, and the only two where
+ // going round again can change the answer.
+ [InlineData(183, true)] // ERROR_ALREADY_EXISTS — it took the name we chose
+ [InlineData(2, true)] // ERROR_FILE_NOT_FOUND — it has parked, not yet landed
+ [InlineData(3, true)] // ERROR_PATH_NOT_FOUND
+ [InlineData(80, false)] // ERROR_FILE_EXISTS — a different call's code; not ours
+ [InlineData(32, false)] // sharing violation: a different name won't help
+ [InlineData(112, false)] // disk full: waiting certainly won't
+ public void OnlyASiblingMidSwapIsWorthAnotherAttempt(int win32, bool retry)
+ => Assert.Equal(retry,
+ UpdateService.IsSiblingMidSwap(new IOException("x", unchecked((int)(0x80070000 | win32)))));
+
+ [Theory]
+ [InlineData(32, true)] // ERROR_SHARING_VIOLATION — something else has it open
+ [InlineData(33, true)] // ERROR_LOCK_VIOLATION
+ [InlineData(112, false)] // ERROR_DISK_FULL — not a "another window has it" case
+ [InlineData(5, false)] // ERROR_ACCESS_DENIED
+ public void OnlySharingViolationsMeanAnotherWindowHasTheFile(int win32, bool sharing)
+ => Assert.Equal(sharing,
+ UpdateService.IsSharingViolation(new IOException("x", unchecked((int)(0x80070000 | win32)))));
+}
diff --git a/tests/Aperture.Core.Tests/UpdateTests.cs b/tests/Aperture.Core.Tests/UpdateTests.cs
new file mode 100644
index 0000000..ae3b4c9
--- /dev/null
+++ b/tests/Aperture.Core.Tests/UpdateTests.cs
@@ -0,0 +1,247 @@
+using Aperture.App.Updates;
+
+namespace Aperture.Core.Tests;
+
+public class UpdateVersionTests
+{
+ [Theory]
+ [InlineData("v0.8.0-beta1", "0.8.0-beta1", true)]
+ [InlineData("0.8.0-beta1", "0.8.0-beta1", true)]
+ [InlineData("V0.8.0", "0.8.0", false)]
+ [InlineData("v0.8", "0.8.0", false)]
+ [InlineData("0.8.0-beta1+abc1234", "0.8.0-beta1", true)]
+ [InlineData("0.8.0+abc1234", "0.8.0", false)]
+ public void Parse_Roundtrips(string input, string expected, bool prerelease)
+ {
+ var v = UpdateVersion.Parse(input);
+ Assert.NotNull(v);
+ Assert.Equal(expected, v!.ToString());
+ Assert.Equal(prerelease, v.IsPrerelease);
+ }
+
+ [Theory]
+ [InlineData("garbage")]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("v-beta1")]
+ public void Parse_RejectsJunk(string? input) => Assert.Null(UpdateVersion.Parse(input));
+
+ [Theory]
+ [InlineData("0.8.0-beta1", "0.7.1-beta1", 1)]
+ [InlineData("0.8.0", "0.8.0-beta1", 1)]
+ [InlineData("0.8.0-beta2", "0.8.0-beta1", 1)]
+ [InlineData("0.8.0-rc1", "0.8.0-beta9", 1)]
+ [InlineData("0.8.0-beta1", "0.8.0-beta1", 0)]
+ public void CompareTo_Orders(string a, string b, int sign)
+ {
+ var va = UpdateVersion.Parse(a)!;
+ var vb = UpdateVersion.Parse(b)!;
+ Assert.Equal(sign, Math.Sign(va.CompareTo(vb)));
+ Assert.Equal(-sign, Math.Sign(vb.CompareTo(va)));
+ }
+}
+
+public class UpdateOfferTests
+{
+ private static ReleaseInfo Rel(string tag, bool prerelease) =>
+ new(tag, UpdateVersion.Parse(tag)!, prerelease, "", "Aperture-x.exe", "https://x/asset.exe", 1);
+
+ private static UpdateVersion Cur(string v) => UpdateVersion.Parse(v)!;
+
+ [Fact]
+ public void SupersededPrerelease_IsNotOffered()
+ {
+ Assert.False(UpdateOffer.ShowPrerelease(
+ Rel("v0.8.0-beta1", true), Rel("v0.8.0", false), Cur("0.8.0")));
+ }
+
+ [Fact]
+ public void PrereleaseAheadOfBoth_IsOffered()
+ {
+ Assert.True(UpdateOffer.ShowPrerelease(
+ Rel("v0.9.0-beta1", true), Rel("v0.8.0", false), Cur("0.8.0")));
+ }
+
+ [Fact]
+ public void NextPrerelease_IsOfferedToSomeoneOnTheEarlierOne()
+ {
+ Assert.True(UpdateOffer.ShowPrerelease(
+ Rel("v0.8.0-beta2", true), Rel("v0.7.1", false), Cur("0.8.0-beta1")));
+ }
+
+ [Fact]
+ public void PrereleaseNewerThanStableButOlderThanRunning_IsNotOffered()
+ {
+ Assert.False(UpdateOffer.ShowPrerelease(
+ Rel("v0.9.0-beta1", true), Rel("v0.8.0", false), Cur("0.9.0-beta2")));
+ }
+
+ [Fact]
+ public void NoPrerelease_IsNotOffered()
+ => Assert.False(UpdateOffer.ShowPrerelease(null, Rel("v0.8.0", false), Cur("0.8.0")));
+
+ [Fact]
+ public void PrereleaseWithNoStablePublishedYet_IsJudgedAgainstTheRunningVersion()
+ {
+ Assert.True(UpdateOffer.ShowPrerelease(Rel("v0.8.0-beta1", true), null, Cur("0.7.1-beta1")));
+ Assert.False(UpdateOffer.ShowPrerelease(Rel("v0.7.1-beta1", true), null, Cur("0.8.0-beta1")));
+ }
+
+ [Fact]
+ public void UnknownRunningVersion_StillRespectsSupersession()
+ {
+ Assert.False(UpdateOffer.ShowPrerelease(Rel("v0.8.0-beta1", true), Rel("v0.8.0", false), null));
+ Assert.True(UpdateOffer.ShowPrerelease(Rel("v0.9.0-beta1", true), Rel("v0.8.0", false), null));
+ }
+
+ [Fact]
+ public void NothingToCompareAgainst_OffersNoPrerelease()
+ => Assert.False(UpdateOffer.ShowPrerelease(Rel("v0.1.0-alpha1", true), null, null));
+
+ [Theory]
+ [InlineData("v0.8.0", "0.7.1", true)]
+ [InlineData("v0.8.0", "0.8.0", false)]
+ [InlineData("v0.7.1", "0.8.0", false)]
+ [InlineData("v0.8.0", "0.8.0-beta1", true)]
+ public void StableUpdate_OfferedOnlyWhenNewer(string tag, string current, bool expected)
+ => Assert.Equal(expected, UpdateOffer.ShowStableUpdate(Rel(tag, false), Cur(current)));
+
+ [Fact]
+ public void StableUpdate_NeedsAReleaseToOffer()
+ => Assert.False(UpdateOffer.ShowStableUpdate(null, Cur("0.8.0-beta1")));
+
+ [Fact]
+ public void UnreadableRunningVersion_StillOffersTheStableRelease()
+ => Assert.True(UpdateOffer.ShowStableUpdate(Rel("v0.8.0", false), null));
+
+ [Theory]
+ [InlineData("0.8.0-beta1", "0.8.0-beta1", "0.7.1-beta1", true)]
+ [InlineData("0.8.0-beta2", "0.8.0-beta1", "0.7.1-beta1", true)]
+ [InlineData("0.7.1-beta1", "0.8.0-beta1", "0.7.1-beta1", false)]
+ [InlineData("0.8.0-beta1", "0.8.0-beta2", "0.7.1-beta1", true)]
+ public void RestartIsNeededWhenTheDiskHasMovedPastUsOrCaughtUpWithTheOffer(
+ string onDisk, string wanted, string running, bool restart)
+ => Assert.Equal(restart,
+ UpdateOffer.NeedsRestartNotUpdate(Cur(onDisk), Cur(wanted), Cur(running)));
+
+ [Fact]
+ public void UnreadableVersions_LetTheUpdateProceed()
+ {
+ Assert.False(UpdateOffer.NeedsRestartNotUpdate(null, Cur("0.8.0-beta1"), Cur("0.7.1-beta1")));
+ Assert.False(UpdateOffer.NeedsRestartNotUpdate(null, null, null));
+ }
+
+ [Fact]
+ public void AnUnknownRunningVersion_StillCatchesTheAlreadyOfferedCase()
+ {
+ Assert.True(UpdateOffer.NeedsRestartNotUpdate(Cur("0.8.0-beta1"), Cur("0.8.0-beta1"), null));
+ Assert.False(UpdateOffer.NeedsRestartNotUpdate(Cur("0.7.1-beta1"), Cur("0.8.0-beta1"), null));
+ }
+
+ [Theory]
+ [InlineData("0.8.0", "0.7.0", true)]
+ [InlineData("0.8.0-beta1", "0.7.0", true)]
+ [InlineData("0.7.0", "0.7.0", false)]
+ [InlineData("0.7.0", "0.8.0", false)]
+ [InlineData("0.7.0", "0.7.0-beta1", true)]
+ public void RestartIsNeededWhenTheDiskLeadsTheProcessEvenWithoutAnOffer(
+ string onDisk, string running, bool offer)
+ => Assert.Equal(offer,
+ UpdateOffer.NeedsRestartNotUpdate(Cur(onDisk), wanted: null, Cur(running)));
+}
+
+public class ReleaseFeedTests
+{
+ private static string Release(string tag, bool prerelease, bool draft = false, bool withAsset = true, string? assetName = null)
+ {
+ var name = assetName ?? $"Aperture-{tag}-win-x64.exe";
+ var assets = withAsset
+ ? $@"[{{""name"":""{name}"",""browser_download_url"":""https://example.com/{tag}.exe"",""size"":100}}]"
+ : "[]";
+ return $@"{{""tag_name"":""{tag}"",""prerelease"":{prerelease.ToString().ToLowerInvariant()},
+ ""draft"":{draft.ToString().ToLowerInvariant()},""html_url"":""https://example.com/{tag}"",
+ ""assets"":{assets}}}";
+ }
+
+ private static string Feed(params string[] releases) => "[" + string.Join(",", releases) + "]";
+
+ [Fact]
+ public void Select_PicksNewestOfEachChannel_ByVersionNotListOrder()
+ {
+ var json = Feed(
+ Release("v0.7.1-beta1", prerelease: true),
+ Release("v0.9.0-beta1", prerelease: true),
+ Release("v0.8.0", prerelease: false),
+ Release("v0.7.0", prerelease: false));
+ var check = ReleaseFeed.Select(json);
+ Assert.Equal("v0.8.0", check.Stable?.Tag);
+ Assert.Equal("v0.9.0-beta1", check.PrereleaseRelease?.Tag);
+ }
+
+ [Fact]
+ public void Select_IgnoresDraftsAndAssetlessReleases()
+ {
+ var json = Feed(
+ Release("v0.9.0", prerelease: false, draft: true),
+ Release("v0.8.0", prerelease: false, withAsset: false),
+ Release("v0.7.0", prerelease: false));
+ var check = ReleaseFeed.Select(json);
+ Assert.Equal("v0.7.0", check.Stable?.Tag);
+ }
+
+ [Fact]
+ public void Select_IgnoresMidgetAssetSuffix()
+ {
+ var json = Feed(
+ Release("v0.9.0", prerelease: false, assetName: "Aperture-v0.9.0-win-x64-net10.exe"),
+ Release("v0.8.0-beta1", prerelease: true));
+ var check = ReleaseFeed.Select(json);
+ Assert.Null(check.Stable);
+ Assert.Equal("v0.8.0-beta1", check.PrereleaseRelease?.Tag);
+ Assert.Equal("Aperture-v0.8.0-beta1-win-x64.exe", check.PrereleaseRelease?.AssetName);
+ }
+
+ [Fact]
+ public void Select_EmptyFeed_YieldsNulls()
+ {
+ var check = ReleaseFeed.Select("[]");
+ Assert.Null(check.Stable);
+ Assert.Null(check.PrereleaseRelease);
+ }
+
+ [Fact]
+ public void Select_CarriesAssetDetails()
+ {
+ var check = ReleaseFeed.Select(Feed(Release("v0.8.0-beta1", prerelease: true)));
+ Assert.Equal("Aperture-v0.8.0-beta1-win-x64.exe", check.PrereleaseRelease?.AssetName);
+ Assert.Equal(100, check.PrereleaseRelease?.AssetSize);
+ Assert.StartsWith("https://example.com/", check.PrereleaseRelease?.AssetUrl);
+ }
+
+ [Fact]
+ public void MislabelledPrerelease_IsNotTreatedAsStable()
+ {
+ // Aperture's published betas have shipped with prerelease:false on GitHub.
+ // The version tail is the safety net.
+ var check = ReleaseFeed.Select(Feed(Release("v0.8.0-beta1", prerelease: false)));
+ Assert.Null(check.Stable);
+ Assert.Equal("v0.8.0-beta1", check.PrereleaseRelease!.Tag);
+ Assert.True(check.PrereleaseRelease.Prerelease);
+ }
+
+ [Fact]
+ public void AgainstTheRealApertureFeedShape_CurrentBetaUsersCanUpdate()
+ {
+ var json = Feed(
+ Release("v0.8.0-beta1", prerelease: false),
+ Release("v0.7.1-beta1", prerelease: false),
+ Release("v0.7.0-beta1", prerelease: false));
+ var check = ReleaseFeed.Select(json);
+ Assert.Null(check.Stable);
+ Assert.Equal("v0.8.0-beta1", check.PrereleaseRelease!.Tag);
+
+ Assert.False(UpdateOffer.ShowPrerelease(check.PrereleaseRelease, check.Stable, UpdateVersion.Parse("0.8.0-beta1")));
+ Assert.True(UpdateOffer.ShowPrerelease(check.PrereleaseRelease, check.Stable, UpdateVersion.Parse("0.7.1-beta1")));
+ Assert.False(UpdateOffer.ShowStableUpdate(check.Stable, UpdateVersion.Parse("0.8.0-beta1")));
+ }
+}
diff --git a/tests/Aperture.Core.Tests/WindowsStartupTests.cs b/tests/Aperture.Core.Tests/WindowsStartupTests.cs
new file mode 100644
index 0000000..560fb41
--- /dev/null
+++ b/tests/Aperture.Core.Tests/WindowsStartupTests.cs
@@ -0,0 +1,181 @@
+using Aperture.Core.Startup;
+
+namespace Aperture.Core.Tests;
+
+public class WindowsStartupTests
+{
+ private sealed class DictRunKeyStore : IRunKeyStore
+ {
+ public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ public string? GetValue(string name) => Values.TryGetValue(name, out var v) ? v : null;
+ public void SetValue(string name, string data) => Values[name] = data;
+ public void DeleteValue(string name) => Values.Remove(name);
+ }
+
+ private sealed class ThrowingRunKeyStore : IRunKeyStore
+ {
+ public string? GetValue(string name) => throw new IOException("registry unavailable");
+ public void SetValue(string name, string data) => throw new UnauthorizedAccessException("denied");
+ public void DeleteValue(string name) => throw new IOException("registry unavailable");
+ }
+
+ private const string Exe = @"C:\Apps\Aperture.exe";
+ private const string Other = @"C:\Apps\Other\Aperture.exe";
+
+ [Fact]
+ public void FormatCommand_QuotesThePath()
+ => Assert.Equal("\"C:\\Apps\\Aperture.exe\"", WindowsStartup.FormatCommand(Exe));
+
+ [Theory]
+ [InlineData("\"C:\\Apps\\Aperture.exe\"", @"C:\Apps\Aperture.exe")]
+ [InlineData(@"C:\Apps\Aperture.exe", @"C:\Apps\Aperture.exe")]
+ [InlineData("\"C:\\Apps\\Aperture.exe\" --minimized", @"C:\Apps\Aperture.exe")]
+ [InlineData(@"C:\Apps\Aperture.exe --minimized", @"C:\Apps\Aperture.exe")]
+ [InlineData(" \"C:\\Apps\\Aperture.exe\" ", @"C:\Apps\Aperture.exe")]
+ [InlineData("", null)]
+ [InlineData(" ", null)]
+ [InlineData("\"\"", null)]
+ public void ExePathFromCommand_ReadsQuotedAndUnquoted(string? stored, string? expected)
+ => Assert.Equal(expected, WindowsStartup.ExePathFromCommand(stored));
+
+ [Fact]
+ public void PointsAtThisExe_MatchesIgnoringQuotesAndCase()
+ {
+ Assert.True(WindowsStartup.PointsAtThisExe("\"C:\\Apps\\Aperture.exe\"", Exe));
+ Assert.True(WindowsStartup.PointsAtThisExe(Exe, Exe));
+ Assert.True(WindowsStartup.PointsAtThisExe("\"C:\\APPS\\APERTURE.EXE\"", Exe));
+ Assert.False(WindowsStartup.PointsAtThisExe("\"C:\\Apps\\Other\\Aperture.exe\"", Exe));
+ Assert.False(WindowsStartup.PointsAtThisExe(null, Exe));
+ Assert.False(WindowsStartup.PointsAtThisExe("", Exe));
+ }
+
+ [Fact]
+ public void PointsAtThisExe_InvalidPath_DoesNotThrow()
+ => Assert.False(WindowsStartup.PointsAtThisExe("\"C:\\Apps\\Aperture.exe\"", ":::not-a-path:::"));
+
+ [Fact]
+ public void IsEnabled_TrueOnlyWhenValuePointsAtThisExe()
+ {
+ var store = new DictRunKeyStore();
+ Assert.False(WindowsStartup.IsEnabled(store, Exe));
+
+ store.SetValue(WindowsStartup.ValueName, WindowsStartup.FormatCommand(Other));
+ Assert.False(WindowsStartup.IsEnabled(store, Exe));
+
+ store.SetValue(WindowsStartup.ValueName, WindowsStartup.FormatCommand(Exe));
+ Assert.True(WindowsStartup.IsEnabled(store, Exe));
+ Assert.False(WindowsStartup.IsEnabled(store, Other));
+ }
+
+ [Fact]
+ public void IsEnabled_MissingExePath_IsFalse()
+ => Assert.False(WindowsStartup.IsEnabled(new DictRunKeyStore(), null));
+
+ [Fact]
+ public void TrySetEnabled_WritesQuotedPathAndDeletes()
+ {
+ var store = new DictRunKeyStore();
+ Assert.True(WindowsStartup.TrySetEnabled(store, Exe, enabled: true));
+ Assert.Equal("\"C:\\Apps\\Aperture.exe\"", store.GetValue(WindowsStartup.ValueName));
+ Assert.True(WindowsStartup.IsEnabled(store, Exe));
+
+ Assert.True(WindowsStartup.TrySetEnabled(store, Exe, enabled: false));
+ Assert.Null(store.GetValue(WindowsStartup.ValueName));
+ Assert.False(WindowsStartup.IsEnabled(store, Exe));
+ }
+
+ [Fact]
+ public void TrySetEnabled_MissingExePath_IsFalseAndWritesNothing()
+ {
+ var store = new DictRunKeyStore();
+ Assert.False(WindowsStartup.TrySetEnabled(store, null, enabled: true));
+ Assert.Empty(store.Values);
+ }
+
+ [Fact]
+ public void RegistryFailures_NeverThrow()
+ {
+ var store = new ThrowingRunKeyStore();
+ Assert.False(WindowsStartup.IsEnabled(store, Exe));
+ Assert.False(WindowsStartup.TrySetEnabled(store, Exe, enabled: true));
+ Assert.False(WindowsStartup.TrySetEnabled(store, Exe, enabled: false));
+ }
+
+ [Fact]
+ public void HasValue_TracksPresenceIndependentOfPath()
+ {
+ var store = new DictRunKeyStore();
+ Assert.False(WindowsStartup.HasValue(store));
+ store.SetValue(WindowsStartup.ValueName, WindowsStartup.FormatCommand(Other));
+ Assert.True(WindowsStartup.HasValue(store));
+ Assert.False(WindowsStartup.IsEnabled(store, Exe));
+ }
+
+ [Fact]
+ public void TryRetargetIfPointsAt_RewritesADownloadPathToTheInstalledExe()
+ {
+ var store = new DictRunKeyStore();
+ var download = @"C:\Users\me\Downloads\Aperture-v0.8.0-beta1-win-x64.exe";
+ var installed = @"C:\Users\me\AppData\Local\Programs\Aperture\Aperture.exe";
+ WindowsStartup.TrySetEnabled(store, download, enabled: true);
+
+ Assert.True(WindowsStartup.TryRetargetIfPointsAt(store, download, installed));
+ Assert.True(WindowsStartup.IsEnabled(store, installed));
+ Assert.False(WindowsStartup.IsEnabled(store, download));
+ Assert.Equal(WindowsStartup.FormatCommand(installed), store.GetValue(WindowsStartup.ValueName));
+ }
+
+ [Fact]
+ public void TryRetargetIfPointsAt_DoesNotCreateARunValueTheUserNeverOptedInto()
+ {
+ var store = new DictRunKeyStore();
+ var installed = @"C:\Users\me\AppData\Local\Programs\Aperture\Aperture.exe";
+ Assert.False(WindowsStartup.TryRetargetIfPointsAt(store, Exe, installed));
+ Assert.False(WindowsStartup.HasValue(store));
+ }
+
+ [Fact]
+ public void HasValueThenSetEnabled_IsHowAnInstalledLaunchRepointsAStaleDownloadPath()
+ {
+ // The startup refresh: a Run value exists (pointing at a Downloads exe)
+ // and this process is the AppData copy — rewrite, don't leave logon on
+ // a path that may already have been deleted by --finish-move.
+ var store = new DictRunKeyStore();
+ var download = @"C:\Users\me\Downloads\Aperture-v0.8.0-beta1-win-x64.exe";
+ var installed = @"C:\Users\me\AppData\Local\Programs\Aperture\Aperture.exe";
+ WindowsStartup.TrySetEnabled(store, download, enabled: true);
+ Assert.True(WindowsStartup.HasValue(store));
+ Assert.True(WindowsStartup.TrySetEnabled(store, installed, enabled: true));
+ Assert.True(WindowsStartup.IsEnabled(store, installed));
+ Assert.False(WindowsStartup.IsEnabled(store, download));
+ }
+
+ [Fact]
+ public void TryRetargetIfPointsAt_LeavesADifferentExeAlone()
+ {
+ var store = new DictRunKeyStore();
+ WindowsStartup.TrySetEnabled(store, Other, enabled: true);
+ Assert.False(WindowsStartup.TryRetargetIfPointsAt(store, Exe, @"C:\Apps\New\Aperture.exe"));
+ Assert.True(WindowsStartup.IsEnabled(store, Other));
+ }
+
+ [Fact]
+ public void CurrentUserRunKeyStore_RoundTripsAThrowawayValue()
+ {
+ var store = new CurrentUserRunKeyStore();
+ var name = "Aperture-test-" + Guid.NewGuid().ToString("N");
+ try
+ {
+ store.SetValue(name, "\"C:\\Apps\\Aperture.exe\"");
+ Assert.Equal("\"C:\\Apps\\Aperture.exe\"", store.GetValue(name));
+ store.DeleteValue(name);
+ Assert.Null(store.GetValue(name));
+ store.DeleteValue(name); // missing: must not throw
+ }
+ finally
+ {
+ try { store.DeleteValue(name); } catch { /* leftover */ }
+ }
+ }
+}