Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Aperture.App/Aperture.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<UseWPF>true</UseWPF>
<ApplicationIcon>Assets\aperture.ico</ApplicationIcon>
<AssemblyName>Aperture</AssemblyName>
<Version>0.8.1-beta1</Version>
<Version>0.8.2-beta1</Version>
<AssemblyTitle>Aperture Image Viewer</AssemblyTitle>
<Product>Aperture Image Viewer</Product>
<Description>A fast local image &amp; video browser for Windows.</Description>
Expand Down
8 changes: 7 additions & 1 deletion src/Aperture.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,14 @@
<StackPanel VerticalAlignment="Center" Margin="10,0,0,0">
<TextBlock Text="{Binding AppVersion}" Foreground="{StaticResource Ink}"
FontWeight="SemiBold" FontSize="13" TextWrapping="Wrap" />
<Border Background="#EEF1F6" CornerRadius="8" Padding="6,1" Margin="0,4,0,0"
HorizontalAlignment="Left"
ToolTip="This process is the AppData install when the caption is installed; anything else is portable.">
<TextBlock Text="{Binding InstallKindCaption}" Foreground="{StaticResource Muted}"
FontSize="10" />
</Border>
<TextBlock Text="A fast local image &amp; video browser" Foreground="{StaticResource Muted}"
FontSize="11" />
FontSize="11" Margin="0,4,0,0" />
</StackPanel>
</StackPanel>
<TextBlock Text="{Binding OnDiskVersionText}" Foreground="{StaticResource Accent}"
Expand Down
8 changes: 8 additions & 0 deletions src/Aperture.App/RegistrationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ public static bool IsRunningFromAppDataInstall()
catch { return false; }
}

/// <summary>
/// Caption the About surface and updater already share: "installed" when
/// this process <em>is</em> the AppData exe, otherwise "portable". Same
/// check as <see cref="IsRunningFromAppDataInstall"/> — not "a copy exists
/// under AppData while this process is still a download."
/// </summary>
public static string RunningKind => IsRunningFromAppDataInstall() ? "installed" : "portable";

// ===== AppData install + Start Menu =====

/// <summary>Copy the current exe to %LocalAppData%\Programs\Aperture.</summary>
Expand Down
74 changes: 53 additions & 21 deletions src/Aperture.App/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Quiet About caption: "installed" or "portable". Same source as the
/// updater — <see cref="RegistrationService.IsRunningFromAppDataInstall"/>.
/// </summary>
public string InstallKindCaption => RegistrationService.RunningKind;

private string _onDiskVersionText = "";
public string OnDiskVersionText
Expand All @@ -293,12 +292,12 @@ public string OnDiskVersionText

/// <summary>
/// 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 <see cref="AutostartPolicy.TargetExe"/>.
/// </summary>
private string? AutostartExePath =>
_installedThisSession || RegistrationService.IsRunningFromAppDataInstall()
? RegistrationService.AppDataInstallExe
: _exePath;
AutostartPolicy.TargetExe(
_exePath, RegistrationService.AppDataInstallExe,
RegistrationService.IsRunningFromAppDataInstall(), _installedThisSession);

// --- About / updater (hamburger header) --------------------------------

Expand Down Expand Up @@ -386,6 +385,7 @@ public void OnSettingsOpened()
private void RefreshInstallUi()
{
OnPropertyChanged(nameof(AppVersion));
OnPropertyChanged(nameof(InstallKindCaption));
OnPropertyChanged(nameof(ShowInstall));
OnPropertyChanged(nameof(ShowUninstall));
}
Expand Down Expand Up @@ -571,15 +571,24 @@ private void FailUpdate(string message)
Notify(message, warn: true);
}

private void InstallToThisPc()
private void InstallToThisPc() => TryInstallToThisPc(alreadyConfirmed: false);

/// <summary>
/// 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.
/// </summary>
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;

Expand Down Expand Up @@ -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<string>
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -929,21 +940,42 @@ public bool IncludeVideos

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
45 changes: 45 additions & 0 deletions src/Aperture.Core/Startup/AutostartPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Aperture.Core.Startup;

/// <summary>
/// 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.
/// </summary>
public static class AutostartPolicy
{
/// <summary>
/// True when turning autostart on would write a portable path. The UI must
/// ask to install first instead of calling <see cref="TryApply"/>.
/// </summary>
public static bool MustInstallFirst(bool enable, bool runningFromInstall, bool installedThisSession)
=> enable && !runningFromInstall && !installedThisSession;

/// <summary>
/// Path the Run key should name: the AppData exe after this process is (or
/// just became) the installed copy; otherwise the running portable path.
/// </summary>
public static string? TargetExe(
string? processExe, string installedExe, bool runningFromInstall, bool installedThisSession)
=> runningFromInstall || installedThisSession ? installedExe : processExe;

/// <summary>
/// Writes or deletes the Run value. Returns false and writes nothing when
/// <paramref name="enable"/> would target a portable path — the caller must
/// install first. Disabling always deletes the named value.
/// </summary>
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);
}
}
40 changes: 40 additions & 0 deletions tests/Aperture.Core.Tests/RegistrationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Aperture.App;
using Aperture.App.Updates;

namespace Aperture.Core.Tests;

Expand Down Expand Up @@ -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; }
}
}
90 changes: 90 additions & 0 deletions tests/Aperture.Core.Tests/WindowsStartupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading