diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49bc9c8..7b6427e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to Aperture Image Viewer are documented here. The format fol
## [Unreleased]
+## [0.8.2-beta1] - 2026-08-21
+
+### Added
+- **Installed / portable badge** on the hamburger About — the same AppData-path check the updater already uses (`this process is %LocalAppData%\Programs\Aperture\Aperture.exe`), shown as a quiet caption next to the version.
+
+### Changed
+- **Start with Windows from a portable copy** now asks to install first instead of writing `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` to a Downloads (or any portable) path. Declining or cancelling leaves autostart off. After install, the Run value names the AppData exe. Installed copies keep writing and deleting the Run key as before.
+
## [0.8.1-beta1] - 2026-08-21
### Added
diff --git a/src/Aperture.App/Aperture.App.csproj b/src/Aperture.App/Aperture.App.csproj
index 81725b2..7ec9d81 100644
--- a/src/Aperture.App/Aperture.App.csproj
+++ b/src/Aperture.App/Aperture.App.csproj
@@ -16,7 +16,7 @@
true
Assets\aperture.ico
Aperture
- 0.8.1-beta1
+ 0.8.2-beta1
Aperture Image Viewer
Aperture Image Viewer
A fast local image & video browser for Windows.
diff --git a/src/Aperture.App/MainWindow.xaml b/src/Aperture.App/MainWindow.xaml
index 08e0a9a..fd4c9a3 100644
--- a/src/Aperture.App/MainWindow.xaml
+++ b/src/Aperture.App/MainWindow.xaml
@@ -369,8 +369,14 @@
+
+
+
+ FontSize="11" Margin="0,4,0,0" />
+ /// Caption the About surface and updater already share: "installed" when
+ /// this process is the AppData exe, otherwise "portable". Same
+ /// check as — not "a copy exists
+ /// under AppData while this process is still a download."
+ ///
+ public static string RunningKind => IsRunningFromAppDataInstall() ? "installed" : "portable";
+
// ===== AppData install + Start Menu =====
/// Copy the current exe to %LocalAppData%\Programs\Aperture.
diff --git a/src/Aperture.App/ViewModels/MainViewModel.cs b/src/Aperture.App/ViewModels/MainViewModel.cs
index 767d126..2504671 100644
--- a/src/Aperture.App/ViewModels/MainViewModel.cs
+++ b/src/Aperture.App/ViewModels/MainViewModel.cs
@@ -264,14 +264,13 @@ private static string ProductTitle
}
}
- public string AppVersion
- {
- get
- {
- var kind = UpdateService.IsInstalled() ? "installed" : "portable";
- return ProductTitle + " (" + kind + ")";
- }
- }
+ public string AppVersion => ProductTitle;
+
+ ///
+ /// Quiet About caption: "installed" or "portable". Same source as the
+ /// updater — .
+ ///
+ public string InstallKindCaption => RegistrationService.RunningKind;
private string _onDiskVersionText = "";
public string OnDiskVersionText
@@ -293,12 +292,12 @@ public string OnDiskVersionText
///
/// Path the Run key should name: the AppData copy after an in-session install,
- /// otherwise this process's exe.
+ /// otherwise this process's exe. Same rule as .
///
private string? AutostartExePath =>
- _installedThisSession || RegistrationService.IsRunningFromAppDataInstall()
- ? RegistrationService.AppDataInstallExe
- : _exePath;
+ AutostartPolicy.TargetExe(
+ _exePath, RegistrationService.AppDataInstallExe,
+ RegistrationService.IsRunningFromAppDataInstall(), _installedThisSession);
// --- About / updater (hamburger header) --------------------------------
@@ -386,6 +385,7 @@ public void OnSettingsOpened()
private void RefreshInstallUi()
{
OnPropertyChanged(nameof(AppVersion));
+ OnPropertyChanged(nameof(InstallKindCaption));
OnPropertyChanged(nameof(ShowInstall));
OnPropertyChanged(nameof(ShowUninstall));
}
@@ -571,15 +571,24 @@ private void FailUpdate(string message)
Notify(message, warn: true);
}
- private void InstallToThisPc()
+ private void InstallToThisPc() => TryInstallToThisPc(alreadyConfirmed: false);
+
+ ///
+ /// Existing AppData install flow. Returns true when the copy (and shortcuts)
+ /// landed; false if the user cancelled or install failed. A "move" install
+ /// still returns true after scheduling shutdown so Start-with-Windows can
+ /// write the AppData path before this process exits.
+ ///
+ private bool TryInstallToThisPc(bool alreadyConfirmed)
{
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;
+ if (!alreadyConfirmed
+ && 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 false;
var addDesktop = Ask("Also add a Desktop shortcut?") == System.Windows.MessageBoxResult.Yes;
@@ -620,7 +629,7 @@ private void InstallToThisPc()
psi.ArgumentList.Add(download);
Process.Start(psi);
System.Windows.Application.Current?.Shutdown();
- return;
+ return true;
}
var lines = new List
@@ -632,10 +641,12 @@ private void InstallToThisPc()
};
if (addDesktop) lines.Add("Desktop shortcut: added");
Notify(string.Join("\n", lines));
+ return true;
}
catch (Exception ex)
{
Notify("Couldn't complete install:\n\n" + ex.Message, warn: true);
+ return false;
}
}
@@ -929,21 +940,42 @@ 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.
+ /// (checked iff a value named Aperture points at the autostart exe). A
+ /// portable copy must not write a Downloads path — turning the checkbox on
+ /// asks to install first. Registry failures never throw; the checkbox
+ /// re-reads the actual key.
///
public bool StartWithWindows
{
get => WindowsStartup.IsEnabled(_runKeys, AutostartExePath);
set
{
+ var runningFromInstall = RegistrationService.IsRunningFromAppDataInstall();
if (value == WindowsStartup.IsEnabled(_runKeys, AutostartExePath))
return;
- WindowsStartup.TrySetEnabled(_runKeys, AutostartExePath, value);
+
+ if (AutostartPolicy.MustInstallFirst(value, runningFromInstall, _installedThisSession))
+ {
+ if (Ask(PortableAutostartPrompt()) != System.Windows.MessageBoxResult.Yes
+ || !TryInstallToThisPc(alreadyConfirmed: true))
+ {
+ OnPropertyChanged();
+ return;
+ }
+ }
+
+ AutostartPolicy.TryApply(
+ _runKeys, _exePath, RegistrationService.AppDataInstallExe,
+ value, RegistrationService.IsRunningFromAppDataInstall(), _installedThisSession);
OnPropertyChanged();
}
}
+ private string PortableAutostartPrompt() =>
+ "Start with Windows from a portable copy would write this path into the sign-in Run key:\n\n" +
+ (_exePath ?? "(unknown)") + "\n\n" +
+ "Install Aperture on this PC first? Windows will then launch the AppData copy.";
+
public string StatusText
{
get => _statusText;
diff --git a/src/Aperture.Core/Startup/AutostartPolicy.cs b/src/Aperture.Core/Startup/AutostartPolicy.cs
new file mode 100644
index 0000000..655a403
--- /dev/null
+++ b/src/Aperture.Core/Startup/AutostartPolicy.cs
@@ -0,0 +1,45 @@
+namespace Aperture.Core.Startup;
+
+///
+/// Start-with-Windows write rules. An installed copy (or one that just copied
+/// itself to AppData this session) may write or delete the Run key. A portable
+/// copy must not enable Run — that would point logon at a Downloads/USB path.
+///
+public static class AutostartPolicy
+{
+ ///
+ /// True when turning autostart on would write a portable path. The UI must
+ /// ask to install first instead of calling .
+ ///
+ public static bool MustInstallFirst(bool enable, bool runningFromInstall, bool installedThisSession)
+ => enable && !runningFromInstall && !installedThisSession;
+
+ ///
+ /// Path the Run key should name: the AppData exe after this process is (or
+ /// just became) the installed copy; otherwise the running portable path.
+ ///
+ public static string? TargetExe(
+ string? processExe, string installedExe, bool runningFromInstall, bool installedThisSession)
+ => runningFromInstall || installedThisSession ? installedExe : processExe;
+
+ ///
+ /// Writes or deletes the Run value. Returns false and writes nothing when
+ /// would target a portable path — the caller must
+ /// install first. Disabling always deletes the named value.
+ ///
+ public static bool TryApply(
+ IRunKeyStore store,
+ string? processExe,
+ string installedExe,
+ bool enable,
+ bool runningFromInstall,
+ bool installedThisSession)
+ {
+ if (MustInstallFirst(enable, runningFromInstall, installedThisSession))
+ return false;
+ return WindowsStartup.TrySetEnabled(
+ store,
+ TargetExe(processExe, installedExe, runningFromInstall, installedThisSession),
+ enable);
+ }
+}
diff --git a/tests/Aperture.Core.Tests/RegistrationServiceTests.cs b/tests/Aperture.Core.Tests/RegistrationServiceTests.cs
index a5741b5..ae2e1a3 100644
--- a/tests/Aperture.Core.Tests/RegistrationServiceTests.cs
+++ b/tests/Aperture.Core.Tests/RegistrationServiceTests.cs
@@ -1,4 +1,5 @@
using Aperture.App;
+using Aperture.App.Updates;
namespace Aperture.Core.Tests;
@@ -27,4 +28,43 @@ public void FinishMove_RefusesToDeleteTheInstalledCopy()
// We can't observe the skip except by it not throwing.
RegistrationService.FinishMove(RegistrationService.AppDataInstallExe);
}
+
+ [Fact]
+ public void RunningKind_MatchesIsRunningFromAppDataInstall()
+ {
+ // One source of truth: this process is the AppData exe, or it is portable.
+ // testhost is never %LocalAppData%\Programs\Aperture\Aperture.exe.
+ var runningInstalled = RegistrationService.IsRunningFromAppDataInstall();
+ var expectedKind = runningInstalled ? "installed" : "portable";
+
+ Assert.False(runningInstalled);
+ Assert.Equal("portable", RegistrationService.RunningKind);
+ Assert.Equal(expectedKind, RegistrationService.RunningKind);
+ Assert.Equal(runningInstalled, UpdateService.IsInstalled());
+ Assert.Equal(
+ PathsEqual(RegistrationService.CurrentExePath, RegistrationService.AppDataInstallExe),
+ runningInstalled);
+ }
+
+ [Fact]
+ public void RunningKind_IsNotWhetherAnAppDataCopyExists()
+ {
+ // A leftover AppData exe must not flip this testhost to "installed".
+ // The About badge and updater share IsRunningFromAppDataInstall, not
+ // IsInstalledToAppData (file exists).
+ var kindFromProcess = RegistrationService.IsRunningFromAppDataInstall()
+ ? "installed" : "portable";
+ Assert.Equal(kindFromProcess, RegistrationService.RunningKind);
+ Assert.Equal(kindFromProcess, UpdateService.IsInstalled() ? "installed" : "portable");
+ }
+
+ private static bool PathsEqual(string a, string b)
+ {
+ try
+ {
+ return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b),
+ StringComparison.OrdinalIgnoreCase);
+ }
+ catch { return false; }
+ }
}
diff --git a/tests/Aperture.Core.Tests/WindowsStartupTests.cs b/tests/Aperture.Core.Tests/WindowsStartupTests.cs
index 560fb41..19f84cb 100644
--- a/tests/Aperture.Core.Tests/WindowsStartupTests.cs
+++ b/tests/Aperture.Core.Tests/WindowsStartupTests.cs
@@ -160,6 +160,96 @@ public void TryRetargetIfPointsAt_LeavesADifferentExeAlone()
Assert.True(WindowsStartup.IsEnabled(store, Other));
}
+ [Fact]
+ public void MustInstallFirst_OnlyWhenEnablingAPortableCopy()
+ {
+ Assert.True(AutostartPolicy.MustInstallFirst(
+ enable: true, runningFromInstall: false, installedThisSession: false));
+ Assert.False(AutostartPolicy.MustInstallFirst(
+ enable: true, runningFromInstall: true, installedThisSession: false));
+ Assert.False(AutostartPolicy.MustInstallFirst(
+ enable: true, runningFromInstall: false, installedThisSession: true));
+ Assert.False(AutostartPolicy.MustInstallFirst(
+ enable: false, runningFromInstall: false, installedThisSession: false));
+ }
+
+ [Fact]
+ public void TargetExe_PrefersAppDataAfterInstall()
+ {
+ var download = @"C:\Users\me\Downloads\Aperture.exe";
+ var installed = @"C:\Users\me\AppData\Local\Programs\Aperture\Aperture.exe";
+ Assert.Equal(download, AutostartPolicy.TargetExe(download, installed, runningFromInstall: false, installedThisSession: false));
+ Assert.Equal(installed, AutostartPolicy.TargetExe(download, installed, runningFromInstall: true, installedThisSession: false));
+ Assert.Equal(installed, AutostartPolicy.TargetExe(download, installed, runningFromInstall: false, installedThisSession: true));
+ }
+
+ [Fact]
+ public void TryApply_PortableEnableDeclined_DoesNotWriteRun()
+ {
+ // The checkbox path: portable + Start-with-Windows, user declined install.
+ // MustInstallFirst is true, so TryApply must leave the store empty.
+ var store = new DictRunKeyStore();
+ var download = @"C:\Users\me\Downloads\Aperture-v0.8.1-beta1-win-x64.exe";
+ var installed = @"C:\Users\me\AppData\Local\Programs\Aperture\Aperture.exe";
+
+ Assert.True(AutostartPolicy.MustInstallFirst(
+ enable: true, runningFromInstall: false, installedThisSession: false));
+ Assert.False(AutostartPolicy.TryApply(
+ store, download, installed, enable: true,
+ runningFromInstall: false, installedThisSession: false));
+ Assert.False(WindowsStartup.HasValue(store));
+ Assert.Empty(store.Values);
+ Assert.False(WindowsStartup.IsEnabled(store, download));
+ Assert.False(WindowsStartup.IsEnabled(store, installed));
+ }
+
+ [Fact]
+ public void TryApply_AcceptedInstallThenRunPointsAtAppData()
+ {
+ // After the user accepts install, _installedThisSession is true (this
+ // process is still the download) and Run must name the AppData exe.
+ var store = new DictRunKeyStore();
+ var download = @"C:\Users\me\Downloads\Aperture-v0.8.1-beta1-win-x64.exe";
+ var installed = @"C:\Users\me\AppData\Local\Programs\Aperture\Aperture.exe";
+
+ Assert.True(AutostartPolicy.TryApply(
+ store, download, installed, enable: true,
+ runningFromInstall: false, installedThisSession: true));
+ Assert.True(WindowsStartup.IsEnabled(store, installed));
+ Assert.False(WindowsStartup.IsEnabled(store, download));
+ Assert.Equal(WindowsStartup.FormatCommand(installed), store.GetValue(WindowsStartup.ValueName));
+ }
+
+ [Fact]
+ public void TryApply_InstalledCopy_WritesAndDeletesRun()
+ {
+ var store = new DictRunKeyStore();
+ var installed = @"C:\Users\me\AppData\Local\Programs\Aperture\Aperture.exe";
+ Assert.True(AutostartPolicy.TryApply(
+ store, installed, installed, enable: true,
+ runningFromInstall: true, installedThisSession: false));
+ Assert.True(WindowsStartup.IsEnabled(store, installed));
+
+ Assert.True(AutostartPolicy.TryApply(
+ store, installed, installed, enable: false,
+ runningFromInstall: true, installedThisSession: false));
+ Assert.False(WindowsStartup.HasValue(store));
+ }
+
+ [Fact]
+ public void TryApply_PortableDisable_DeletesAStaleDownloadRunValue()
+ {
+ var store = new DictRunKeyStore();
+ var download = @"C:\Users\me\Downloads\Aperture.exe";
+ var installed = @"C:\Users\me\AppData\Local\Programs\Aperture\Aperture.exe";
+ WindowsStartup.TrySetEnabled(store, download, enabled: true);
+
+ Assert.True(AutostartPolicy.TryApply(
+ store, download, installed, enable: false,
+ runningFromInstall: false, installedThisSession: false));
+ Assert.False(WindowsStartup.HasValue(store));
+ }
+
[Fact]
public void CurrentUserRunKeyStore_RoundTripsAThrowawayValue()
{